mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 21:21:18 -04:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| dc9b576172 | |||
| f87abfbdca | |||
| 5e11cf5fc9 | |||
| f5917726d3 | |||
| eb22768a23 | |||
| 8644db2c2a | |||
| aca42423d3 | |||
| d9b81d2233 | |||
| 8fc65b3038 | |||
| f161c90057 | |||
| 728ae7c949 | |||
| 42fc297d30 | |||
| 43dd33842e | |||
| 3d13b6c5b6 | |||
| 996b05432f |
@@ -191,7 +191,7 @@
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -520,7 +520,7 @@
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.18.15",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-beta.101",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -700,6 +700,7 @@
|
||||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
@@ -916,7 +917,7 @@
|
||||
"diff": "catalog:",
|
||||
"dompurify": "3.3.1",
|
||||
"fuzzysort": "catalog:",
|
||||
"katex": "0.16.27",
|
||||
"katex": "0.16.47",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"marked-shiki": "catalog:",
|
||||
@@ -1053,9 +1054,9 @@
|
||||
],
|
||||
"patchedDependencies": {
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch",
|
||||
@@ -1067,6 +1068,7 @@
|
||||
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
@@ -1077,9 +1079,10 @@
|
||||
"catalog": {
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@corvu/drawer": "0.2.4",
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@effect/opentelemetry": "4.0.0-beta.107",
|
||||
"@effect/platform-node": "4.0.0-beta.107",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@kobalte/core": "0.13.11",
|
||||
@@ -1116,7 +1119,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"fuzzysort": "3.1.0",
|
||||
"get-east-asian-width": "1.6.0",
|
||||
"hono": "4.10.7",
|
||||
@@ -1563,13 +1566,13 @@
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.101", "", { "peerDependencies": { "@opentelemetry/api": "^1.9", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": "^2.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": "^2.0.0", "@opentelemetry/sdk-trace-base": "^2.0.0", "@opentelemetry/sdk-trace-node": "^2.0.0", "@opentelemetry/sdk-trace-web": "^2.0.0", "@opentelemetry/semantic-conventions": "^1.33.0", "effect": "^4.0.0-beta.101" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-IdejlqRLbjRHJgVnea4s8CxTWfvkSjM0HlnpNfP07IGTbhmAvPs7PMaWt1xzYWbOnI7CriQwxv+54eW1PwIkZg=="],
|
||||
"@effect/opentelemetry": ["@effect/opentelemetry@4.0.0-beta.107", "", { "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <2.0.0", "@opentelemetry/api-logs": ">=0.203.0 <0.300.0", "@opentelemetry/resources": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-logs": ">=0.203.0 <0.300.0", "@opentelemetry/sdk-metrics": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-base": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-node": ">=2.0.0 <3.0.0", "@opentelemetry/sdk-trace-web": ">=2.0.0 <3.0.0", "@opentelemetry/semantic-conventions": ">=1.33.0 <2.0.0", "effect": "^4.0.0-beta.107" }, "optionalPeers": ["@opentelemetry/api", "@opentelemetry/api-logs", "@opentelemetry/resources", "@opentelemetry/sdk-logs", "@opentelemetry/sdk-metrics", "@opentelemetry/sdk-trace-base", "@opentelemetry/sdk-trace-node", "@opentelemetry/sdk-trace-web"] }, "sha512-WxR3OEcwVtckNYGxvERA4kiS8cb2B46lSWxQw8P6dCCzW0j0VC7hkWyzryJ16MVXfI/5xQHS3r5j9mud+JVvsg=="],
|
||||
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.101", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.101", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101", "ioredis": "^5.7.0" } }, "sha512-pClk7dmMtHgM6Byu7CzGfrPvZ1/4BwmrRlCg2Op+iJozMkwVUxq8v4beK8b9SvxsliJjHZFznjvkVLX7LQjBqw=="],
|
||||
"@effect/platform-node": ["@effect/platform-node@4.0.0-beta.107", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.107", "mime": "^4.1.0", "undici": "^8.7.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107", "ioredis": ">=5.7.0 <6.0.0" } }, "sha512-k+6YNbV4Ck0L6YXtlgkvEnuP5tlxWD8EeWOrpn46PDqbGEwt4ONpRltTwm3tn2cyBXD0i+2P11cUH/6sdFagTA=="],
|
||||
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.101", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-g4L7XiyJSNJLJVhlslyg2zBCQsoKQf1y1gd+Yfd+3wD9ymC+m7ymbd/5FGqnT1aXV6E2AwRr4D/R1eyRUikvWQ=="],
|
||||
"@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.107", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.21.0" }, "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-y6BqcRi86BfTJv+tvDrob4ozYVHxxlHYcn/zIQqZjXI9CvKnkgD6ng+38G1o45c4f2ucU+6HRI9POCmFdMoVGA=="],
|
||||
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.101", "", { "peerDependencies": { "effect": "^4.0.0-beta.101" } }, "sha512-s6AC7LXCEjCN+nKegKFY4MOi6bmT1+SLR9YHEYwhY3P5qyQQB4R5yYLgt+3J4EPp5fa3t4FIDdPEUwz9LdKm6g=="],
|
||||
"@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.107", "", { "peerDependencies": { "effect": "^4.0.0-beta.107" } }, "sha512-BuSSCUoXz6JSR30wT4Y0ukmJKvIFhm/gROwEeA0nLO5QYf33CdTunwX+q35+/MNOOLT8jL3xaDsz5R5aAF1vYQ=="],
|
||||
|
||||
"@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="],
|
||||
|
||||
@@ -3871,7 +3874,7 @@
|
||||
|
||||
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
|
||||
|
||||
"effect": ["effect@4.0.0-beta.101", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "multipasta": "^0.2.8", "toml": "^4.1.2", "uuid": "^14.0.1", "yaml": "^2.9.0" } }, "sha512-HjowumlIo+orthn4jMlEJPuzIYPBV+uq/XiciHWhiedLsXQpWHdNJHO5d59BVDP5s1LPuvERcktwFqRXnJqnhA=="],
|
||||
"effect": ["effect@4.0.0-beta.107", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ=="],
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
@@ -4073,8 +4076,6 @@
|
||||
|
||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||
|
||||
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||
|
||||
"finity": ["finity@0.5.4", "", {}, "sha512-3l+5/1tuw616Lgb0QBimxfdd2TqaDGpfCBpfX6EqtFmqUV3FtQnVEX4Aa62DagYEqnsTIjZcTfbq9msDbXYgyA=="],
|
||||
@@ -4325,7 +4326,7 @@
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
|
||||
"ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
|
||||
|
||||
@@ -4523,7 +4524,7 @@
|
||||
|
||||
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
|
||||
|
||||
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
|
||||
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||
|
||||
@@ -4833,8 +4834,6 @@
|
||||
|
||||
"muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="],
|
||||
|
||||
"multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
|
||||
|
||||
"mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="],
|
||||
|
||||
"mysql2": ["mysql2@3.14.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", "generate-function": "^2.3.1", "iconv-lite": "^0.7.0", "long": "^5.2.1", "lru.min": "^1.0.0", "named-placeholders": "^1.1.3", "seq-queue": "^0.0.5", "sqlstring": "^2.3.2" } }, "sha512-Cs/jx3WZPNrYHVz+Iunp9ziahaG5uFMvD2R8Zlmc194AqXNxt9HBNu7ZsPYrUtmJsF0egETCWIdMIYAwOGjL1w=="],
|
||||
@@ -5641,8 +5640,6 @@
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
|
||||
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
|
||||
|
||||
"topojson-client": ["topojson-client@3.1.0", "", { "dependencies": { "commander": "2" }, "bin": { "topo2geo": "bin/topo2geo", "topomerge": "bin/topomerge", "topoquantize": "bin/topoquantize" } }, "sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw=="],
|
||||
@@ -6247,6 +6244,8 @@
|
||||
|
||||
"@dot/log/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@effect/platform-node-shared/ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
|
||||
"@electron/asar/glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="],
|
||||
@@ -6301,10 +6300,6 @@
|
||||
|
||||
"@modelcontextprotocol/sdk/jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="],
|
||||
|
||||
"@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="],
|
||||
|
||||
"@npmcli/query/postcss-selector-parser": ["postcss-selector-parser@7.1.5", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw=="],
|
||||
|
||||
"@octokit/auth-app/@octokit/request": ["@octokit/request@10.0.13", "", { "dependencies": { "@octokit/endpoint": "^11.0.3", "@octokit/request-error": "^7.1.1", "@octokit/types": "^17.0.0", "content-type": "^2.0.0", "json-with-bigint": "^3.5.3", "universal-user-agent": "^7.0.2" } }, "sha512-v2269YxL9Yf+x3d+gRI63FP0vFQEiWgLyBzxe/Y+0yFDg2B/Tzf5dhh9VNfccVAQnfcfwQWyk/y6Bn7rUXXs7A=="],
|
||||
@@ -6793,8 +6788,6 @@
|
||||
|
||||
"mermaid/dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
|
||||
|
||||
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
|
||||
|
||||
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
|
||||
|
||||
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
|
||||
@@ -7817,8 +7810,6 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
+7
-5
@@ -37,9 +37,10 @@
|
||||
"packages/slack"
|
||||
],
|
||||
"catalog": {
|
||||
"@effect/opentelemetry": "4.0.0-beta.101",
|
||||
"@effect/platform-node": "4.0.0-beta.101",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.101",
|
||||
"@effect/opentelemetry": "4.0.0-beta.107",
|
||||
"@effect/platform-node": "4.0.0-beta.107",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.107",
|
||||
"@effect/sql-sqlite-bun": "4.0.0-beta.107",
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@types/bun": "1.3.13",
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
@@ -70,7 +71,7 @@
|
||||
"dompurify": "3.3.1",
|
||||
"drizzle-kit": "1.0.0-rc.2",
|
||||
"drizzle-orm": "1.0.0-rc.2",
|
||||
"effect": "4.0.0-beta.101",
|
||||
"effect": "4.0.0-beta.107",
|
||||
"ai": "6.0.168",
|
||||
"cross-spawn": "7.0.6",
|
||||
"hono": "4.10.7",
|
||||
@@ -154,6 +155,7 @@
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"effect": "catalog:"
|
||||
@@ -164,6 +166,7 @@
|
||||
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"drizzle-orm@1.0.0-rc.2": "patches/drizzle-orm@1.0.0-rc.2.patch",
|
||||
"solid-js@1.9.10": "patches/solid-js@1.9.10.patch",
|
||||
"@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch",
|
||||
"gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch",
|
||||
@@ -171,7 +174,6 @@
|
||||
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
|
||||
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||
}
|
||||
|
||||
@@ -957,9 +957,12 @@ const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult =
|
||||
]
|
||||
}
|
||||
|
||||
const onMessageStop = (state: ParserState): StepResult => {
|
||||
const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (state: ParserState) {
|
||||
const result = yield* ToolStream.finishAll(ADAPTER, state.tools)
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
const finished = Lifecycle.finish(lifecycle, events, {
|
||||
reason: state.pendingFinish?.reason ?? {
|
||||
normalized: "unknown",
|
||||
raw: undefined,
|
||||
@@ -967,8 +970,8 @@ const onMessageStop = (state: ParserState): StepResult => {
|
||||
usage: state.usage,
|
||||
providerMetadata: state.pendingFinish?.providerMetadata,
|
||||
})
|
||||
return [{ ...state, lifecycle }, events]
|
||||
}
|
||||
return [{ ...state, lifecycle: finished, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Prefix `error.type` so overloads, rate limits, and quota errors are visible
|
||||
// even when the provider message is generic or empty.
|
||||
@@ -992,7 +995,7 @@ const step = (state: ParserState, event: AnthropicEvent) => {
|
||||
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
|
||||
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
|
||||
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
|
||||
if (event.type === "message_stop") return Effect.succeed(onMessageStop(state))
|
||||
if (event.type === "message_stop") return onMessageStop(state)
|
||||
if (event.type === "error") return onError(event)
|
||||
return Effect.succeed<StepResult>([state, NO_EVENTS])
|
||||
}
|
||||
|
||||
@@ -92,7 +92,7 @@ const GeminiFunctionCallPart = Schema.Struct({
|
||||
functionCall: Schema.Struct({
|
||||
id: Schema.optional(Schema.String),
|
||||
name: Schema.String,
|
||||
args: Schema.Unknown,
|
||||
args: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
thoughtSignature: Schema.optional(Schema.String),
|
||||
})
|
||||
@@ -166,6 +166,7 @@ const GeminiGenerationConfig = Schema.Struct({
|
||||
const GeminiBodyFields = {
|
||||
cachedContent: Schema.optional(Schema.String),
|
||||
contents: Schema.Array(GeminiContent),
|
||||
labels: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
safetySettings: optionalArray(GeminiSafetySetting),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
systemInstruction: Schema.optional(GeminiSystemInstruction),
|
||||
@@ -190,8 +191,19 @@ const GeminiCandidate = Schema.Struct({
|
||||
finishReason: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
const GeminiPromptFeedback = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
blockReason: Schema.optional(Schema.String),
|
||||
blockReasonMessage: Schema.optional(Schema.String),
|
||||
safetyRatings: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type GeminiPromptFeedback = Schema.Schema.Type<typeof GeminiPromptFeedback>
|
||||
|
||||
const GeminiEvent = Schema.Struct({
|
||||
candidates: optionalArray(GeminiCandidate),
|
||||
promptFeedback: Schema.optional(GeminiPromptFeedback),
|
||||
usageMetadata: Schema.optional(GeminiUsage),
|
||||
})
|
||||
type GeminiEvent = Schema.Schema.Type<typeof GeminiEvent>
|
||||
@@ -200,6 +212,7 @@ interface ParserState {
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly nextToolCallId: number
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
@@ -503,32 +516,37 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.finishReason || state.usage
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
raw: state.finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
})()
|
||||
: []
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const promptBlockReason = state.finishReason === undefined ? state.promptFeedback?.blockReason : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = state.reasoningSignature
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
"reasoning-0",
|
||||
googleMetadata({ thoughtSignature: state.reasoningSignature }),
|
||||
)
|
||||
: state.lifecycle
|
||||
Lifecycle.finish(lifecycle, events, {
|
||||
reason: {
|
||||
normalized:
|
||||
promptBlockReason === undefined ? mapFinishReason(finishReason, state.hasToolCalls) : "content-filter",
|
||||
raw: finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
providerMetadata:
|
||||
state.promptFeedback === undefined ? undefined : googleMetadata({ promptFeedback: state.promptFeedback }),
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
usage: event.usageMetadata ? (mapUsage(event.usageMetadata) ?? state.usage) : state.usage,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
@@ -569,7 +587,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const input = part.functionCall.args === undefined ? {} : part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
const metadata = {
|
||||
...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
|
||||
|
||||
@@ -202,14 +202,15 @@ export const errorText = (error: unknown) => {
|
||||
* decoder, and drops empty / `[DONE]` keep-alive events so the downstream
|
||||
* `decodeChunk` sees one JSON string per element. The SSE channel emits a
|
||||
* `Retry` control event on its error channel; we drop it here (we don't
|
||||
* implement client-driven retries) so the public error channel stays
|
||||
* `AIError`.
|
||||
* implement client-driven retries). Decoder failures become provider output
|
||||
* errors so the public error channel stays `AIError`.
|
||||
*/
|
||||
export const sseFraming = (bytes: Stream.Stream<Uint8Array, AIError>): Stream.Stream<string, AIError> =>
|
||||
bytes.pipe(
|
||||
Stream.decodeText(),
|
||||
Stream.pipeThroughChannel(Sse.decode()),
|
||||
Stream.catchTag("Retry", () => Stream.empty),
|
||||
Stream.catchTag("SseError", (error) => Stream.fail(eventError("sse", error.message))),
|
||||
Stream.filter((event) => event.data.length > 0 && event.data !== "[DONE]"),
|
||||
Stream.map((event) => event.data),
|
||||
)
|
||||
|
||||
@@ -57,7 +57,7 @@ export const isContextOverflowFailure = (failure: unknown) =>
|
||||
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
|
||||
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
|
||||
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
|
||||
const SERVER_CODES = new Set([
|
||||
"api_error",
|
||||
|
||||
@@ -1,14 +1,21 @@
|
||||
import { Effect } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { Gemini } from "../protocols/gemini.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderID, type LLMRequest, type ModelID, type ProviderOptions } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type GeminiOptionsInput = Gemini.OptionsInput
|
||||
export type GeminiProviderOptionsInput = Gemini.ProviderOptionsInput
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
readonly labels?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type GeminiProviderOptionsInput = ProviderOptions & {
|
||||
readonly gemini?: GeminiOptionsInput
|
||||
}
|
||||
|
||||
export const id = ProviderID.make("google-vertex")
|
||||
|
||||
@@ -17,7 +24,7 @@ export type Config = RouteDefaultsInput &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
readonly providerOptions?: GeminiProviderOptionsInput
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
@@ -28,14 +35,33 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
readonly providerOptions?: GeminiProviderOptionsInput
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const body = yield* Gemini.protocol.body.from(request)
|
||||
const value = request.providerOptions?.gemini?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
...Gemini.protocol,
|
||||
body: {
|
||||
...Gemini.protocol.body,
|
||||
from: fromRequest,
|
||||
},
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-gemini",
|
||||
provider: id,
|
||||
providerMetadataKey: "google",
|
||||
protocol: Gemini.protocol,
|
||||
protocol,
|
||||
endpoint: Endpoint.path(({ request }) => {
|
||||
const model = String(request.model.id)
|
||||
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
|
||||
@@ -78,7 +104,7 @@ export const configure = (input: Config = {}) => {
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configuredRoute(input, modelID).model<Gemini.ProviderOptionsInput>({ id: modelID }),
|
||||
configuredRoute(input, modelID).model<GeminiProviderOptionsInput>({ id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -87,10 +113,7 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
) => {
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined && settings.accessToken !== undefined)
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
return configure({
|
||||
|
||||
@@ -148,7 +148,7 @@ export const AIErrorReason = Schema.Union([
|
||||
]).pipe(Schema.toTaggedUnion("_tag"))
|
||||
export type AIErrorReason = Schema.Schema.Type<typeof AIErrorReason>
|
||||
|
||||
export class AIError extends Schema.TaggedErrorClass<AIError>()("AI.Error", {
|
||||
export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: AIErrorReason,
|
||||
|
||||
@@ -955,6 +955,37 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("settles pending tool calls at message_stop", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "tool_use", id: "call_1", name: "lookup" },
|
||||
},
|
||||
{
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":"weather"}' },
|
||||
},
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 1 } },
|
||||
{ type: "message_stop" },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toMatchObject([
|
||||
{ id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
])
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "tool_use" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("assembles and persists multiple tool calls from one Anthropic response", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
|
||||
@@ -75,7 +75,6 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(seen).toEqual([{ url: "https://mantle.test/v1/chat/completions", authorization: "Bearer test-key" }])
|
||||
|
||||
@@ -702,6 +702,80 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("leaves unsigned parallel calls unchanged after a signed Gemini 3 call", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: { google: { thoughtSignature: "parallel_signature" } },
|
||||
}),
|
||||
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
|
||||
ToolCallPart.make({ id: "tool_2", name: "lookup", input: { query: "sports" } }),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "parallel_signature",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "sports" } },
|
||||
thoughtSignature: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds the validator bypass sentinel to every call in an unsigned Gemini 3 batch", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: gemini3,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({ id: "tool_0", name: "lookup", input: { query: "weather" } }),
|
||||
ToolCallPart.make({ id: "tool_1", name: "lookup", input: { query: "news" } }),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
{
|
||||
functionCall: { id: undefined, name: "lookup", args: { query: "news" } },
|
||||
thoughtSignature: "skip_thought_signature_validator",
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
@@ -767,6 +841,31 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("defaults omitted function call args to an empty object", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
tools: [ToolDefinition.make({ name: "ping", description: "Ping", inputSchema: { type: "object" } })],
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ functionCall: { name: "ping" } }] },
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toEqual([{ type: "tool-call", id: "tool_0", name: "ping", input: {} }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps tool calls without a finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
@@ -862,6 +961,51 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves candidate-less prompt safety blocks as content-filter outcomes", () =>
|
||||
Effect.gen(function* () {
|
||||
const blocked = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents({
|
||||
promptFeedback: {
|
||||
blockReason: "FUTURE_SAFETY_REASON",
|
||||
blockReasonMessage: "Prompt blocked",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||
},
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
const blockedWithUsage = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ promptFeedback: { blockReason: "SAFETY" } },
|
||||
{ usageMetadata: { promptTokenCount: 7, totalTokenCount: 7 } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(blocked.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
|
||||
expect(blocked.events.at(-1)).toMatchObject({
|
||||
type: "finish",
|
||||
reason: { normalized: "content-filter", raw: "FUTURE_SAFETY_REASON" },
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptFeedback: {
|
||||
blockReason: "FUTURE_SAFETY_REASON",
|
||||
blockReasonMessage: "Prompt blocked",
|
||||
safetyRatings: [{ category: "HARM_CATEGORY_HARASSMENT", blocked: true }],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(blockedWithUsage.finishReason).toEqual({ normalized: "content-filter", raw: "SAFETY" })
|
||||
expect(blockedWithUsage.usage).toMatchObject({ inputTokens: 7, totalTokens: 7 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps current blocking and invalid-output finish reasons", () =>
|
||||
Effect.gen(function* () {
|
||||
const reasons = [
|
||||
|
||||
@@ -54,6 +54,27 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("adds billing labels to Vertex Gemini requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
providerOptions: {
|
||||
gemini: { labels: { component: "opencode", environment: "test" } },
|
||||
},
|
||||
}).model("gemini-3.5-flash"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).toMatchObject({
|
||||
labels: { component: "opencode", environment: "test" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("projects Anthropic Messages onto the Vertex raw-predict API", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = GoogleVertexMessages.configure({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses.js"
|
||||
import {
|
||||
@@ -90,6 +90,18 @@ describe("AI.Usage", () => {
|
||||
expect(ProviderShared.sumTokens()).toBeUndefined()
|
||||
})
|
||||
|
||||
test("sseFraming maps decoder failures to AI errors", async () => {
|
||||
const error = await Effect.runPromise(
|
||||
ProviderShared.sseFraming(Stream.make(new TextEncoder().encode(`data: ${"x".repeat(10 * 1024 * 1024)}`))).pipe(
|
||||
Stream.runCollect,
|
||||
Effect.flip,
|
||||
),
|
||||
)
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
})
|
||||
|
||||
test("visibleOutputTokens clamps reasoning > output to zero", () => {
|
||||
expect(new Usage({ outputTokens: 10, reasoningTokens: 4 }).visibleOutputTokens).toBe(6)
|
||||
expect(new Usage({ outputTokens: 10 }).visibleOutputTokens).toBe(10)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -97,13 +98,15 @@ test.describe("timeline adverse visual stability", () => {
|
||||
element.scrollTop = 0
|
||||
})
|
||||
await page.waitForTimeout(300)
|
||||
const trigger = page.locator(`[data-timeline-part-id="${targetID}"] [data-slot="collapsible-trigger"]`)
|
||||
const trigger = page.locator(
|
||||
`[data-timeline-part-id="${renderedPartID(targetID)}"] [data-slot="collapsible-trigger"]`,
|
||||
)
|
||||
await expect(trigger).toBeVisible()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await expect(page.locator(`[data-timeline-part-id="${targetID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(targetID)}"]`)).toHaveCount(0)
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
await expect(trigger).toBeVisible()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
@@ -127,13 +130,16 @@ test.describe("timeline adverse visual stability", () => {
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -184,10 +190,13 @@ test.describe("timeline adverse visual stability", () => {
|
||||
})
|
||||
const group = `[data-timeline-part-ids="${contextIDs.join(",")}"]`
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
defineVisualRegions,
|
||||
reportVisualStability,
|
||||
startVisualProbe,
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
event,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
const inputs = {
|
||||
read: { filePath: "src/a.ts", offset: 0, limit: 120 },
|
||||
glob: { path: ".", pattern: "**/*.ts" },
|
||||
grep: { path: ".", pattern: "stable", include: "*.ts" },
|
||||
list: { path: "src" },
|
||||
}
|
||||
|
||||
test("appends context operations while the group is expanded", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_append_01_read"
|
||||
const followingID = "prt_append_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(firstID, "read", "running", inputs.read), textPart(followingID, "Following append")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const initialGroup = `[data-timeline-part-ids="${firstID}"]`
|
||||
await page.locator(`${initialGroup} [data-slot="collapsible-trigger"]`).click()
|
||||
await waitForVisualSettle(page, [initialGroup, `[data-timeline-part-id="${followingID}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids^="prt_append_01_read"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_02_glob", "glob", "running", inputs.glob)), 180)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_03_grep", "grep", "completed", inputs.grep)), 240)
|
||||
await timeline.send(partUpdated(toolPart("prt_append_04_list", "list", "completed", inputs.list)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-append",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "stable", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: ["following"], maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "preserve-bottom-anchor" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(
|
||||
page.locator(
|
||||
'[data-timeline-part-ids="prt_append_01_read,prt_append_02_glob,prt_append_03_grep,prt_append_04_list"]',
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(
|
||||
page.locator('[data-timeline-part-ids^="prt_append_01_read"] [data-slot="collapsible-trigger"]'),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
|
||||
test("splits and merges context groups when a middle text part changes", async ({ page }, testInfo) => {
|
||||
const textID = "prt_split_02_text"
|
||||
const followingID = "prt_split_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart("prt_split_01_read", "read", "completed", inputs.read),
|
||||
textPart(textID, "Boundary"),
|
||||
toolPart("prt_split_03_glob", "glob", "completed", inputs.glob),
|
||||
textPart(followingID, "Following split groups"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: textID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read,prt_split_03_glob"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart(textID, "Boundary restored")), 500)
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_01_read"]')).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-part-ids="prt_split_03_glob"]')).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-split-merge",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["following"] },
|
||||
{ type: "unique", regions: ["following"] },
|
||||
{ type: "stable", regions: ["following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("removing the first context member replaces the group once without overlapping following content", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const ids = ["prt_key_01_read", "prt_key_02_glob", "prt_key_03_grep"]
|
||||
const followingID = "prt_key_99_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(ids[0]!, "read", "completed", inputs.read),
|
||||
toolPart(ids[1]!, "glob", "completed", inputs.glob),
|
||||
toolPart(ids[2]!, "grep", "completed", inputs.grep),
|
||||
textPart(followingID, "Following replaced group"),
|
||||
]),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const original = page.locator(`[data-timeline-part-ids="${ids.join(",")}"]`)
|
||||
const originalRowKey = await original.evaluate((element) =>
|
||||
element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key"),
|
||||
)
|
||||
await original.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(original.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true")
|
||||
const regions = defineVisualRegions({
|
||||
context: {
|
||||
selector: '[data-timeline-part-ids*="prt_key_02_glob"]',
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: ids[0] }),
|
||||
500,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"context-first-remove",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["context", "following"] },
|
||||
{ type: "unique", regions: ["context", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["context", "following"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)).toBeVisible()
|
||||
expect(
|
||||
await page
|
||||
.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"]`)
|
||||
.evaluate((element) => element.closest("[data-timeline-key]")?.getAttribute("data-timeline-key")),
|
||||
).toBe(originalRowKey)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-ids="${ids.slice(1).join(",")}"] [data-slot="collapsible-trigger"]`),
|
||||
).toHaveAttribute("aria-expanded", "true")
|
||||
})
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -34,13 +35,16 @@ for (const deviceScaleFactor of [1, 1.25]) {
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -71,13 +75,16 @@ for (const reducedMotion of [true]) {
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
@@ -43,11 +44,17 @@ for (const profile of profiles) {
|
||||
settings: { editToolPartsExpanded: true },
|
||||
cpuRate: 4,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${partID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${partID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
tool: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
@@ -35,12 +36,23 @@ test("adds patch files incrementally without resetting outer expansion", async (
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${patchID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
const trigger = page
|
||||
.locator(`[data-timeline-part-id="${renderedPartID(patchID)}"] [data-slot="collapsible-trigger"]`)
|
||||
.first()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${patchID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(patchID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
patch: { selector: `[data-timeline-part-id="${patchID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
patch: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(patchID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
const second = patchFile("src/b.ts", "add")
|
||||
|
||||
@@ -17,19 +17,28 @@ describe("timeline fixture validation", () => {
|
||||
test("rejects malformed SDK values at runtime", () => {
|
||||
expect(() =>
|
||||
assistantMessage([], {
|
||||
error: { name: "APIError", data: { message: "failed" } } as never,
|
||||
error: { type: "APIError", message: 1 } as never,
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() =>
|
||||
validateTimelineEvent({
|
||||
directory: "C:/OpenCode/TimelineStability",
|
||||
payload: {
|
||||
id: "evt_invalid_status",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||
},
|
||||
id: "evt_invalid_status",
|
||||
created: 1,
|
||||
type: "session.status",
|
||||
data: { sessionID: "ses_timeline_stability", status: { type: "retry", attempt: 1 } },
|
||||
}),
|
||||
).toThrow()
|
||||
expect(() => validateTimelineMessages([{ ...userMessage(), id: "invalid" } as never])).toThrow()
|
||||
expect(() => validateTimelineMessages([{ ...userMessage(), time: { created: "invalid" } } as never])).toThrow()
|
||||
expect(() =>
|
||||
validateTimelineMessages([
|
||||
userMessage(),
|
||||
{
|
||||
...assistantMessage(),
|
||||
content: [{ type: "tool", id: "call_invalid", name: "bash", state: { status: "completed" } }],
|
||||
} as never,
|
||||
]),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects duplicate IDs and orphan assistants", () => {
|
||||
@@ -42,8 +51,8 @@ describe("timeline fixture validation", () => {
|
||||
test("assigns deterministic event IDs", () => {
|
||||
const first = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "busy" } })
|
||||
const second = event("session.status", { sessionID: "ses_timeline_stability", status: { type: "idle" } })
|
||||
expect(first.payload.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||
expect(Number(second.payload.id.slice(-4))).toBe(Number(first.payload.id.slice(-4)) + 1)
|
||||
expect(first.id).toMatch(/^evt_timeline_\d{4}$/)
|
||||
expect(Number(second.id.slice(-4))).toBe(Number(first.id.slice(-4)) + 1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,16 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { SessionStatusEvent } from "@opencode-ai/schema/session-status-event"
|
||||
import { SessionV1 } from "@opencode-ai/schema/session-v1"
|
||||
import type { SessionInfo, SessionMessageInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type { AssistantMessage, Message, Part, ToolPart, ToolState, UserMessage } from "../../../src/types"
|
||||
import type {
|
||||
JsonValue,
|
||||
OpenCodeEvent,
|
||||
SessionInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionMessageUser,
|
||||
SessionStatus,
|
||||
SessionStructuredError,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { EventManifest } from "@opencode-ai/schema/event-manifest"
|
||||
import { SessionMessage } from "@opencode-ai/schema/session-message"
|
||||
import { expect, type Page } from "@playwright/test"
|
||||
import { Schema } from "effect"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -18,50 +25,80 @@ export const assistantID = "msg_1001_timeline_assistant"
|
||||
export const title = "Timeline visual stability"
|
||||
export const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const tokens = { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
|
||||
type Session = SessionInfo
|
||||
type GlobalEvent = {
|
||||
directory: string
|
||||
project?: string
|
||||
workspace?: string
|
||||
payload: {
|
||||
id: string
|
||||
type: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
type TextSeed = {
|
||||
id: string
|
||||
type: "text"
|
||||
text: string
|
||||
messageID?: string
|
||||
}
|
||||
type FileSeed = {
|
||||
id: string
|
||||
type: "file"
|
||||
mime: string
|
||||
filename?: string
|
||||
url: string
|
||||
source?: { type: string; path?: string; text?: { value: string; start: number; end: number } }
|
||||
}
|
||||
type AgentSeed = {
|
||||
id: string
|
||||
type: "agent"
|
||||
name: string
|
||||
source?: { value: string; start: number; end: number }
|
||||
}
|
||||
type ReasoningSeed = {
|
||||
id: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
time?: { start: number; end?: number }
|
||||
metadata?: Record<string, unknown>
|
||||
messageID?: string
|
||||
}
|
||||
type ToolSeed = {
|
||||
id: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
messageID?: string
|
||||
executed?: boolean
|
||||
providerState?: Record<string, unknown>
|
||||
providerResultState?: Record<string, unknown>
|
||||
state:
|
||||
| { status: "pending"; input: Record<string, unknown>; raw: string }
|
||||
| {
|
||||
status: "running"
|
||||
input: Record<string, unknown>
|
||||
title?: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number }
|
||||
}
|
||||
| {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
| {
|
||||
status: "error"
|
||||
input: Record<string, unknown>
|
||||
error: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
type TimelineProperties = {
|
||||
"message.updated": { sessionID: string; info: Message }
|
||||
"message.removed": { sessionID: string; messageID: string }
|
||||
"message.part.updated": { sessionID: string; part: Part; time: number }
|
||||
"message.part.removed": { sessionID: string; messageID: string; partID: string }
|
||||
"message.part.delta": { sessionID: string; messageID: string; partID: string; field: string; delta: string }
|
||||
"session.status": { sessionID: string; status: SessionStatus }
|
||||
}
|
||||
type TimelinePayload = {
|
||||
[Type in keyof TimelineProperties]: { id: string; type: Type; properties: TimelineProperties[Type] }
|
||||
}[keyof TimelineProperties]
|
||||
|
||||
type DeepReadonly<Value> = Value extends readonly unknown[]
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value extends object
|
||||
? { readonly [Key in keyof Value]: DeepReadonly<Value[Key]> }
|
||||
: Value
|
||||
|
||||
export type TimelineEvent = DeepReadonly<Omit<GlobalEvent, "payload"> & { payload: TimelinePayload }>
|
||||
export type EventPayload = TimelineEvent
|
||||
export type ToolStatus = ToolState["status"]
|
||||
export type TimelineMessage = { info: UserMessage; parts: Part[] } | { info: AssistantMessage; parts: Part[] }
|
||||
|
||||
type UserPart = Extract<Part, { type: "text" | "file" | "agent" | "subtask" }>
|
||||
type AssistantPart = Exclude<Part, { type: "agent" | "subtask" }>
|
||||
type OwnedPart<Owner extends Message["role"]> = Owner extends "user" ? UserPart : AssistantPart
|
||||
export type PartSeed<Owner extends Message["role"]> =
|
||||
OwnedPart<Owner> extends infer Candidate
|
||||
? Candidate extends Part
|
||||
? Omit<Candidate, "sessionID" | "messageID">
|
||||
: never
|
||||
: never
|
||||
export type TimelineMessage = SessionMessageUser | SessionMessageAssistant
|
||||
export type TimelineEvent = OpenCodeEvent | readonly OpenCodeEvent[]
|
||||
export type EventPayload = OpenCodeEvent
|
||||
export type ToolStatus = ToolSeed["state"]["status"]
|
||||
export type PartSeed<Owner extends "user" | "assistant"> = Owner extends "user"
|
||||
? TextSeed | FileSeed | AgentSeed
|
||||
: TextSeed | ReasoningSeed | ToolSeed
|
||||
|
||||
type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||
? { output?: never; title?: never; metadata?: never; error?: never }
|
||||
@@ -71,26 +108,18 @@ type ToolOptions<State extends ToolStatus> = State extends "pending"
|
||||
? { error?: string; metadata?: Record<string, unknown>; output?: never; title?: never }
|
||||
: { output?: string; title?: string; metadata?: Record<string, unknown>; error?: never }
|
||||
|
||||
const decodeOptions = { errors: "all", onExcessProperty: "error" } as const
|
||||
const decodeMessage = Schema.decodeUnknownSync(SessionV1.WithParts)
|
||||
const decodePart = Schema.decodeUnknownSync(SessionV1.Part)
|
||||
const decodeStatus = Schema.decodeUnknownSync(SessionStatusEvent.Info)
|
||||
const timelineEventSchema = Schema.Union([
|
||||
eventSchema("message.updated", SessionV1.Event.MessageUpdated.data),
|
||||
eventSchema("message.removed", SessionV1.Event.MessageRemoved.data),
|
||||
eventSchema("message.part.updated", SessionV1.Event.PartUpdated.data),
|
||||
eventSchema("message.part.removed", SessionV1.Event.PartRemoved.data),
|
||||
eventSchema("message.part.delta", SessionV1.Event.PartDelta.data),
|
||||
eventSchema("session.status", SessionStatusEvent.Status.data),
|
||||
])
|
||||
const decodeEvent = Schema.decodeUnknownSync(timelineEventSchema)
|
||||
type PartRef = { messageID: string; type: "text" | "reasoning" | "tool"; ordinal?: number }
|
||||
const partRefs = new Map<string, PartRef>()
|
||||
const nextOrdinals = new Map<string, { text: number; reasoning: number }>()
|
||||
const startedParts = new Set<string>()
|
||||
const toolStates = new Map<string, ToolStatus>()
|
||||
let eventSequence = 0
|
||||
|
||||
export async function setupTimeline(
|
||||
page: Page,
|
||||
input: {
|
||||
messages?: TimelineMessage[]
|
||||
currentMessages?: SessionMessageInfo[]
|
||||
sessionMessages?: SessionMessageInfo[]
|
||||
sessionStatus?: Record<string, SessionStatus>
|
||||
settings?: Record<string, boolean>
|
||||
sessions?: Session[]
|
||||
@@ -105,38 +134,22 @@ export async function setupTimeline(
|
||||
) {
|
||||
const sessions = input.sessions ?? [session()]
|
||||
const messages =
|
||||
input.currentMessages ??
|
||||
input.sessionMessages ??
|
||||
validateTimelineMessages([
|
||||
...(input.seedHistory ? historyMessages(18) : []),
|
||||
...(input.messages ?? [userMessage(), assistantMessage()]),
|
||||
])
|
||||
const active = messages.findLast((message) =>
|
||||
"info" in message ? message.info.role === "assistant" : message.type === "assistant",
|
||||
)
|
||||
const initialStatus = decodeStatus(
|
||||
active &&
|
||||
("info" in active
|
||||
? active.info.role === "assistant" && active.info.time.completed === undefined
|
||||
: active.type === "assistant" && active.time.completed === undefined)
|
||||
? { type: "busy" }
|
||||
: { type: "idle" },
|
||||
decodeOptions,
|
||||
)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const transport = await installSseTransport<EventPayload>(page, {
|
||||
server,
|
||||
retry: input.eventRetry ?? 20,
|
||||
})
|
||||
const active = messages.findLast((message) => message.type === "assistant")
|
||||
const initialStatus: SessionStatus =
|
||||
active?.type === "assistant" && active.time.completed === undefined ? { type: "busy" } : { type: "idle" }
|
||||
const transport = await installSseTransport(page, { server, retry: input.eventRetry ?? 20 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: project(),
|
||||
provider: provider(),
|
||||
sessions,
|
||||
sessionStatus: input.sessionStatus ?? { [sessionID]: initialStatus },
|
||||
pageMessages: () => ({
|
||||
items: messages,
|
||||
}),
|
||||
pageMessages: () => ({ items: messages }),
|
||||
})
|
||||
await page.addInitScript((settings) => {
|
||||
localStorage.setItem(
|
||||
@@ -169,7 +182,7 @@ export async function setupTimeline(
|
||||
mobile: false,
|
||||
})
|
||||
}
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await transport.waitForConnection()
|
||||
if (input.cpuRate && input.cpuRate > 1) {
|
||||
@@ -179,15 +192,25 @@ export async function setupTimeline(
|
||||
|
||||
return {
|
||||
transport,
|
||||
async send(event: TimelineEvent, delay = 0) {
|
||||
const valid = validateTimelineEvent(event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
async send(input: TimelineEvent, delay = 0) {
|
||||
const events = timelineEvents(input)
|
||||
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
|
||||
if (events.length > 1)
|
||||
await transport.burst(
|
||||
events,
|
||||
events.map((item) => ({ marker: describeEvent(item) })),
|
||||
)
|
||||
if (delay) await page.waitForTimeout(delay)
|
||||
},
|
||||
async sendAll(sequence: { event: TimelineEvent; delay: number }[]) {
|
||||
for (const item of sequence) {
|
||||
const valid = validateTimelineEvent(item.event)
|
||||
await transport.send(valid, { marker: describeEvent(valid) })
|
||||
const events = timelineEvents(item.event)
|
||||
if (events.length === 1) await transport.send(events[0]!, { marker: describeEvent(events[0]!) })
|
||||
if (events.length > 1)
|
||||
await transport.burst(
|
||||
events,
|
||||
events.map((event) => ({ marker: describeEvent(event) })),
|
||||
)
|
||||
await page.waitForTimeout(item.delay)
|
||||
}
|
||||
},
|
||||
@@ -207,72 +230,63 @@ export async function setupTimeline(
|
||||
)
|
||||
},
|
||||
async waitForPart(partID: string) {
|
||||
const part = page.locator(`[data-timeline-part-id="${partID}"]`)
|
||||
const part = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`)
|
||||
await expect(part).toHaveCount(1)
|
||||
await expect(part).toBeVisible()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function describeEvent(event: EventPayload) {
|
||||
if (event.payload.type === "message.part.updated") {
|
||||
const part = event.payload.properties.part
|
||||
return [
|
||||
event.payload.type,
|
||||
part.id,
|
||||
part.type === "tool" ? part.tool : part.type,
|
||||
part.type === "tool" ? part.state.status : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(":")
|
||||
}
|
||||
if (event.payload.type === "session.status") {
|
||||
const status = event.payload.properties.status
|
||||
return [event.payload.type, status.type, status.type === "retry" ? status.attempt : undefined]
|
||||
.filter((value) => value !== undefined)
|
||||
.join(":")
|
||||
}
|
||||
return event.payload.type
|
||||
function timelineEvents(input: TimelineEvent) {
|
||||
return (Array.isArray(input) ? input : [input]).map(validateTimelineEvent)
|
||||
}
|
||||
|
||||
export function event<const Type extends TimelinePayload["type"]>(
|
||||
type: Type,
|
||||
properties: Extract<TimelinePayload, { type: Type }>["properties"],
|
||||
): TimelineEvent
|
||||
export function event(type: TimelinePayload["type"], properties: TimelinePayload["properties"]): TimelineEvent {
|
||||
return validateTimelineEvent({
|
||||
directory,
|
||||
payload: { id: `evt_timeline_${String(++eventSequence).padStart(4, "0")}`, type, properties },
|
||||
})
|
||||
function describeEvent(event: OpenCodeEvent) {
|
||||
if (event.type.startsWith("session.tool.")) {
|
||||
const data = event.data as { id?: string }
|
||||
return [event.type, data.id].filter(Boolean).join(":")
|
||||
}
|
||||
return event.type
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): TimelineEvent {
|
||||
return decodeEvent(input, decodeOptions) as TimelineEvent
|
||||
export function event(
|
||||
type: "session.status",
|
||||
data: Extract<OpenCodeEvent, { type: "session.status" }>["data"],
|
||||
): OpenCodeEvent {
|
||||
return makeEvent(type, data)
|
||||
}
|
||||
|
||||
export function validateTimelineEvent(input: unknown): OpenCodeEvent {
|
||||
if (!input || typeof input !== "object") throw new Error("Timeline event must be an object")
|
||||
if (!("type" in input) || typeof input.type !== "string") throw new Error("Timeline event requires a type")
|
||||
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === input.type)
|
||||
if (!definition) throw new Error(`Unknown timeline event: ${input.type}`)
|
||||
return Schema.decodeUnknownSync(definition)(input) as OpenCodeEvent
|
||||
}
|
||||
|
||||
export function validateTimelineMessages(input: readonly TimelineMessage[]): TimelineMessage[] {
|
||||
input.forEach((message) => decodeMessage(message, decodeOptions))
|
||||
const messages = [...input]
|
||||
const messages = input.map((message): TimelineMessage => {
|
||||
const decoded = Schema.decodeUnknownSync(SessionMessage.Info)(message)
|
||||
if (decoded.type !== "user" && decoded.type !== "assistant")
|
||||
throw new Error(`Unsupported timeline message type: ${decoded.type}`)
|
||||
return message
|
||||
})
|
||||
const messageIDs = new Set<string>()
|
||||
const partIDs = new Set<string>()
|
||||
const users = new Set(messages.filter((message) => message.info.role === "user").map((message) => message.info.id))
|
||||
|
||||
let parentID: string | undefined
|
||||
messages.forEach((message) => {
|
||||
if (messageIDs.has(message.info.id))
|
||||
throw new Error(`Timeline fixture has duplicate message ID: ${message.info.id}`)
|
||||
messageIDs.add(message.info.id)
|
||||
if (message.info.role === "assistant" && !users.has(message.info.parentID))
|
||||
throw new Error(`Timeline assistant ${message.info.id} must reference a parent user in the fixture`)
|
||||
message.parts.forEach((part) => {
|
||||
if (part.sessionID !== message.info.sessionID || part.messageID !== message.info.id)
|
||||
throw new Error(`Timeline part ${part.id} ownership does not match message ${message.info.id}`)
|
||||
if (message.info.role === "user" && !["text", "file", "agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline user message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (message.info.role === "assistant" && ["agent", "subtask"].includes(part.type))
|
||||
throw new Error(`Timeline assistant message ${message.info.id} cannot own ${part.type} part ${part.id}`)
|
||||
if (partIDs.has(part.id)) throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||
partIDs.add(part.id)
|
||||
})
|
||||
if (messageIDs.has(message.id)) throw new Error(`Timeline fixture has duplicate message ID: ${message.id}`)
|
||||
messageIDs.add(message.id)
|
||||
if (message.type === "user") parentID = message.id
|
||||
if (message.type === "assistant") {
|
||||
const expected = typeof message.metadata?.parentID === "string" ? message.metadata.parentID : parentID
|
||||
if (!expected || expected !== parentID)
|
||||
throw new Error(`Timeline assistant ${message.id} must reference a parent user in the fixture`)
|
||||
message.content.forEach((part) => {
|
||||
if (part.type !== "tool") return
|
||||
if (partRefs.has(part.id) && partRefs.get(part.id)?.messageID !== message.id)
|
||||
throw new Error(`Timeline fixture has duplicate part ID: ${part.id}`)
|
||||
})
|
||||
}
|
||||
})
|
||||
return messages
|
||||
}
|
||||
@@ -334,22 +348,82 @@ export function historyMessages(count: number): TimelineMessage[] {
|
||||
}).flat()
|
||||
}
|
||||
|
||||
export function partUpdated(part: Part | PartSeed<"assistant">) {
|
||||
const owned = "messageID" in part ? part : { ...part, sessionID, messageID: assistantID }
|
||||
decodePart(owned, decodeOptions)
|
||||
return event("message.part.updated", {
|
||||
sessionID,
|
||||
part: owned,
|
||||
time: 1700000002000,
|
||||
})
|
||||
export function partUpdated(part: PartSeed<"assistant">): readonly OpenCodeEvent[] {
|
||||
const messageID = part.messageID ?? assistantID
|
||||
const started = startedParts.has(part.id)
|
||||
const ref = partRef(part.id, messageID, part.type)
|
||||
if (part.type === "text") {
|
||||
startedParts.add(part.id)
|
||||
return [
|
||||
...(started
|
||||
? []
|
||||
: [makeEvent("session.text.started", { sessionID, assistantMessageID: messageID, ordinal: ref.ordinal! })]),
|
||||
makeEvent("session.text.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal!,
|
||||
text: part.text,
|
||||
}),
|
||||
]
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
startedParts.add(part.id)
|
||||
return [
|
||||
...(started
|
||||
? []
|
||||
: [
|
||||
makeEvent("session.reasoning.started", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal!,
|
||||
state: jsonRecord(part.metadata),
|
||||
}),
|
||||
]),
|
||||
makeEvent("session.reasoning.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal!,
|
||||
text: part.text,
|
||||
state: jsonRecord(part.metadata),
|
||||
}),
|
||||
]
|
||||
}
|
||||
return toolEvents(part, messageID)
|
||||
}
|
||||
|
||||
export function renderedPartID(partID: string) {
|
||||
const ref = partRefs.get(partID)
|
||||
if (!ref || ref.type === "tool") return partID
|
||||
return `${ref.messageID}:${ref.type}:${ref.ordinal}`
|
||||
}
|
||||
|
||||
export function partDelta(partID: string, delta: string, messageID = assistantID) {
|
||||
return event("message.part.delta", { sessionID, messageID, partID, field: "text", delta })
|
||||
const ref = partRefs.get(partID)
|
||||
if (!ref || ref.type !== "text" || ref.ordinal === undefined) throw new Error(`Unknown text part: ${partID}`)
|
||||
return makeEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
ordinal: ref.ordinal,
|
||||
delta,
|
||||
})
|
||||
}
|
||||
|
||||
export function messageUpdated(info: Message) {
|
||||
return event("message.updated", { sessionID, info })
|
||||
export function messageUpdated(info: SessionMessageAssistant) {
|
||||
if (info.error)
|
||||
return makeEvent("session.step.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: info.id,
|
||||
error: info.error,
|
||||
cost: info.cost,
|
||||
tokens: info.tokens,
|
||||
})
|
||||
return makeEvent("session.step.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: info.id,
|
||||
finish: info.finish ?? "stop",
|
||||
cost: info.cost ?? 0,
|
||||
tokens: info.tokens ?? tokens,
|
||||
})
|
||||
}
|
||||
|
||||
export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
@@ -361,28 +435,45 @@ export function status(type: SessionStatus["type"], attempt = 1) {
|
||||
|
||||
export function userMessage(
|
||||
parts?: PartSeed<"user">[],
|
||||
input: { id?: string; summary?: UserMessage["summary"]; created?: number } = {},
|
||||
): Extract<TimelineMessage, { info: { role: "user" } }> {
|
||||
input: { id?: string; summary?: unknown; created?: number } = {},
|
||||
): SessionMessageUser {
|
||||
const id = input.id ?? userID
|
||||
const seeds = parts ?? [userText("Build the timeline stability matrix.", { id: `prt_${id}_text` })]
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
summary: input.summary ?? { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: seeds.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID: id,
|
||||
})),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "user" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
return {
|
||||
id,
|
||||
type: "user",
|
||||
time: { created: input.created ?? 1700000000000 },
|
||||
text: seeds.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
||||
files: seeds.flatMap((part) => {
|
||||
if (part.type !== "file") return []
|
||||
const mention = part.source?.text
|
||||
? { text: part.source.text.value, start: part.source.text.start, end: part.source.text.end }
|
||||
: undefined
|
||||
return [
|
||||
{
|
||||
data: part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? "",
|
||||
mime: part.mime,
|
||||
source: part.url.startsWith("data:")
|
||||
? ({ type: "inline" } as const)
|
||||
: ({ type: "uri", uri: part.source?.path ?? part.url } as const),
|
||||
...(part.filename ? { name: part.filename } : {}),
|
||||
...(mention ? { mention } : {}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
agents: seeds.flatMap((part) => {
|
||||
if (part.type !== "agent") return []
|
||||
return [
|
||||
{
|
||||
name: part.name,
|
||||
...(part.source
|
||||
? { mention: { text: part.source.value, start: part.source.start, end: part.source.end } }
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}),
|
||||
...(input.summary === undefined ? {} : { metadata: { summary: input.summary as JsonValue } }),
|
||||
}
|
||||
}
|
||||
|
||||
export function assistantMessage(
|
||||
@@ -391,49 +482,43 @@ export function assistantMessage(
|
||||
id?: string
|
||||
parentID?: string
|
||||
completed?: boolean
|
||||
error?: AssistantMessage["error"]
|
||||
error?: SessionStructuredError
|
||||
created?: number
|
||||
} = {},
|
||||
): Extract<TimelineMessage, { info: { role: "assistant" } }> {
|
||||
): SessionMessageAssistant {
|
||||
if (input.error && (typeof input.error.type !== "string" || typeof input.error.message !== "string"))
|
||||
throw new Error("Invalid assistant error")
|
||||
const id = input.id ?? assistantID
|
||||
const message = {
|
||||
info: {
|
||||
id,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: {
|
||||
created: input.created ?? 1700000001000,
|
||||
...(input.completed === false ? {} : { completed: (input.created ?? 1700000001000) + 1_000 }),
|
||||
},
|
||||
parentID: input.parentID ?? userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
},
|
||||
parts: parts.map((part) => ({ ...part, sessionID, messageID: id })),
|
||||
} satisfies Extract<TimelineMessage, { info: { role: "assistant" } }>
|
||||
decodeMessage(message, decodeOptions)
|
||||
return message
|
||||
const created = input.created ?? 1700000001000
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
const content = parts.map((part) => messageContent(part, id, ordinals))
|
||||
nextOrdinals.set(id, ordinals)
|
||||
return {
|
||||
id,
|
||||
type: "assistant",
|
||||
metadata: { parentID: input.parentID ?? userID },
|
||||
time: { created, ...(input.completed === false ? {} : { completed: created + 1_000 }) },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
content,
|
||||
cost: 0.01,
|
||||
tokens,
|
||||
...(input.completed === false ? {} : { finish: "stop" as const }),
|
||||
...(input.error ? { error: input.error } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function userText(
|
||||
text: string,
|
||||
input: Partial<Omit<Extract<PartSeed<"user">, { type: "text" }>, "type" | "text">> = {},
|
||||
): Extract<PartSeed<"user">, { type: "text" }> {
|
||||
export function userText(text: string, input: Partial<Omit<TextSeed, "type" | "text">> = {}): TextSeed {
|
||||
return { id: "prt_user_text", type: "text", text, ...input }
|
||||
}
|
||||
|
||||
export function textPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "text" }> {
|
||||
export function textPart(id: string, text: string): TextSeed {
|
||||
partRef(id, assistantID, "text")
|
||||
return { id, type: "text", text }
|
||||
}
|
||||
|
||||
export function reasoningPart(id: string, text: string): Extract<PartSeed<"assistant">, { type: "reasoning" }> {
|
||||
export function reasoningPart(id: string, text: string): ReasoningSeed {
|
||||
partRef(id, assistantID, "reasoning")
|
||||
return { id, type: "reasoning", text, time: { start: 1700000001000 } }
|
||||
}
|
||||
|
||||
@@ -443,35 +528,35 @@ export function toolPart(
|
||||
state: "pending",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"pending">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "running",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"running">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "completed",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"completed">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: "error",
|
||||
input: Record<string, unknown>,
|
||||
options?: ToolOptions<"error">,
|
||||
): Omit<ToolPart, "sessionID" | "messageID">
|
||||
): ToolSeed
|
||||
export function toolPart(
|
||||
id: string,
|
||||
tool: string,
|
||||
state: ToolStatus,
|
||||
input: Record<string, unknown>,
|
||||
options: ToolOptions<ToolStatus> = {},
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
): ToolSeed {
|
||||
const base = { id, type: "tool" as const, callID: id, tool }
|
||||
if (state === "pending") return { ...base, state: { status: state, input, raw: "" } }
|
||||
if (state === "running")
|
||||
@@ -509,12 +594,7 @@ export function toolPart(
|
||||
}
|
||||
}
|
||||
|
||||
export function shell(
|
||||
id: string,
|
||||
state: ToolStatus,
|
||||
output = "",
|
||||
command = `echo ${id}`,
|
||||
): Omit<ToolPart, "sessionID" | "messageID"> {
|
||||
export function shell(id: string, state: ToolStatus, output = "", command = `echo ${id}`): ToolSeed {
|
||||
if (state === "pending") return toolPart(id, "bash", state, { command })
|
||||
if (state === "running")
|
||||
return toolPart(id, "bash", state, { command }, { title: command, metadata: { command, output } })
|
||||
@@ -523,7 +603,7 @@ export function shell(
|
||||
return toolPart(id, "bash", state, { command }, { title: command, output, metadata: { command, output } })
|
||||
}
|
||||
|
||||
export function completedAssistantInfo(info: AssistantMessage): AssistantMessage {
|
||||
export function completedAssistantInfo(info: SessionMessageAssistant): SessionMessageAssistant {
|
||||
return { ...info, time: { ...info.time, completed: 1700000003000 } }
|
||||
}
|
||||
|
||||
@@ -551,16 +631,200 @@ export function session(input: Partial<Session> = {}): Session {
|
||||
}
|
||||
}
|
||||
|
||||
function eventSchema<
|
||||
const Type extends TimelinePayload["type"],
|
||||
const Properties extends Schema.Codec<unknown, unknown>,
|
||||
>(type: Type, properties: Properties) {
|
||||
return Schema.Struct({
|
||||
directory: Schema.String,
|
||||
project: Schema.optional(Schema.String),
|
||||
workspace: Schema.optional(Schema.String),
|
||||
payload: Schema.Struct({ id: Event.ID, type: Schema.Literal(type), properties }),
|
||||
})
|
||||
function messageContent(
|
||||
part: PartSeed<"assistant">,
|
||||
messageID: string,
|
||||
ordinals: { text: number; reasoning: number },
|
||||
): SessionMessageAssistant["content"][number] {
|
||||
if (part.type === "tool") {
|
||||
partRefs.set(part.id, { messageID, type: part.type })
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
} else {
|
||||
partRefs.set(part.id, { messageID, type: part.type, ordinal: ordinals[part.type]++ })
|
||||
startedParts.add(part.id)
|
||||
}
|
||||
if (part.type === "text") return { type: "text", text: part.text }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
state: jsonRecord(part.metadata),
|
||||
time: part.time
|
||||
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||
: undefined,
|
||||
}
|
||||
const state = part.state
|
||||
const time = "time" in state ? state.time : undefined
|
||||
const completed = state.status === "completed" || state.status === "error" ? state.time.end : undefined
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: {
|
||||
created: time?.start ?? 1700000001000,
|
||||
...(time?.start === undefined ? {} : { ran: time.start }),
|
||||
...(completed === undefined ? {} : { completed }),
|
||||
},
|
||||
...(part.executed === undefined ? {} : { executed: part.executed }),
|
||||
...(part.providerState ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||
...(part.providerResultState ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending") return { ...base, state: { status: "streaming", input: state.raw } }
|
||||
if (state.status === "running")
|
||||
return {
|
||||
...base,
|
||||
state: { status: "running", input: jsonRecord(state.input), metadata: jsonRecord(state.metadata) },
|
||||
}
|
||||
if (state.status === "error")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input: jsonRecord(state.input),
|
||||
error: { type: "ToolError", message: state.error },
|
||||
metadata: jsonRecord(state.metadata),
|
||||
},
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: jsonRecord(state.input),
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: jsonRecord(state.metadata),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function toolEvents(part: ToolSeed, messageID: string): readonly OpenCodeEvent[] {
|
||||
const previous = toolStates.get(part.callID)
|
||||
if (previous === "completed" || previous === "error") return []
|
||||
|
||||
const events: OpenCodeEvent[] = []
|
||||
if (!previous) {
|
||||
events.push(
|
||||
makeEvent("session.tool.input.started", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.state.status === "pending") {
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
if (!previous || previous === "pending") {
|
||||
events.push(
|
||||
makeEvent("session.tool.input.ended", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
text: JSON.stringify(part.state.input),
|
||||
}),
|
||||
makeEvent("session.tool.called", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: part.executed ?? true,
|
||||
state: jsonRecord(part.providerState),
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (part.state.status === "running") {
|
||||
if (previous === "running" || Object.keys(part.state.metadata).length)
|
||||
events.push(
|
||||
makeEvent("session.tool.progress", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
if (part.state.status === "error") {
|
||||
events.push(
|
||||
makeEvent("session.tool.failed", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
error: { type: "ToolError", message: part.state.error },
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
executed: part.executed ?? true,
|
||||
resultState: jsonRecord(part.providerResultState),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
events.push(
|
||||
makeEvent("session.tool.success", {
|
||||
sessionID,
|
||||
assistantMessageID: messageID,
|
||||
id: part.callID,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: jsonRecord(part.state.metadata),
|
||||
executed: part.executed ?? true,
|
||||
resultState: jsonRecord(part.providerResultState),
|
||||
}),
|
||||
)
|
||||
toolStates.set(part.callID, part.state.status)
|
||||
return events
|
||||
}
|
||||
|
||||
function partRef(id: string, messageID: string, type: PartRef["type"]): PartRef {
|
||||
const current = partRefs.get(id)
|
||||
if (current) return current
|
||||
if (type === "tool") {
|
||||
const ref = { messageID, type } satisfies PartRef
|
||||
partRefs.set(id, ref)
|
||||
return ref
|
||||
}
|
||||
const next = nextOrdinals.get(messageID) ?? { text: 0, reasoning: 0 }
|
||||
const ref = { messageID, type, ordinal: next[type]++ } satisfies PartRef
|
||||
nextOrdinals.set(messageID, next)
|
||||
partRefs.set(id, ref)
|
||||
return ref
|
||||
}
|
||||
|
||||
function makeEvent<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
): OpenCodeEvent {
|
||||
const id = `evt_timeline_${String(++eventSequence).padStart(4, "0")}`
|
||||
const base = { id, created: 1700000002000 + eventSequence, type, data, location: { directory } }
|
||||
const definition = EventManifest.ServerDefinitions.find((definition) => definition.type === type)
|
||||
if (!definition) throw new Error(`Unknown timeline event: ${type}`)
|
||||
const input =
|
||||
definition.durability === "durable"
|
||||
? {
|
||||
...base,
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version: definition.durable.version },
|
||||
}
|
||||
: base
|
||||
return Schema.decodeUnknownSync(definition)(input) as unknown as OpenCodeEvent
|
||||
}
|
||||
|
||||
function jsonRecord(value: Record<string, unknown> | undefined): Record<string, JsonValue> {
|
||||
if (!value) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).flatMap(([key, item]) => {
|
||||
const next = jsonValue(item)
|
||||
return next === undefined ? [] : [[key, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function jsonValue(value: unknown): JsonValue | undefined {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") return value
|
||||
if (typeof value === "number") return Number.isFinite(value) ? value : null
|
||||
if (Array.isArray(value)) return value.map((item) => jsonValue(item) ?? null)
|
||||
if (!value || typeof value !== "object") return
|
||||
return jsonRecord(value as Record<string, unknown>)
|
||||
}
|
||||
|
||||
function provider() {
|
||||
|
||||
@@ -6,7 +6,16 @@ import {
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, shell, textPart, toolPart, userMessage, waitForVisualSettle } from "./fixture"
|
||||
import {
|
||||
assistantMessage,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
waitForVisualSettle,
|
||||
} from "./fixture"
|
||||
|
||||
test("expands and collapses a long completed shell without overlap", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_interaction_01_shell"
|
||||
@@ -20,11 +29,20 @@ test("expands and collapses a long completed shell without overlap", async ({ pa
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
const plan = visualPlan(regions, [
|
||||
{ type: "required", regions: ["shell", "following"] },
|
||||
@@ -76,7 +94,7 @@ test("expands and collapses a completed context group without overlap", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`${group} [data-slot="collapsible-trigger"]`)
|
||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [group, `[data-timeline-part-id="${renderedPartID(followingID)}"]`])
|
||||
for (const [name, expanded] of [
|
||||
["context-expand", true],
|
||||
["context-collapse", false],
|
||||
@@ -85,7 +103,7 @@ test("expands and collapses a completed context group without overlap", async ({
|
||||
const regions = defineVisualRegions({
|
||||
context: { selector: group, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -144,11 +162,22 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${editID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
const trigger = page
|
||||
.locator(`[data-timeline-part-id="${renderedPartID(editID)}"] [data-slot="collapsible-trigger"]`)
|
||||
.first()
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(editID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
edit: { selector: `[data-timeline-part-id="${editID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
edit: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(editID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await trigger.click()
|
||||
@@ -173,64 +202,6 @@ test("expands and collapses an edit diff without moving twice", async ({ page },
|
||||
)
|
||||
})
|
||||
|
||||
test("shows all and expands historical diff summary without overlap", async ({ page }, testInfo) => {
|
||||
const firstUser = userMessage(undefined, {
|
||||
summary: {
|
||||
diffs: Array.from({ length: 12 }, (_, index) => ({
|
||||
file: `src/diff-${index}.ts`,
|
||||
status: "modified",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
patch: `@@ -1 +1 @@\n-export const value = ${index}\n+export const value = ${index + 1}`,
|
||||
})),
|
||||
},
|
||||
})
|
||||
const nextUserID = "msg_2000_diff_interaction_user"
|
||||
await setupTimeline(page, {
|
||||
messages: [
|
||||
firstUser,
|
||||
assistantMessage(),
|
||||
userMessage(undefined, { id: nextUserID, created: 1700000010000 }),
|
||||
assistantMessage([], {
|
||||
id: "msg_2001_diff_interaction_assistant",
|
||||
parentID: nextUserID,
|
||||
created: 1700000011000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
const diff = page.locator('[data-timeline-row="DiffSummary"]')
|
||||
const following = page.locator(`[data-message-id="${nextUserID}"]`).first()
|
||||
await expect(diff).toBeVisible()
|
||||
const regions = defineVisualRegions({
|
||||
diff: { selector: '[data-timeline-row="DiffSummary"]' },
|
||||
following: { selector: `[data-message-id="${nextUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByText(/show all/i).click()
|
||||
await page.waitForTimeout(500)
|
||||
await diff.locator('[data-slot="session-turn-diff-trigger"]').first().click()
|
||||
await page.waitForTimeout(900)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"diff-summary-expand",
|
||||
trace,
|
||||
visualPlan(regions, [
|
||||
{ type: "required", regions: ["diff", "following"] },
|
||||
{ type: "unique", regions: ["diff", "following"] },
|
||||
{ type: "stable", regions: ["diff", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 1, maxReversals: 2 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["diff", "following"] },
|
||||
]),
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => `line ${index + 1}`).join("\n")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
partDelta,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
@@ -50,7 +51,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
prt_shell_long: shellRegion(ids[2]),
|
||||
following: shellRegion(followingID),
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(followingID)}"]`])
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.sendAll([
|
||||
{ event: partUpdated(shell(ids[0]!, "completed", "")), delay: 180 },
|
||||
@@ -60,7 +61,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
{ event: partUpdated(shell(ids[1]!, "completed", lines(2))), delay: 260 },
|
||||
{ event: partUpdated(shell(ids[2]!, "running", lines(50))), delay: 100 },
|
||||
{ event: partUpdated(shell(ids[2]!, "completed", lines(50))), delay: 450 },
|
||||
{ event: messageUpdated(completedAssistantInfo(assistant.info)), delay: 100 },
|
||||
{ event: messageUpdated(completedAssistantInfo(assistant)), delay: 100 },
|
||||
{ event: status("idle"), delay: 700 },
|
||||
])
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
@@ -84,9 +85,11 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${ids[2]}"] [data-slot="bash-pre"]`)).toContainText("line 50")
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-id="${renderedPartID(ids[2])}"] [data-slot="bash-pre"]`),
|
||||
).toContainText("line 50")
|
||||
|
||||
const short = page.locator(`[data-timeline-part-id="${ids[1]}"]`)
|
||||
const short = page.locator(`[data-timeline-part-id="${renderedPartID(ids[1])}"]`)
|
||||
await short.locator('[data-slot="collapsible-trigger"]').click()
|
||||
await expect(short.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(textPart("prt_late_sibling", "A later sibling rerender.")), 250)
|
||||
@@ -106,26 +109,31 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
const initialReasoning = reasoningPart(reasoningID, "")
|
||||
const initialText = textPart(textID, "Starting")
|
||||
|
||||
const regions = defineVisualRegions({
|
||||
thinking: { selector: '[data-timeline-row="Thinking"]' },
|
||||
reasoning: {
|
||||
selector: `[data-timeline-part-id="${reasoningID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(reasoningID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
text: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "")), 100)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(initialReasoning), 100)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(reasoningID)}"]`)).toHaveCount(0)
|
||||
await timeline.send(partUpdated(reasoningPart(reasoningID, "## Planning\n\nChecking the visible timeline.")), 160)
|
||||
await timeline.waitForPart(reasoningID)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await timeline.send(partUpdated(textPart(textID, "Starting")), 100)
|
||||
await timeline.send(partUpdated(initialText), 100)
|
||||
await timeline.send(partDelta(textID, " **stable"), 90)
|
||||
await timeline.send(partDelta(textID, " output** with `code` and [a link"), 130)
|
||||
await timeline.send(partDelta(textID, "](https://example.com)."), 220)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 120)
|
||||
await timeline.send(status("idle"), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
@@ -144,7 +152,7 @@ test.describe("timeline visual lifecycle stability", () => {
|
||||
{ type: "flow", regions: ["reasoning", "text"] },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("stable output")
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText("stable output")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -153,5 +161,5 @@ function lines(count: number) {
|
||||
}
|
||||
|
||||
function shellRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
|
||||
@@ -6,14 +6,14 @@ import {
|
||||
stopVisualProbe,
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import { assistantMessage, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
import { assistantMessage, renderedPartID, setupTimeline, textPart, userMessage } from "./fixture"
|
||||
|
||||
test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_ancestor_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
|
||||
const regions = defineVisualRegions({
|
||||
content: { selector: `[data-timeline-part-id="${partID}"]` },
|
||||
content: { selector: `[data-timeline-part-id="${renderedPartID(partID)}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await row.evaluate((element) => {
|
||||
@@ -41,13 +41,13 @@ test("detects blanking caused by ancestor opacity", async ({ page }) => {
|
||||
test("detects root opacity when probing descendant opacity", async ({ page }) => {
|
||||
const partID = "prt_oracle_descendant_opacity"
|
||||
await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart(partID, "Visible content")])] })
|
||||
const row = page.locator(`[data-timeline-part-id="${partID}"]`).first()
|
||||
const row = page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"]`).first()
|
||||
await row.evaluate((element) => {
|
||||
element.innerHTML = '<span data-probe-opacity="true">Visible content</span>'
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
content: {
|
||||
selector: `[data-timeline-part-id="${partID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(partID)}"]`,
|
||||
opacitySelectors: ['[data-probe-opacity="true"]'],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -34,8 +35,14 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(30))), 80)
|
||||
@@ -143,8 +150,8 @@ test("tracks keyboard scrolling from a focused timeline descendant", async ({ pa
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const row = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
const trigger = page.locator(`[data-timeline-part-id="${shellID}"] [data-slot="collapsible-trigger"]`)
|
||||
const row = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
|
||||
const trigger = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-slot="collapsible-trigger"]`)
|
||||
await row.evaluate((element) => element.setAttribute("tabindex", "0"))
|
||||
await row.focus()
|
||||
for (let index = 0; index < 3; index++) {
|
||||
@@ -182,7 +189,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
|
||||
seedHistory: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const nested = page.locator(`[data-timeline-part-id="${shellID}"] [data-scrollable]`)
|
||||
const nested = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"] [data-scrollable]`)
|
||||
await nested.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await nested.focus()
|
||||
await page.waitForFunction(() => {
|
||||
@@ -209,7 +216,7 @@ test("does not claim keyboard scrolling owned by a nested scrollable", async ({
|
||||
await nested.press("PageUp")
|
||||
await expect.poll(() => scroller.evaluate((element) => element.scrollTop)).toBeLessThan(boundaryBefore)
|
||||
|
||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${shellID}"]`).first()
|
||||
const nonOverflowing = page.locator(`[data-timeline-part-id="${renderedPartID(shellID)}"]`).first()
|
||||
await nonOverflowing.evaluate((element) => {
|
||||
element.setAttribute("data-scrollable", "")
|
||||
element.setAttribute("tabindex", "0")
|
||||
@@ -238,12 +245,18 @@ test("jump to latest lands on stable final rows after offscreen growth", async (
|
||||
)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 300)
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await page.getByRole("button", { name: /Jump to latest/i }).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="${followingID}"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(followingID)}"]`)).toBeVisible()
|
||||
await page.waitForTimeout(600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
@@ -277,8 +290,14 @@ test("handles a single row taller than the viewport", async ({ page }, testInfo)
|
||||
seedHistory: true,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(100))), 700)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
textPart,
|
||||
@@ -68,13 +69,16 @@ for (const profile of profiles) {
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${shellID}"]`,
|
||||
`[data-timeline-part-id="${followingID}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -120,10 +124,19 @@ test("keeps following row stable when a collapsed shell receives 50 lines", asyn
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(50))), 240)
|
||||
@@ -164,10 +177,19 @@ test("keeps rows stable when a running shell becomes an error", async ({ page },
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`, `[data-timeline-part-id="${followingID}"]`])
|
||||
await waitForVisualSettle(page, [
|
||||
`[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
`[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
@@ -215,16 +237,20 @@ test("keeps rows stable when later text arrives before shell output", async ({ p
|
||||
cpuRate: 4,
|
||||
seedHistory: true,
|
||||
})
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${shellID}"]`])
|
||||
const following = textPart(followingID, "Later assistant content arrived before shell output.")
|
||||
await waitForVisualSettle(page, [`[data-timeline-part-id="${renderedPartID(shellID)}"]`])
|
||||
const regions = defineVisualRegions({
|
||||
shell: { selector: `[data-timeline-part-id="${shellID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
shell: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(shellID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${followingID}"]`,
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(followingID, "Later assistant content arrived before shell output.")), 240)
|
||||
await timeline.send(partUpdated(following), 240)
|
||||
await timeline.send(partUpdated(shell(shellID, "running", lines(20))), 300)
|
||||
await timeline.send(partUpdated(shell(shellID, "completed", lines(20))), 600)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
textPart,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
@@ -27,7 +27,7 @@ test("adds a task child-session link without replacing the task row", async ({ p
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
task: { selector: `[data-timeline-part-id="${taskID}"] [data-slot="collapsible-trigger"]` },
|
||||
task: { selector: `[data-timeline-part-id="${renderedPartID(taskID)}"] [data-slot="collapsible-trigger"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
@@ -53,54 +53,3 @@ test("adds a task child-session link without replacing the task row", async ({ p
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
})
|
||||
|
||||
test("changes generic tool arguments without replacing the row", async ({ page }, testInfo) => {
|
||||
const toolID = "prt_generic_mutation"
|
||||
const followingID = "prt_generic_mutation_following"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage(
|
||||
[
|
||||
toolPart(toolID, "mcp_probe", "running", { target: "one", count: 1 }),
|
||||
textPart(followingID, "Following generic tool"),
|
||||
],
|
||||
{ completed: false },
|
||||
),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
tool: { selector: `[data-timeline-part-id="${toolID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "running", { target: "two", count: 2, mode: "deep" })),
|
||||
200,
|
||||
)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(toolID, "mcp_probe", "completed", { target: "two", count: 2, mode: "deep" })),
|
||||
400,
|
||||
)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"generic-mutation",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["tool", "following"] },
|
||||
{ type: "unique", regions: ["tool", "following"] },
|
||||
{ type: "stable", regions: ["tool", "following"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
{ type: "flow", regions: ["tool", "following"] },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
partUpdated,
|
||||
session,
|
||||
sessionID,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
status,
|
||||
textPart,
|
||||
@@ -48,8 +49,8 @@ test.describe("timeline tool state stability", () => {
|
||||
})
|
||||
await timeline.send(status("busy"), 120)
|
||||
for (const id of ids) await timeline.waitForPart(`prt_state_${id}`)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
|
||||
|
||||
const regionIDs = [
|
||||
"prt_state_webfetch",
|
||||
@@ -104,8 +105,10 @@ test.describe("timeline tool state stability", () => {
|
||||
{ type: "label-stability", regions: "all" },
|
||||
]),
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText("Keep it stable")
|
||||
await expect(page.locator(`[data-timeline-part-id="${todoID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toContainText(
|
||||
"Keep it stable",
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(todoID)}"]`)).toHaveCount(0)
|
||||
await expect(
|
||||
page.locator(`a[href$="/session/${childID}"]`, { has: page.locator('[data-component="task-tool-card"]') }),
|
||||
).toBeVisible()
|
||||
@@ -145,7 +148,7 @@ test.describe("timeline tool state stability", () => {
|
||||
},
|
||||
context: { selector: groupSelector, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: {
|
||||
selector: '[data-timeline-part-id="prt_ctx_following"]',
|
||||
selector: `[data-timeline-part-id="${renderedPartID("prt_ctx_following")}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
@@ -194,5 +197,5 @@ function questionInput() {
|
||||
}
|
||||
|
||||
function toolRegion(id: string) {
|
||||
return { selector: `[data-timeline-part-id="${id}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
return { selector: `[data-timeline-part-id="${renderedPartID(id)}"]`, closest: '[data-timeline-row="AssistantPart"]' }
|
||||
}
|
||||
|
||||
@@ -7,13 +7,13 @@ import {
|
||||
visualPlan,
|
||||
} from "../../utils/visual-stability"
|
||||
import {
|
||||
assistantID,
|
||||
assistantMessage,
|
||||
completedAssistantInfo,
|
||||
event,
|
||||
messageUpdated,
|
||||
partDelta,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
@@ -22,35 +22,6 @@ import {
|
||||
userMessage,
|
||||
} from "./fixture"
|
||||
|
||||
test("keeps unchanged siblings stable while a middle part is inserted and removed", async ({ page }, testInfo) => {
|
||||
const firstID = "prt_mutation_01_first"
|
||||
const middleID = "prt_mutation_02_middle"
|
||||
const lastID = "prt_mutation_03_last"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([textPart(firstID, "First stable row"), textPart(lastID, "Last stable row")], {
|
||||
completed: false,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partUpdated(textPart(middleID, "Inserted middle row. ".repeat(12))), 350)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toBeVisible()
|
||||
await timeline.send(
|
||||
event("message.part.removed", { sessionID: "ses_timeline_stability", messageID: assistantID, partID: middleID }),
|
||||
500,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${middleID}"]`)).toHaveCount(0)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "middle-insert-remove", trace, stablePairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("streams text through growth, canonical replacement, and completion", async ({ page }, testInfo) => {
|
||||
const textID = "prt_text_reconcile"
|
||||
const followingID = "prt_text_reconcile_following"
|
||||
@@ -59,14 +30,20 @@ test("streams text through growth, canonical replacement, and completion", async
|
||||
})
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistant], cpuRate: 4 })
|
||||
const regions = defineVisualRegions({
|
||||
text: { selector: `[data-timeline-part-id="${textID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
following: { selector: `[data-timeline-part-id="${followingID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
text: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(textID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
following: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(followingID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(partDelta(textID, " streamed content"), 100)
|
||||
await timeline.send(partDelta(textID, "\n\n- item one\n- item two\n- item three"), 180)
|
||||
await timeline.send(partUpdated(textPart(textID, "Canonical replacement with a shorter final paragraph.")), 200)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 500)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
@@ -105,17 +82,23 @@ test("inserts a completed question between stable rows", async ({ page }, testIn
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toHaveCount(0)
|
||||
const regions = defineVisualRegions({
|
||||
first: { selector: `[data-timeline-part-id="${firstID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
last: { selector: `[data-timeline-part-id="${lastID}"]`, closest: '[data-timeline-row="AssistantPart"]' },
|
||||
first: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(firstID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
last: {
|
||||
selector: `[data-timeline-part-id="${renderedPartID(lastID)}"]`,
|
||||
closest: '[data-timeline-row="AssistantPart"]',
|
||||
},
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
partUpdated(toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Yes"]] } })),
|
||||
600,
|
||||
)
|
||||
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(questionID)}"]`)).toBeVisible()
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(testInfo, "question-insert", trace, stablePairPlan(regions, 0))
|
||||
})
|
||||
@@ -132,8 +115,8 @@ test("replaces thinking with an assistant error without a blank turn", async ({
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
messageUpdated({
|
||||
...assistant.info,
|
||||
error: { name: "APIError", data: { message: "Provider failed visibly", isRetryable: false } },
|
||||
...assistant,
|
||||
error: { type: "APIError", message: "Provider failed visibly" },
|
||||
}),
|
||||
500,
|
||||
)
|
||||
@@ -203,59 +186,6 @@ test("updates retry attempts and long provider messages without remounting the r
|
||||
)
|
||||
})
|
||||
|
||||
test("reducer-hardening: removes a historical turn one message at a time without moving a visible lower anchor twice", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const removeUserID = "msg_0500_remove_user"
|
||||
const removeAssistantID = "msg_0501_remove_assistant"
|
||||
const anchorUserID = "msg_2000_anchor_user"
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(undefined, { id: removeUserID, created: 1690000000000 }),
|
||||
assistantMessage([textPart("prt_remove_text", "Removed historical content. ".repeat(15))], {
|
||||
id: removeAssistantID,
|
||||
parentID: removeUserID,
|
||||
created: 1690000001000,
|
||||
}),
|
||||
userMessage(undefined, { id: anchorUserID, created: 1700000000000 }),
|
||||
assistantMessage([textPart("prt_anchor_text", "Visible anchor response")], {
|
||||
id: "msg_2001_anchor_assistant",
|
||||
parentID: anchorUserID,
|
||||
created: 1700000001000,
|
||||
}),
|
||||
],
|
||||
cpuRate: 4,
|
||||
})
|
||||
const regions = defineVisualRegions({
|
||||
anchor: { selector: `[data-timeline-row="UserMessage"][data-message-id="${anchorUserID}"]` },
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
await timeline.send(
|
||||
event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeAssistantID }),
|
||||
200,
|
||||
)
|
||||
await timeline.send(event("message.removed", { sessionID: "ses_timeline_stability", messageID: removeUserID }), 500)
|
||||
const trace = await stopVisualProbe<keyof typeof regions>(page)
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
"historical-turn-remove",
|
||||
trace,
|
||||
visualPlan(
|
||||
regions,
|
||||
[
|
||||
{ type: "required", regions: ["anchor"] },
|
||||
{ type: "unique", regions: ["anchor"] },
|
||||
{ type: "stable", regions: ["anchor"] },
|
||||
{ type: "opacity", regions: "all" },
|
||||
{ type: "continuity", regions: "all" },
|
||||
{ type: "motion", regions: "all", maxPositionReversals: 0 },
|
||||
{ type: "label-stability", regions: "all" },
|
||||
],
|
||||
{ perMarker: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
function stablePairPlan(
|
||||
regions: Record<"first" | "last", { selector: string; closest?: string }>,
|
||||
maxPositionReversals: number,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
@@ -11,42 +12,35 @@ type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||
const userID = "msg_parent_hydration_user"
|
||||
const userSeed = fixture.messages[fixture.targetID][0] as SessionMessageUser
|
||||
const user = {
|
||||
...fixture.messages[fixture.targetID][0]!,
|
||||
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
||||
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_user_${index}`,
|
||||
messageID: userID,
|
||||
})),
|
||||
}
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
||||
...userSeed,
|
||||
id: userID,
|
||||
time: { created: 1700001000000 },
|
||||
} satisfies SessionMessageInfo
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3] as SessionMessageAssistant
|
||||
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||
return {
|
||||
...assistantSeed,
|
||||
info: {
|
||||
...assistantSeed.info,
|
||||
id: messageID,
|
||||
parentID: userID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
},
|
||||
parts: assistantSeed.parts.map((part, partIndex) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
id: messageID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
content: assistantSeed.content.map((part, partIndex) =>
|
||||
part.type === "tool"
|
||||
? { ...part, id: `call_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}` }
|
||||
: part,
|
||||
),
|
||||
} satisfies SessionMessageInfo
|
||||
})
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
const lastID = userID
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPart = lastAssistant.parts.at(-1)!
|
||||
const lastPart = lastAssistant.content.at(-1)!
|
||||
const lastPartID =
|
||||
lastPart.type === "tool"
|
||||
? lastPart.id
|
||||
: `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}`
|
||||
: `${lastAssistant.id}:${lastPart.type}:${lastAssistant.content.filter((part) => part.type === lastPart.type).length - 1}`
|
||||
|
||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
@@ -107,30 +101,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
},
|
||||
pageMessages: (sessionID, limit, before) => {
|
||||
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
||||
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
||||
const end = before ? items.findIndex((message) => message.id === before) : items.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.id : undefined }
|
||||
},
|
||||
})
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) => {
|
||||
const current = new URL(route.request().url()).pathname.startsWith("/api/")
|
||||
return route.fulfill({
|
||||
await page.route(`**/api/session/${fixture.targetID}`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
current
|
||||
? {
|
||||
data: {
|
||||
...target,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: target.directory },
|
||||
},
|
||||
}
|
||||
: target,
|
||||
),
|
||||
})
|
||||
})
|
||||
body: JSON.stringify({
|
||||
data: {
|
||||
...target,
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
location: { directory: target.directory },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
@@ -148,8 +137,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
{ href, title: target.title },
|
||||
)
|
||||
const metrics = await measureSessionSwitch(page, {
|
||||
destinationIDs: messages.map((message) => message.info.id),
|
||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||
destinationIDs: messages.map((message) => message.id),
|
||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||
lastID,
|
||||
requiredPartID: lastPartID,
|
||||
requireBottomAnchor: false,
|
||||
|
||||
@@ -32,8 +32,8 @@ benchmark("samples cached session repaint after the click", async ({ page, repor
|
||||
|
||||
await installCachedRepaintProbe(page, {
|
||||
targetHref: stressSessionHref(fixture.targetID),
|
||||
destination: fixture.messages[fixture.targetID].map((message) => message.info.id),
|
||||
source: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||
destination: fixture.messages[fixture.targetID].map((message) => message.id),
|
||||
source: fixture.messages[fixture.sourceID].map((message) => message.id),
|
||||
last: fixture.expected.targetMessageIDs.at(-1)!,
|
||||
windowMs: 1_000,
|
||||
})
|
||||
|
||||
@@ -60,8 +60,8 @@ async function trial(page: Page, mode: "cold" | "hot", reviewPane: "closed" | "o
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
}
|
||||
|
||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.info.id)
|
||||
const destinationIDs = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const sourceIDs = fixture.messages[fixture.sourceID].map((message) => message.id)
|
||||
const lastID = fixture.expected.targetMessageIDs.at(-1)!
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
const result = await measureSessionSwitch(page, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../../utils/waits"
|
||||
@@ -10,37 +11,20 @@ const sessionID = "ses_timeline_state_regression"
|
||||
const userMessageID = "msg_user_regression"
|
||||
const assistantMessageID = "msg_assistant_regression"
|
||||
const editPartID = "prt_0001_edit"
|
||||
export const textPartID = "prt_9999_text"
|
||||
export const textPartID = `${assistantMessageID}:text:0`
|
||||
const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
type EventPayload = OpenCodeEvent
|
||||
|
||||
const userMessage = {
|
||||
info: {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Please edit the file.",
|
||||
},
|
||||
],
|
||||
}
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const editPart = {
|
||||
const editPart: ToolSeed = {
|
||||
id: editPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
@@ -66,32 +50,16 @@ const editPart = {
|
||||
},
|
||||
}
|
||||
|
||||
const streamedTextPart = {
|
||||
id: textPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
info: {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
parentID: userMessageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
},
|
||||
parts: [editPart],
|
||||
}
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
export async function setupTimelineBenchmark(
|
||||
page: Page,
|
||||
@@ -105,7 +73,7 @@ export async function setupTimelineBenchmark(
|
||||
const events: EventPayload[] = []
|
||||
let eventBatch = options.eventBatch
|
||||
const currentUserMessage = options.turnDiffs
|
||||
? { ...userMessage, info: { ...userMessage.info, summary: { diffs: options.turnDiffs } } }
|
||||
? { ...userMessage, metadata: { diffs: options.turnDiffs as JsonValue } }
|
||||
: userMessage
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -138,7 +106,8 @@ export async function setupTimelineBenchmark(
|
||||
await page.setViewportSize({ width: 1366, height: 768 })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const text = page.locator(`[data-timeline-part-id="${textPartID}"]`).first()
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expectAppVisible(scroller)
|
||||
return {
|
||||
@@ -182,34 +151,27 @@ export async function setupTimelineBenchmark(
|
||||
}
|
||||
}
|
||||
|
||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: {
|
||||
...streamedTextPart,
|
||||
text: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
export function buildInitialStreamEvent(deltaCount: number): EventPayload[] {
|
||||
return [
|
||||
timelineEvent("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, true),
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: `Streaming${streamChunk(0, deltaCount + 1)}\n\n\`\`\`ts\nconst initial = true\n\`\`\``,
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
export function buildStreamDeltaEvents(deltaCount: number): EventPayload[] {
|
||||
return Array.from({ length: deltaCount }, (_, index) => ({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.delta",
|
||||
properties: {
|
||||
messageID: assistantMessageID,
|
||||
partID: textPartID,
|
||||
field: "text",
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
},
|
||||
},
|
||||
}))
|
||||
return Array.from({ length: deltaCount }, (_, index) =>
|
||||
timelineEvent("session.text.delta", {
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
delta: streamChunk(index + 1, deltaCount + 1),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function performanceTurn(index: number) {
|
||||
@@ -315,48 +277,92 @@ function performanceTurn(index: number) {
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
] as unknown as ContentSeed[]
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1690000000000 + index * 2_000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `prt_0000_${suffix}_user`,
|
||||
sessionID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: `Historical prompt ${index}`,
|
||||
},
|
||||
],
|
||||
id: userID,
|
||||
type: "user",
|
||||
time: { created: 1690000000000 + index * 2_000 },
|
||||
text: `Historical prompt ${index}`,
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
||||
parentID: userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts,
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 1690000001000 + index * 2_000, completed: 1690000001500 + index * 2_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map((part) => {
|
||||
if (part.type === "text") return { type: "text" as const, text: part.text }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning" as const,
|
||||
text: part.text,
|
||||
time: { created: part.time.start, completed: part.time.end },
|
||||
}
|
||||
return toolContent(part)
|
||||
}),
|
||||
},
|
||||
]
|
||||
] satisfies SessionMessageInfo[]
|
||||
}
|
||||
|
||||
type ToolSeed = {
|
||||
id?: string
|
||||
sessionID?: string
|
||||
messageID?: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: string
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title?: string
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
type ContentSeedBase = { id?: string; sessionID?: string; messageID?: string }
|
||||
|
||||
type ContentSeed =
|
||||
| (ContentSeedBase & { type: "text"; text: string })
|
||||
| (ContentSeedBase & { type: "reasoning"; text: string; time: { start: number; end: number } })
|
||||
| ToolSeed
|
||||
|
||||
function toolContent(part: ToolSeed): SessionMessageAssistant["content"][number] {
|
||||
return {
|
||||
type: "tool",
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let eventSequence = 0
|
||||
|
||||
function timelineEvent<Type extends "session.text.started" | "session.text.delta">(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
durable = false,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_timeline_benchmark_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
...(durable ? { durable: { aggregateID: sessionID, seq: eventSequence, version: 1 } } : {}),
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
}
|
||||
|
||||
function historicalMarkdown(index: number) {
|
||||
|
||||
@@ -28,9 +28,22 @@ const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||
type MessagePart = {
|
||||
id: string
|
||||
type: "text" | "reasoning" | "tool"
|
||||
text?: string
|
||||
time?: { start: number; end?: number }
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title: unknown
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
|
||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: id("prt_user_text", index),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: lorem(index, textLength),
|
||||
},
|
||||
],
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||
function assistantMessage(
|
||||
_sessionID: string,
|
||||
index: number,
|
||||
_parentID: string,
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
parentID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
if (part.type === "text") return { type: "text", text: part.text ?? "" }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: part.text ?? "",
|
||||
time: part.time
|
||||
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||
: undefined,
|
||||
}
|
||||
const state = part.state!
|
||||
return {
|
||||
type: "tool",
|
||||
id: part.callID ?? part.id,
|
||||
name: part.tool!,
|
||||
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +218,7 @@ function code(seed: number, lines: number, width = 32) {
|
||||
).join("\n")
|
||||
}
|
||||
|
||||
function turn(index: number): Message[] {
|
||||
function turn(index: number): SessionMessageInfo[] {
|
||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||
const parts = [
|
||||
@@ -241,7 +259,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 72 }, (_, index) => turn(index)).flat()
|
||||
@@ -267,19 +285,12 @@ const childMessages = Array.from({ length: 4 }, (_, index) => [
|
||||
userMessage(childID, index + 2000, 120),
|
||||
assistantMessage(childID, index + 2000, id("msg_user", index + 2000), [textPart(index + 2000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = {
|
||||
const messages: Record<string, SessionMessageInfo[]> = {
|
||||
[sourceID]: sourceMessages,
|
||||
[targetID]: targetMessages,
|
||||
[childID]: childMessages,
|
||||
}
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
export const fixture = {
|
||||
directory,
|
||||
project: {
|
||||
@@ -339,14 +350,19 @@ export const fixture = {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
childTitle: "Inspect child navigation",
|
||||
sourceMessageIDs: sourceMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.info.role === "user").map((message) => message.info.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) => message.parts.filter(renderable).map((part) => part.id)),
|
||||
sourceMessageIDs: sourceMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
childMessageIDs: childMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
targetPartIDs: targetMessages.flatMap((message) => {
|
||||
if (message.type !== "assistant") return []
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
|
||||
if (part.type === "reasoning")
|
||||
return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
|
||||
return [part.id]
|
||||
})
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -355,12 +371,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
messages.findIndex((message) => message.info.id === before),
|
||||
messages.findIndex((message) => message.id === before),
|
||||
)
|
||||
: messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start].id : undefined,
|
||||
}
|
||||
}
|
||||
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -1,121 +1,6 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { currentMessage, mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("preserves current messages", () => {
|
||||
const message = {
|
||||
id: "msg_current",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "current",
|
||||
files: [{ data: "e30=", mime: "application/json", source: { type: "inline" } }],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
expect(currentMessage(message)).toBe(message)
|
||||
})
|
||||
|
||||
test("converts rich legacy messages to current message types", () => {
|
||||
expect(
|
||||
currentMessage({
|
||||
info: { id: "msg_user", role: "user", time: { created: 1 } },
|
||||
parts: [
|
||||
{ type: "text", text: "Use @src/a.ts with @explore" },
|
||||
{
|
||||
type: "file",
|
||||
mime: "application/json",
|
||||
filename: "data.json",
|
||||
url: "data:application/json;base64,e30=",
|
||||
},
|
||||
{
|
||||
type: "file",
|
||||
mime: "text/plain",
|
||||
filename: "a.ts",
|
||||
url: "src/a.ts",
|
||||
source: { type: "file", text: { value: "@src/a.ts", start: 4, end: 13 } },
|
||||
},
|
||||
{ type: "agent", name: "explore", source: { value: "@explore", start: 19, end: 27 } },
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_user",
|
||||
type: "user",
|
||||
time: { created: 1 },
|
||||
text: "Use @src/a.ts with @explore",
|
||||
files: [
|
||||
{ data: "e30=", mime: "application/json", name: "data.json", source: { type: "inline" } },
|
||||
{
|
||||
data: "",
|
||||
mime: "text/plain",
|
||||
name: "a.ts",
|
||||
source: { type: "uri", uri: "src/a.ts" },
|
||||
mention: { text: "@src/a.ts", start: 4, end: 13 },
|
||||
},
|
||||
],
|
||||
agents: [{ name: "explore", mention: { text: "@explore", start: 19, end: 27 } }],
|
||||
})
|
||||
|
||||
expect(
|
||||
currentMessage({
|
||||
info: {
|
||||
id: "msg_assistant",
|
||||
role: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
variant: "high",
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
},
|
||||
parts: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { start: 2, end: 3 } },
|
||||
{
|
||||
id: "prt_tool",
|
||||
callID: "call_tool",
|
||||
type: "tool",
|
||||
tool: "read",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
output: "contents",
|
||||
metadata: { title: "a.ts" },
|
||||
time: { start: 3, end: 4 },
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toEqual({
|
||||
id: "msg_assistant",
|
||||
type: "assistant",
|
||||
time: { created: 2, completed: 5 },
|
||||
agent: "explore",
|
||||
model: { id: "model", providerID: "provider", variant: "high" },
|
||||
cost: 0.5,
|
||||
tokens: { input: 1, output: 2, reasoning: 3, cache: { read: 4, write: 5 } },
|
||||
finish: "tool-calls",
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
content: [
|
||||
{ type: "text", text: "Answer" },
|
||||
{ type: "reasoning", text: "Thinking", time: { created: 2, completed: 3 } },
|
||||
{
|
||||
type: "tool",
|
||||
id: "call_tool",
|
||||
name: "read",
|
||||
time: { created: 3, ran: 3, completed: 4 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { filePath: "src/a.ts" },
|
||||
content: [{ type: "text", text: "contents" }],
|
||||
metadata: { title: "a.ts" },
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
})
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type VisualStabilityTrace,
|
||||
} from "../../utils/visual-stability"
|
||||
import { analyzeVisualObservations } from "../../utils/visual-stability/analyzer"
|
||||
import { legacyVisualPlan, visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||
import { visualPlan, type VisualInvariant } from "../../utils/visual-stability/invariant"
|
||||
import { defineVisualRegions, mapVisualRegions } from "../../utils/visual-stability/regions"
|
||||
|
||||
function trace(samples: VisualStabilityTrace["samples"]): VisualStabilityTrace {
|
||||
@@ -346,19 +346,6 @@ test("evaluates the typed invariant algebra over explicit observations", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("legacy plan adapter preserves analyzer messages and order", () => {
|
||||
const input = trace([
|
||||
frame(0, region({ label: "Exploring", opacity: 1, bottom: 40 }), region({ top: 40, bottom: 60 })),
|
||||
frame(16, region({ label: "Explored", opacity: 0.2, bottom: 50 }), region({ top: 49, bottom: 69 })),
|
||||
frame(32, region({ label: "Exploring", opacity: 1, bottom: 50 }), region({ top: 50, bottom: 70 })),
|
||||
])
|
||||
const options = { flow: ["changing", "following"], stable: ["changing"] }
|
||||
|
||||
expect(analyzeVisualObservations(input.samples, legacyVisualPlan(options))).toEqual(
|
||||
analyzeVisualStability(input, options),
|
||||
)
|
||||
})
|
||||
|
||||
function frame(
|
||||
at: number,
|
||||
changing: VisualStabilityTrace["samples"][number]["regions"][string],
|
||||
|
||||
@@ -46,24 +46,6 @@ test("closing the active server's last tab opens the remaining server tab", asyn
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("legacy session routes preserve an existing tab's server", async ({ page }) => {
|
||||
await mockServers(page, [])
|
||||
await page.addInitScript(
|
||||
({ serverB, sessionB }) => {
|
||||
localStorage.setItem("opencode.global.dat:server", JSON.stringify({ list: [serverB] }))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "session", server: serverB, sessionId: sessionB }]),
|
||||
)
|
||||
},
|
||||
{ serverB, sessionB: sessionB.id },
|
||||
)
|
||||
|
||||
const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}`
|
||||
await page.goto(`/${base64Encode(sessionB.directory)}/session/${sessionB.id}`)
|
||||
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
|
||||
})
|
||||
|
||||
function session(id: string, directory: string, title: string) {
|
||||
return {
|
||||
id,
|
||||
@@ -91,40 +73,26 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
if (url.pathname === "/api/session/active") return json(route, { data: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: current.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
|
||||
const directory = "C:/OpenCode/PromptInputV2Editing"
|
||||
const projectID = "proj_prompt_input_v2_editing"
|
||||
const sessionID = "ses_prompt_input_v2_editing"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("preserves the draft when a populated command menu triggers a built-in", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -32,7 +33,7 @@ test("preserves the draft when a populated command menu triggers a built-in", as
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
await expectAppVisible(composer)
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expectAppVisible } from "../utils/waits"
|
||||
const directory = "C:/OpenCode/PromptThinkingLevelRegression"
|
||||
const projectID = "proj_prompt_thinking_level_regression"
|
||||
const sessionID = "ses_prompt_thinking_level_regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
@@ -49,7 +50,7 @@ test("shows the V2 thinking level control while relevant", async ({ page }) => {
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
const composer = page.locator('[data-component="prompt-input-v2"]')
|
||||
const input = composer.locator('[data-component="prompt-input"]')
|
||||
const control = composer.getByRole("button", { name: "Choose model variant" })
|
||||
|
||||
@@ -49,7 +49,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
const permissionRequests: string[] = []
|
||||
const permissionResponses: PermissionResponse[] = []
|
||||
await installSseTransport(page, { server: serverB })
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
const transport = await installSseTransport(page, {
|
||||
server: serverA,
|
||||
retry: 20,
|
||||
})
|
||||
@@ -82,18 +82,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
await transport.waitForConnection()
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a",
|
||||
sessionID: sessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git status"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
id: "evt_permission_background_a",
|
||||
created: 1700000001000,
|
||||
type: "permission.asked",
|
||||
location: { directory: directoryA },
|
||||
data: {
|
||||
id: "permission-background-a",
|
||||
sessionID: sessionA.id,
|
||||
action: "bash",
|
||||
resources: ["git status"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -110,18 +109,17 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
])
|
||||
|
||||
await transport.send({
|
||||
directory: directoryA,
|
||||
payload: {
|
||||
id: "event-permission-background-a-child",
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "permission-background-a-child",
|
||||
sessionID: childSessionA.id,
|
||||
permission: "bash",
|
||||
patterns: ["git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
id: "evt_permission_background_a_child",
|
||||
created: 1700000002000,
|
||||
type: "permission.asked",
|
||||
location: { directory: directoryA },
|
||||
data: {
|
||||
id: "permission-background-a-child",
|
||||
sessionID: childSessionA.id,
|
||||
action: "bash",
|
||||
resources: ["git diff"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -226,38 +224,7 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (url.pathname === "/permission") {
|
||||
permissionRequests.push(url.toString())
|
||||
return json(route, [])
|
||||
}
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/question", "/vcs/diff", "/pty/shells"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider") return json(route, provider(remote ? "server-b" : "server-a"))
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
const project = {
|
||||
id: remote ? sessionB.projectID : "project-server-a",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: directory,
|
||||
config: directory,
|
||||
worktree: directory,
|
||||
directory,
|
||||
home: directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory })
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] })
|
||||
|
||||
@@ -57,47 +57,33 @@ async function mockServers(page: Page) {
|
||||
const current = url.origin === serverA ? sessionA : sessionB
|
||||
const directory = url.searchParams.get("directory")
|
||||
if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500)
|
||||
if (url.pathname === "/api/event") return sse(route, url.pathname === "/api/event")
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/health") return json(route, { pid: 1 })
|
||||
if (url.pathname === "/api/session/active")
|
||||
return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} })
|
||||
if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} })
|
||||
if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) })
|
||||
if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: current.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
worktree: current.directory,
|
||||
canonical: current.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: current.directory,
|
||||
config: current.directory,
|
||||
worktree: current.directory,
|
||||
directory: current.directory,
|
||||
home: current.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory: current.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: current.directory },
|
||||
@@ -116,10 +102,10 @@ function json(route: Route, body: unknown, status = 200) {
|
||||
})
|
||||
}
|
||||
|
||||
function sse(route: Route, current: boolean) {
|
||||
function sse(route: Route) {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n",
|
||||
body: 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ const directory = "C:/OpenCode/ReviewImageFlashRegression"
|
||||
const sessionID = "ses_review_image_flash_regression"
|
||||
const title = "Review image flash regression"
|
||||
const imageFile = "assets/preview.png"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("clicking an image file in the v2 review pane does not blank the panel", async ({ page }) => {
|
||||
await openReview(page)
|
||||
@@ -109,30 +110,16 @@ async function openReview(page: Page) {
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_review_image_flash_regression",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_review_image_flash_regression",
|
||||
sessionID,
|
||||
messageID: "msg_review_image_flash_regression",
|
||||
type: "text",
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
id: "msg_review_image_flash_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectAppVisible(page.locator('#review-panel [data-component="session-review-v2"]'))
|
||||
|
||||
@@ -6,6 +6,7 @@ import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
const directory = "C:/OpenCode/ReviewLineCommentRegression"
|
||||
const sessionID = "ses_review_line_comment_regression"
|
||||
const title = "Review line comment regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await openReview(page)
|
||||
@@ -88,7 +89,6 @@ test("stages a submitted line comment in the prompt context", async ({ page }) =
|
||||
async function openReview(page: Page) {
|
||||
await page.setViewportSize({ width: 700, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_review_line_comment_regression",
|
||||
@@ -123,30 +123,16 @@ async function openReview(page: Page) {
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
info: {
|
||||
id: "msg_review_line_comment_regression",
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_review_line_comment_regression",
|
||||
sessionID,
|
||||
messageID: "msg_review_line_comment_regression",
|
||||
type: "text",
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
id: "msg_review_line_comment_regression",
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Review this change.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
const changes = page.getByRole("tab", { name: "Changes" })
|
||||
const diffResponse = page.waitForResponse(
|
||||
|
||||
@@ -14,7 +14,6 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -54,7 +54,6 @@ async function switchSession(page: Page, title: string) {
|
||||
|
||||
async function setup(page: Page) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
|
||||
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/ReviewTerminalStacked"
|
||||
const projectID = "proj_review_terminal_stacked"
|
||||
const sessionID = "ses_review_terminal_stacked"
|
||||
const title = "Review terminal stacked"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const branchDiffs = [
|
||||
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||
...Array.from({ length: 2_739 }, (_, index) =>
|
||||
@@ -21,7 +22,6 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
test.setTimeout(120_000)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -130,8 +130,7 @@ test("keeps the review tree and terminal sized when both panels are open", async
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionReady(page, { server, sessionID, title })
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectTree(page, 2_773, "action.yml")
|
||||
|
||||
@@ -16,8 +16,7 @@ test("shows loaded sessions before the directory path request resolves", async (
|
||||
const pathBlocked = new Promise<void>((resolve) => {
|
||||
releasePath = resolve
|
||||
})
|
||||
await page.route("**/api/path?*", async (route) => {
|
||||
if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback()
|
||||
await page.route("**/api/location*", async (route) => {
|
||||
await pathBlocked
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
@@ -8,6 +8,7 @@ const directory = "C:/OpenCode/RequestDocks"
|
||||
const projectID = "proj_request_docks"
|
||||
const sessionID = "ses_request_docks"
|
||||
const title = "Request dock regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("shows a pending question dock", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
@@ -34,7 +35,7 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
@@ -92,7 +93,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const permission = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
||||
@@ -111,11 +112,11 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
const transport = await installSseTransport(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
server,
|
||||
retry: 20,
|
||||
})
|
||||
await mockServer(page, { forms: [] })
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
@@ -138,26 +139,26 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
)
|
||||
.toBe(cursor)
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "form.created",
|
||||
properties: {
|
||||
form: {
|
||||
id: "frm_question_caret",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
type: "string",
|
||||
title: "Continue",
|
||||
description: "Continue?",
|
||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
id: "evt_form_created",
|
||||
created: 1700000001000,
|
||||
type: "form.created",
|
||||
location: { directory },
|
||||
data: {
|
||||
form: {
|
||||
id: "frm_question_caret",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: "message-caret", id: "call-caret" } },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
type: "string",
|
||||
title: "Continue",
|
||||
description: "Continue?",
|
||||
options: [{ value: "yes", label: "Yes", description: "Continue the session" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -166,11 +167,11 @@ test("restores the draft caret before typing after a request dock closes", async
|
||||
await expect(editor).toHaveCount(0)
|
||||
|
||||
await transport.send({
|
||||
directory,
|
||||
payload: {
|
||||
type: "form.cancelled",
|
||||
properties: { sessionID, id: "frm_question_caret" },
|
||||
},
|
||||
id: "evt_form_cancelled",
|
||||
created: 1700000002000,
|
||||
type: "form.cancelled",
|
||||
location: { directory },
|
||||
data: { sessionID, id: "frm_question_caret" },
|
||||
})
|
||||
await expect(question).toHaveCount(0)
|
||||
await expect(editor).toBeVisible()
|
||||
@@ -183,13 +184,11 @@ async function mockServer(
|
||||
page: Page,
|
||||
requests: {
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
sessionStatus?: Record<string, unknown>
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -229,7 +228,6 @@ async function mockServer(
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
permissions: requests.permissions,
|
||||
questions: requests.questions,
|
||||
forms: requests.forms,
|
||||
sessionStatus: requests.sessionStatus,
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Locator, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -8,14 +9,11 @@ const sessionID = "ses_timeline_state_regression"
|
||||
const userMessageID = "msg_user_regression"
|
||||
const assistantMessageID = "msg_assistant_regression"
|
||||
const editPartID = "prt_0001_edit"
|
||||
const textPartID = "prt_9999_text"
|
||||
const textPartID = `${assistantMessageID}:text:0`
|
||||
const title = "Timeline collapse state regression"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
type EventPayload = OpenCodeEvent
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
@@ -27,25 +25,11 @@ declare global {
|
||||
}
|
||||
|
||||
const userMessage = {
|
||||
info: {
|
||||
id: userMessageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text",
|
||||
sessionID,
|
||||
messageID: userMessageID,
|
||||
type: "text",
|
||||
text: "Please edit the file.",
|
||||
},
|
||||
],
|
||||
}
|
||||
id: userMessageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Please edit the file.",
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
const editPart = {
|
||||
id: editPartID,
|
||||
@@ -74,31 +58,19 @@ const editPart = {
|
||||
}
|
||||
|
||||
const streamedTextPart = {
|
||||
id: textPartID,
|
||||
sessionID,
|
||||
messageID: assistantMessageID,
|
||||
type: "text",
|
||||
text: "Streaming added a later assistant text part.",
|
||||
}
|
||||
|
||||
const assistantMessage = {
|
||||
info: {
|
||||
id: assistantMessageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
parentID: userMessageID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
},
|
||||
parts: [editPart],
|
||||
}
|
||||
id: assistantMessageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [toolContent(editPart)],
|
||||
} satisfies SessionMessageInfo
|
||||
|
||||
test.describe("regression: session timeline local row state", () => {
|
||||
test("keeps a manually collapsed tool collapsed when later assistant content streams", async ({ page }) => {
|
||||
@@ -106,7 +78,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await mockServer(page, events)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
@@ -119,13 +91,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await wrapper.locator('[data-slot="collapsible-trigger"]').first().click()
|
||||
await expectExpanded(wrapper, false)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
events.push(...textEvents())
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
|
||||
@@ -136,13 +102,13 @@ test.describe("regression: session timeline local row state", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("does not remount an edit diff when sibling parts or diff counts update", async ({ page }) => {
|
||||
test("does not remount an edit diff when a sibling part arrives", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
await installDiffProbe(page)
|
||||
await mockServer(page, events)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
@@ -151,13 +117,7 @@ test.describe("regression: session timeline local row state", () => {
|
||||
await expectAppVisible(file)
|
||||
await markDiffProbe(page)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: streamedTextPart },
|
||||
},
|
||||
})
|
||||
events.push(...textEvents())
|
||||
|
||||
await expect(page.locator(`[data-timeline-part-id="${textPartID}"]`).first()).toBeVisible({ timeout: 10_000 })
|
||||
const siblingProbe = await readDiffProbe(page)
|
||||
@@ -169,27 +129,6 @@ test.describe("regression: session timeline local row state", () => {
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
})
|
||||
|
||||
await markDiffProbe(page)
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: { part: editPartWithAdditions(2) },
|
||||
},
|
||||
})
|
||||
|
||||
await expect(wrapper.locator('[data-slot="diff-changes-additions"]').filter({ hasText: "+2" }).first()).toBeVisible(
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
expect(await readDiffProbe(page)).toEqual({
|
||||
fileMarker: "before",
|
||||
frameMarker: "before",
|
||||
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
|
||||
rowMarker: "before",
|
||||
shadowRoots: 0,
|
||||
toolMarker: "before",
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps a sticky edit header aligned with a multi-hunk diff", async ({ page }) => {
|
||||
@@ -216,10 +155,10 @@ test.describe("regression: session timeline local row state", () => {
|
||||
},
|
||||
},
|
||||
}
|
||||
await mockServer(page, events, [userMessage, { ...assistantMessage, parts: [part] }])
|
||||
await mockServer(page, events, [userMessage, { ...assistantMessage, content: [toolContent(part)] }])
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${editPartID}"]`).first()
|
||||
@@ -355,22 +294,103 @@ async function readDiffProbe(page: Page) {
|
||||
})
|
||||
}
|
||||
|
||||
function editPartWithAdditions(additions: number) {
|
||||
function toolContent(part: typeof editPart): SessionMessageAssistant["content"][number] {
|
||||
return {
|
||||
...editPart,
|
||||
type: "tool",
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: { created: part.state.time.start, ran: part.state.time.start, completed: part.state.time.end },
|
||||
state: {
|
||||
...editPart.state,
|
||||
metadata: {
|
||||
...editPart.state.metadata,
|
||||
filediff: {
|
||||
...editPart.state.metadata.filediff,
|
||||
additions,
|
||||
},
|
||||
},
|
||||
status: "completed",
|
||||
input: part.state.input,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let eventSequence = 0
|
||||
|
||||
function textEvents(): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue("session.text.started", { sessionID, assistantMessageID, ordinal: 0 }, 1),
|
||||
eventValue(
|
||||
"session.text.ended",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
ordinal: 0,
|
||||
text: streamedTextPart.text,
|
||||
},
|
||||
1,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function toolEvents(part: typeof editPart): OpenCodeEvent[] {
|
||||
return [
|
||||
eventValue(
|
||||
"session.tool.input.started",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.input.ended",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
text: JSON.stringify(part.state.input),
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.called",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: true,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID,
|
||||
id: part.callID,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_collapse_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
}
|
||||
|
||||
function readExpanded(element: Element) {
|
||||
const trigger = element.querySelector('[data-slot="collapsible-trigger"]')
|
||||
const aria = trigger?.getAttribute("aria-expanded")
|
||||
@@ -385,7 +405,11 @@ function readExpanded(element: Element) {
|
||||
return !!content && content.getBoundingClientRect().height > 0
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: EventPayload[], messages = [userMessage, assistantMessage]) {
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: EventPayload[],
|
||||
messages: SessionMessageInfo[] = [userMessage, assistantMessage],
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
@@ -437,3 +461,8 @@ function provider() {
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function sessionHref() {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible, expectSessionTitle } from "../utils/waits"
|
||||
import {
|
||||
@@ -17,11 +18,6 @@ const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "ma
|
||||
const contextIDs = ["ctx_0100_read", "ctx_0101_glob", "ctx_0102_grep", "ctx_0103_list"]
|
||||
const followingTextID = `${id("msg_assistant", 10)}:text:0`
|
||||
|
||||
type Message = {
|
||||
info: Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
parts: Record<string, unknown>[]
|
||||
}
|
||||
|
||||
const messages = [...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(), ...turn(10, true)]
|
||||
|
||||
test.describe("regression: session timeline context group resize", () => {
|
||||
@@ -30,7 +26,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
await mockServer(page)
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
await expectAppVisible(page.locator(`[data-timeline-part-ids="${contextIDs.join(",")}"]`).first())
|
||||
await expectAppVisible(page.locator(`[data-timeline-part-id="${followingTextID}"]`).first())
|
||||
@@ -45,7 +41,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
})
|
||||
|
||||
test("paints a stable exploring to explored transition", async ({ page }) => {
|
||||
const events: { directory: string; payload: Record<string, unknown> }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockServer(page, events, [
|
||||
...Array.from({ length: 8 }, (_, index) => turn(index, false)).flat(),
|
||||
@@ -53,7 +49,7 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
])
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(sessionHref())
|
||||
await expectSessionTitle(page, title)
|
||||
const devtools = await page.context().newCDPSession(page)
|
||||
await devtools.send("Emulation.setCPUThrottlingRate", { rate: 4 })
|
||||
@@ -75,25 +71,21 @@ test.describe("regression: session timeline context group resize", () => {
|
||||
})
|
||||
await startVisualProbe(page, regions)
|
||||
for (const [index, delay] of [120, 350, 80, 500].entries()) {
|
||||
events.push({
|
||||
directory,
|
||||
payload: {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
},
|
||||
},
|
||||
})
|
||||
events.push(
|
||||
...toolEvents(
|
||||
contextTool(
|
||||
contextIDs[index]!,
|
||||
id("msg_assistant", 10),
|
||||
["read", "glob", "grep", "list"][index]!,
|
||||
[
|
||||
{ filePath: "src/recent-a.ts" },
|
||||
{ path: directory, pattern: "**/*.ts" },
|
||||
{ path: directory, pattern: "Explored" },
|
||||
{ path: "src" },
|
||||
][index]!,
|
||||
),
|
||||
),
|
||||
)
|
||||
await page.waitForTimeout(delay)
|
||||
}
|
||||
|
||||
@@ -211,74 +203,51 @@ async function sampleExpansion(page: Page) {
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): Message[] {
|
||||
function turn(index: number, target: boolean, status: "running" | "completed" = "completed"): SessionMessageInfo[] {
|
||||
const userID = id("msg_user", index)
|
||||
const assistantID = id("msg_assistant", index)
|
||||
const content: SessionMessageAssistant["content"] = target
|
||||
? [
|
||||
toolContent(
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
),
|
||||
toolContent(contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status)),
|
||||
toolContent(
|
||||
contextTool(
|
||||
contextIDs[2]!,
|
||||
assistantID,
|
||||
"grep",
|
||||
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||
status,
|
||||
),
|
||||
),
|
||||
toolContent(contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status)),
|
||||
{ type: "text", text: "This assistant text is immediately after the explored context group." },
|
||||
]
|
||||
: [{ type: "text", text: `Assistant filler ${index}. ${"filler ".repeat(60)}` }]
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs: [] },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [{ id: id("prt_user", index), sessionID, messageID: userID, type: "text", text: `User message ${index}` }],
|
||||
id: userID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: `User message ${index}`,
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
||||
parentID: userID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
},
|
||||
parts: target
|
||||
? [
|
||||
contextTool(
|
||||
contextIDs[0]!,
|
||||
assistantID,
|
||||
"read",
|
||||
{ filePath: "src/recent-a.ts", offset: 0, limit: 120 },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[1]!, assistantID, "glob", { path: directory, pattern: "**/*.ts" }, status),
|
||||
contextTool(
|
||||
contextIDs[2]!,
|
||||
assistantID,
|
||||
"grep",
|
||||
{ path: directory, pattern: "Explored", include: "*.ts" },
|
||||
status,
|
||||
),
|
||||
contextTool(contextIDs[3]!, assistantID, "list", { path: "src" }, status),
|
||||
{
|
||||
id: followingTextID,
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: "This assistant text is immediately after the explored context group.",
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
id: id("prt_text", index),
|
||||
sessionID,
|
||||
messageID: assistantID,
|
||||
type: "text",
|
||||
text: `Assistant filler ${index}. ${"filler ".repeat(60)}`,
|
||||
},
|
||||
],
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 2_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content,
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -308,11 +277,110 @@ function contextTool(
|
||||
}
|
||||
}
|
||||
|
||||
async function mockServer(
|
||||
page: Page,
|
||||
events: { directory: string; payload: Record<string, unknown> }[] = [],
|
||||
fixtureMessages = messages,
|
||||
) {
|
||||
type ContextTool = ReturnType<typeof contextTool>
|
||||
|
||||
function toolContent(part: ContextTool): SessionMessageAssistant["content"][number] {
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
time: {
|
||||
created: part.state.time.start,
|
||||
ran: part.state.time.start,
|
||||
...(part.state.status === "completed" ? { completed: part.state.time.end } : {}),
|
||||
},
|
||||
}
|
||||
if (part.state.status === "running")
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "running",
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input: part.state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let eventSequence = 0
|
||||
|
||||
function toolEvents(part: ContextTool): OpenCodeEvent[] {
|
||||
const events = [
|
||||
eventValue(
|
||||
"session.tool.input.started",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
name: part.tool,
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.input.ended",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
text: JSON.stringify(part.state.input),
|
||||
},
|
||||
1,
|
||||
),
|
||||
eventValue(
|
||||
"session.tool.called",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
input: part.state.input,
|
||||
executed: true,
|
||||
},
|
||||
1,
|
||||
),
|
||||
] satisfies OpenCodeEvent[]
|
||||
if (part.state.status === "running") return events
|
||||
return [
|
||||
...events,
|
||||
eventValue(
|
||||
"session.tool.success",
|
||||
{
|
||||
sessionID,
|
||||
assistantMessageID: part.messageID,
|
||||
id: part.callID,
|
||||
content: [{ type: "text", text: part.state.output }],
|
||||
metadata: part.state.metadata,
|
||||
executed: true,
|
||||
},
|
||||
2,
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
function eventValue<Type extends OpenCodeEvent["type"]>(
|
||||
type: Type,
|
||||
data: Extract<OpenCodeEvent, { type: Type }>["data"],
|
||||
version: 1 | 2,
|
||||
): Extract<OpenCodeEvent, { type: Type }> {
|
||||
eventSequence++
|
||||
return {
|
||||
id: `evt_context_resize_${eventSequence}`,
|
||||
created: 1700000002000 + eventSequence,
|
||||
type,
|
||||
data,
|
||||
location: { directory },
|
||||
durable: { aggregateID: sessionID, seq: eventSequence, version },
|
||||
} as unknown as Extract<OpenCodeEvent, { type: Type }>
|
||||
}
|
||||
|
||||
async function mockServer(page: Page, events: OpenCodeEvent[] = [], fixtureMessages = messages) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
@@ -372,3 +440,8 @@ function provider() {
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function sessionHref() {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
@@ -1,30 +1,5 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates edit diagnostics without resetting manual collapse state", async ({ page }) => {
|
||||
const editID = "prt_diagnostics_edit"
|
||||
const base = editPart(editID, [])
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [userMessage(), assistantMessage([base])],
|
||||
settings: { editToolPartsExpanded: true },
|
||||
})
|
||||
const trigger = page.locator(`[data-timeline-part-id="${editID}"] [data-slot="collapsible-trigger"]`).first()
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(
|
||||
partUpdated(editPart(editID, [diagnostic("First failure", 2), diagnostic("Second failure", 4)])),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await timeline.send(partUpdated(editPart(editID, [])), 300)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
import { assistantMessage, setupTimeline, toolPart, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("preserves nested patch file state through outer collapse and reopen", async ({ page }) => {
|
||||
const patchID = "prt_nested_patch"
|
||||
@@ -68,25 +43,6 @@ function patchFile(filePath: string, type: "add" | "update" | "delete") {
|
||||
}
|
||||
}
|
||||
|
||||
function editPart(id: string, diagnostics: Record<string, unknown>[]) {
|
||||
return toolPart(
|
||||
id,
|
||||
"edit",
|
||||
"completed",
|
||||
{ filePath: "src/edit.ts" },
|
||||
{
|
||||
metadata: {
|
||||
filediff: { file: "src/edit.ts", additions: 1, deletions: 1, before: source(false), after: source(true) },
|
||||
diagnostics,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
function diagnostic(message: string, line: number) {
|
||||
return { message, severity: 1, range: { start: { line, character: 0 }, end: { line, character: 2 } } }
|
||||
}
|
||||
|
||||
function source(changed: boolean) {
|
||||
return Array.from({ length: 12 }, (_, index) => `export const value${index} = ${changed ? index + 1 : index}\n`).join(
|
||||
"",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { SessionMessageAssistant } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
@@ -17,6 +18,7 @@ import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const messagePageSize = 200
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
|
||||
return [
|
||||
@@ -29,19 +31,19 @@ const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
|
||||
}),
|
||||
]
|
||||
}).flat()
|
||||
const assistants = messages.filter((message) => message.info.role === "assistant")
|
||||
const assistants = messages.filter((message): message is SessionMessageAssistant => message.type === "assistant")
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPartID = `${assistants.at(-1)!.info.id}:text:0`
|
||||
const userPartID = `${messages.at(-2)!.info.id}:text:0`
|
||||
const lastPartID = `${assistants.at(-1)!.id}:text:0`
|
||||
const userPartID = `${messages.at(-2)!.id}:text:0`
|
||||
const completed = {
|
||||
...lastAssistant.info,
|
||||
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||
...lastAssistant,
|
||||
time: { ...lastAssistant.time, completed: lastAssistant.time.created + 15_000 },
|
||||
}
|
||||
const scenarios = [
|
||||
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
||||
{
|
||||
name: "interruption",
|
||||
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
||||
info: { ...completed, error: { type: "MessageAbortedError", message: "Stopped" } },
|
||||
idleFirst: true,
|
||||
interrupted: true,
|
||||
},
|
||||
@@ -56,12 +58,11 @@ for (const scenario of scenarios) {
|
||||
const roots: { sessionID: string; messageID: string }[] = []
|
||||
const sequence: string[] = []
|
||||
const history = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
const transport = await installSseTransport(page, {
|
||||
server,
|
||||
retry: 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: project(),
|
||||
provider: {
|
||||
@@ -94,15 +95,15 @@ for (const scenario of scenarios) {
|
||||
},
|
||||
message: (requestedSessionID, messageID) => {
|
||||
if (requestedSessionID !== sessionID) return
|
||||
return messages.find((item) => item.info.id === messageID)
|
||||
return messages.find((item) => item.id === messageID)
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
pages.push({ before, limit })
|
||||
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
||||
const end = before ? messages.findIndex((message) => message.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
cursor: start > 0 ? messages[start]!.id : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -152,7 +153,7 @@ for (const scenario of scenarios) {
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
||||
@@ -169,7 +170,7 @@ for (const scenario of scenarios) {
|
||||
expect(sequence.slice(0, 3)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`messages:start:${messages.at(-messagePageSize)!.info.id}`,
|
||||
`messages:start:${messages.at(-messagePageSize)!.id}`,
|
||||
])
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
@@ -187,7 +188,7 @@ for (const scenario of scenarios) {
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages).toEqual([
|
||||
{ before: undefined, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.info.id, limit: messagePageSize },
|
||||
{ before: messages.at(-messagePageSize)!.id, limit: messagePageSize },
|
||||
])
|
||||
expect(roots).toEqual([])
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
reasoningPart,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
@@ -65,7 +66,7 @@ test("transitions thinking and hidden reasoning through busy to idle", async ({
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "running")), 160)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(shell("prt_reasoning_shell", "completed", "done")), 180)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 300)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${reasoningID}"]`)).toHaveCount(0)
|
||||
@@ -100,11 +101,13 @@ test("moves busy through retry and recovery to final idle content", async ({ pag
|
||||
await timeline.send(status("busy", 2), 180)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toBeVisible()
|
||||
await timeline.send(partUpdated(textPart("prt_recovered", "Recovered response")), 140)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 100)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 100)
|
||||
await timeline.send(status("idle"), 350)
|
||||
await expect(page.locator('[data-timeline-row="Retry"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-part-id="prt_recovered"]')).toContainText("Recovered response")
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_recovered")}"]`)).toContainText(
|
||||
"Recovered response",
|
||||
)
|
||||
})
|
||||
|
||||
function lines(count: number) {
|
||||
|
||||
@@ -39,7 +39,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
ownerWarnings.push(message.text())
|
||||
})
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [
|
||||
sessionMessages: [
|
||||
user,
|
||||
{ id: "msg_agent", type: "agent-switched", agent: "explore", time: { created: 2 } },
|
||||
assistant(true),
|
||||
@@ -74,7 +74,7 @@ test("renders current protocol notices in CLI order", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true)] })
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true)] })
|
||||
const card = page.locator('[data-component="task-tool-card"]')
|
||||
await expect(card).toBeVisible()
|
||||
await expect(card).toContainText("Inspect code")
|
||||
@@ -92,14 +92,14 @@ test("moves blocking work to the background with Ctrl+B", async ({ page }) => {
|
||||
})
|
||||
|
||||
test("waits for completion before labeling requested background work", async ({ page }) => {
|
||||
await setupTimeline(page, { currentMessages: [user, assistant(false, true, undefined, true)] })
|
||||
await setupTimeline(page, { sessionMessages: [user, assistant(false, true, undefined, true)] })
|
||||
await expect(page.locator('[data-component="task-tool-card"]')).not.toContainText("(background)")
|
||||
})
|
||||
|
||||
test("navigates from a running subagent card and hides background controls in the child", async ({ page }) => {
|
||||
const childID = "ses_running_child"
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [user, assistant(false, true, childID)],
|
||||
sessionMessages: [user, assistant(false, true, childID)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID, title: "Sleep for 5 minutes" })],
|
||||
sessionStatus: { [sessionID]: { type: "busy" }, [childID]: { type: "busy" } },
|
||||
})
|
||||
@@ -113,7 +113,7 @@ test("navigates from a running subagent card and hides background controls in th
|
||||
test("shows a badge for active background work", async ({ page }) => {
|
||||
const childID = "ses_background_child"
|
||||
await setupTimeline(page, {
|
||||
currentMessages: [user, assistant(true)],
|
||||
sessionMessages: [user, assistant(true)],
|
||||
sessions: [session(), session({ id: childID, parentID: sessionID })],
|
||||
sessionStatus: { [childID]: { type: "busy" } },
|
||||
})
|
||||
@@ -125,7 +125,7 @@ test("separates blocking and already-backgrounded work into two rows", async ({
|
||||
const backgroundID = "ses_background_existing"
|
||||
const blockingID = "ses_background_blocking"
|
||||
const timeline = await setupTimeline(page, {
|
||||
currentMessages: [
|
||||
sessionMessages: [
|
||||
user,
|
||||
{
|
||||
id: "msg_backgrounded",
|
||||
|
||||
@@ -71,33 +71,20 @@ test.describe("session timeline projection", () => {
|
||||
test("projects gaps, dividers, assistant parts, and errors together", async ({ page }) => {
|
||||
const firstUser = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Keep this stable", { id: "prt_comment" }),
|
||||
userText("Continue after the comment", { id: "prt_visible_user" }),
|
||||
],
|
||||
{ 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: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
})
|
||||
const failed = assistantMessage([{ id: "prt_after_abort", type: "text", text: "After interruption" }], {
|
||||
id: "msg_1002_assistant_failed",
|
||||
error: {
|
||||
name: "APIError",
|
||||
data: {
|
||||
message: JSON.stringify({ error: { type: "provider_error", message: "Visible provider failure" } }),
|
||||
isRetryable: false,
|
||||
},
|
||||
type: "APIError",
|
||||
message: "Visible provider failure",
|
||||
},
|
||||
created: 1700000003000,
|
||||
})
|
||||
@@ -122,48 +109,11 @@ test.describe("session timeline projection", () => {
|
||||
await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("renders legacy synthetic comments as ordinary V2 user text", async ({ page }) => {
|
||||
const user = userMessage(
|
||||
[
|
||||
userText("The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable", {
|
||||
id: "prt_comment_only",
|
||||
synthetic: true,
|
||||
metadata: {
|
||||
opencodeComment: {
|
||||
path: "src/a.ts",
|
||||
selection: { startLine: 4, startChar: 0, endLine: 8, endChar: 0 },
|
||||
comment: "Keep this stable",
|
||||
},
|
||||
},
|
||||
}),
|
||||
userText("Continue after the comment", { id: "prt_comment_visible" }),
|
||||
],
|
||||
{ summary: { diffs: Array.from({ length: 11 }, (_, index) => summaryDiff(index)) } },
|
||||
)
|
||||
const nextUser = userMessage(undefined, { id: "msg_2000_diff_next_user", created: 1700000010000 })
|
||||
const nextAssistant = assistantMessage([], {
|
||||
id: "msg_2001_diff_next_assistant",
|
||||
parentID: "msg_2000_diff_next_user",
|
||||
created: 1700000011000,
|
||||
})
|
||||
await setupTimeline(page, { messages: [user, assistantMessage(), nextUser, nextAssistant] })
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
await scroller.evaluate((element) => (element.scrollTop = 0))
|
||||
|
||||
await expect(
|
||||
page.getByText(
|
||||
"The user made the following comment regarding lines 4 through 8 of src/a.ts: Keep this stable Continue after the comment",
|
||||
{ exact: true },
|
||||
),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-timeline-row="DiffSummary"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("renders interruption independently when the turn is not compacted", async ({ page }) => {
|
||||
const user = userMessage()
|
||||
const before = assistantMessage([{ id: "prt_before", type: "text", text: "Before" }], {
|
||||
id: "msg_1001_before",
|
||||
error: { name: "MessageAbortedError", data: { message: "Stopped" } },
|
||||
error: { type: "MessageAbortedError", message: "Stopped" },
|
||||
})
|
||||
const after = assistantMessage([{ id: "prt_after", type: "text", text: "After" }], {
|
||||
id: "msg_1002_after",
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
completedAssistantInfo,
|
||||
messageUpdated,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
setupTimeline,
|
||||
shell,
|
||||
status,
|
||||
@@ -36,8 +37,10 @@ test("reducer-hardening: converges when idle arrives before final part and messa
|
||||
await timeline.send(status("busy"), 100)
|
||||
await timeline.send(status("idle"), 100)
|
||||
await timeline.send(partUpdated(textPart(textID, "Final after early idle")), 120)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant.info)), 250)
|
||||
await timeline.send(messageUpdated(completedAssistantInfo(assistant)), 250)
|
||||
|
||||
await expect(page.locator('[data-timeline-row="Thinking"]')).toHaveCount(0)
|
||||
await expect(page.locator(`[data-timeline-part-id="${textID}"]`)).toContainText("Final after early idle")
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID(textID)}"]`)).toContainText(
|
||||
"Final after early idle",
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
partUpdated,
|
||||
setupTimeline,
|
||||
toolPart,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("updates expanded web search links without resetting expansion", async ({ page }) => {
|
||||
const searchID = "prt_websearch_mutation"
|
||||
const input = { query: "timeline stability" }
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([toolPart(searchID, "websearch", "completed", input, { output: "https://example.com/one" })]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${searchID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(searchID, "websearch", "completed", input, {
|
||||
output: "https://example.com/one\nhttps://example.com/two",
|
||||
}),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper.locator('a[href="https://example.com/two"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test("preserves an expanded tool error card across duplicate delivery", async ({ page }) => {
|
||||
const toolID = "prt_duplicate_error"
|
||||
const failed = toolPart(toolID, "bash", "error", { command: "exit 1" }, { error: "Command failed visibly" })
|
||||
const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([failed])] })
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${toolID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await trigger.click()
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(partUpdated(failed), 150)
|
||||
await timeline.send(partUpdated(failed), 250)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Command failed visibly")
|
||||
})
|
||||
|
||||
test("renders multiple question answers and preserves open state on answer updates", async ({ page }) => {
|
||||
const questionID = "prt_multi_question"
|
||||
const input = {
|
||||
questions: [
|
||||
{ header: "First", question: "First choice?", options: [] },
|
||||
{ header: "Second", question: "Second choice?", options: [], multiple: true },
|
||||
],
|
||||
}
|
||||
const timeline = await setupTimeline(page, {
|
||||
messages: [
|
||||
userMessage(),
|
||||
assistantMessage([
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["A"], ["B", "C"]] } }),
|
||||
]),
|
||||
],
|
||||
})
|
||||
const wrapper = page.locator(`[data-timeline-part-id="${questionID}"]`)
|
||||
const trigger = wrapper.locator('[data-slot="collapsible-trigger"]')
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(questionID, "question", "completed", input, { metadata: { answers: [["Updated"], ["B", "C"]] } }),
|
||||
),
|
||||
300,
|
||||
)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(wrapper).toContainText("Updated")
|
||||
await expect(wrapper).toContainText("B, C")
|
||||
})
|
||||
@@ -1,43 +1,44 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { partUpdated, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
|
||||
import { partUpdated, renderedPartID, setupTimeline, textPart } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps one connection open while delivering multiple events", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_first", "first event")))
|
||||
const second = await timeline.transport.send(partUpdated(textPart("prt_transport_second", "second event")))
|
||||
const first = (await timeline.transport.burst(partUpdated(textPart("prt_transport_first", "first event")))).at(-1)!
|
||||
const second = (await timeline.transport.burst(partUpdated(textPart("prt_transport_second", "second event")))).at(-1)!
|
||||
|
||||
await timeline.waitForPart("prt_transport_first")
|
||||
await timeline.waitForPart("prt_transport_second")
|
||||
expect(first.connectionID).toBe(second.connectionID)
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(2)
|
||||
expect(await timeline.transport.acknowledgements()).toHaveLength(4)
|
||||
})
|
||||
|
||||
test("delivers a burst from one stream chunk", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const acknowledgements = await timeline.transport.burst([
|
||||
partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||
partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
...partUpdated(textPart("prt_transport_burst_a", "burst a")),
|
||||
...partUpdated(textPart("prt_transport_burst_b", "burst b")),
|
||||
])
|
||||
|
||||
await timeline.waitForPart("prt_transport_burst_a")
|
||||
await timeline.waitForPart("prt_transport_burst_b")
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(2)
|
||||
expect(acknowledgements.map((item) => item.chunkCount)).toEqual([1, 1, 1, 1])
|
||||
expect(new Set(acknowledgements.map((item) => item.deliveryID)).size).toBe(4)
|
||||
})
|
||||
|
||||
test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const payload = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||
const [started, payload] = partUpdated(textPart("prt_transport_split", "split snowman \u2603\u2603\u2603"))
|
||||
await timeline.transport.send(started!)
|
||||
const encoded = new TextEncoder().encode(`data: ${JSON.stringify(payload)}\n\n`)
|
||||
const snowman = new TextEncoder().encode("\u2603")[0]!
|
||||
const multibyte = encoded.indexOf(snowman)
|
||||
|
||||
const acknowledgement = await timeline.transport.split(payload, [9, multibyte + 1, multibyte + 2])
|
||||
const acknowledgement = await timeline.transport.split(payload!, [9, multibyte + 1, multibyte + 2])
|
||||
|
||||
await timeline.waitForPart("prt_transport_split")
|
||||
await expect(page.locator('[data-timeline-part-id="prt_transport_split"]')).toContainText(
|
||||
await expect(page.locator(`[data-timeline-part-id="${renderedPartID("prt_transport_split")}"]`)).toContainText(
|
||||
"split snowman \u2603\u2603\u2603",
|
||||
)
|
||||
expect(acknowledgement.chunkCount).toBe(4)
|
||||
@@ -46,12 +47,11 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => {
|
||||
test("delivers server heartbeat without mutating the timeline", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page)
|
||||
const partID = "prt_transport_heartbeat_sentinel"
|
||||
const sentinel = await timeline.transport.send(partUpdated(textPart(partID, "heartbeat sentinel")))
|
||||
const sentinel = (await timeline.transport.burst(partUpdated(textPart(partID, "heartbeat sentinel")))).at(-1)!
|
||||
await timeline.waitForPart(partID)
|
||||
await expect(page.locator(`[data-timeline-part-id="${partID}"] [data-component="markdown"]`)).toHaveAttribute(
|
||||
"data-markdown-ready",
|
||||
"",
|
||||
)
|
||||
await expect(
|
||||
page.locator(`[data-timeline-part-id="${renderedPartID(partID)}"] [data-component="markdown"]`),
|
||||
).toHaveAttribute("data-markdown-ready", "")
|
||||
const before = await timelineRows(page)
|
||||
const heartbeat = await timeline.transport.heartbeat()
|
||||
|
||||
@@ -66,7 +66,7 @@ test("reconnects after a clean close", async ({ page }) => {
|
||||
|
||||
await timeline.transport.close()
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
await timeline.transport.burst(partUpdated(textPart("prt_transport_close", "after close")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_close")
|
||||
expect(second.id).toBeGreaterThan(first.id)
|
||||
@@ -79,7 +79,7 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
|
||||
await timeline.transport.error("contract failure")
|
||||
const second = await timeline.transport.waitForConnection({ after: first.id })
|
||||
await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error")))
|
||||
await timeline.transport.burst(partUpdated(textPart("prt_transport_error", "after error")))
|
||||
|
||||
await timeline.waitForPart("prt_transport_error")
|
||||
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2)
|
||||
@@ -89,9 +89,13 @@ test("reconnects after a stream error", async ({ page }) => {
|
||||
|
||||
test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => {
|
||||
const timeline = await setupTimeline(page, { eventRetry: 10 })
|
||||
const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), {
|
||||
id: "timeline-event-7",
|
||||
})
|
||||
const events = partUpdated(textPart("prt_transport_id", "event with id"))
|
||||
const first = (
|
||||
await timeline.transport.burst(
|
||||
events,
|
||||
events.map((_, index) => (index === events.length - 1 ? { id: "timeline-event-7" } : {})),
|
||||
)
|
||||
).at(-1)!
|
||||
await timeline.waitForPart("prt_transport_id")
|
||||
|
||||
await timeline.transport.error("retry with event id")
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TodoDockNavigation"
|
||||
const projectID = "proj_todo_dock_navigation"
|
||||
const sourceID = "ses_todo_dock_source"
|
||||
const otherID = "ses_todo_dock_other"
|
||||
const sourceTitle = "Todo dock animation"
|
||||
const otherTitle = "Separate session"
|
||||
|
||||
const activeTodos = [
|
||||
{ id: "todo-1", content: "Receive todos in the active session", status: "completed", priority: "high" },
|
||||
{ id: "todo-2", content: "Keep the dock visible across tabs", status: "completed", priority: "high" },
|
||||
{ id: "todo-3", content: "Close after the final todo", status: "in_progress", priority: "high" },
|
||||
]
|
||||
|
||||
type EventPayload = {
|
||||
directory: string
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("animates todo opening without replaying it across session tabs", async ({ page }) => {
|
||||
test.setTimeout(90_000)
|
||||
const events: EventPayload[] = []
|
||||
const todos: Record<string, typeof activeTodos> = { [sourceID]: [], [otherID]: [] }
|
||||
const sessionStatus: Record<string, { type: "busy" | "idle" }> = {}
|
||||
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "todo-dock-navigation",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0, 1),
|
||||
eventRetry: 16,
|
||||
sessionStatus: () => sessionStatus,
|
||||
todos: (sessionID) => todos[sessionID] ?? [],
|
||||
})
|
||||
await configurePage(page)
|
||||
|
||||
await page.goto(sessionHref(sourceID))
|
||||
await expectSessionTitle(page, sourceTitle)
|
||||
const dock = page.locator('[data-component="session-todo-dock"]')
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
sessionStatus[sourceID] = { type: "busy" }
|
||||
events.push(statusEvent(sourceID, "busy"))
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
|
||||
await page.waitForTimeout(700)
|
||||
const opening = sampleDock(page, 1_000)
|
||||
todos[sourceID] = activeTodos
|
||||
events.push(todoEvent(sourceID, activeTodos))
|
||||
await expect(dock).toBeVisible()
|
||||
await expect(dock.locator('[data-state="in_progress"]')).toHaveCount(1)
|
||||
expect((await opening).some((sample) => sample.opacity > 0.05 && sample.opacity < 0.95)).toBe(true)
|
||||
|
||||
await switchSession(page, otherID, otherTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
|
||||
await switchSession(page, sourceID, sourceTitle)
|
||||
await expect(dock).toHaveCount(0)
|
||||
})
|
||||
|
||||
function session(id: string, title: string, created: number) {
|
||||
return {
|
||||
id,
|
||||
slug: id,
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created, updated: created },
|
||||
}
|
||||
}
|
||||
|
||||
function statusEvent(sessionID: string, type: "busy" | "idle"): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "session.status", properties: { sessionID, status: { type } } },
|
||||
}
|
||||
}
|
||||
|
||||
function todoEvent(sessionID: string, next: typeof activeTodos): EventPayload {
|
||||
return {
|
||||
directory,
|
||||
payload: { type: "todo.updated", properties: { sessionID, todos: next } },
|
||||
}
|
||||
}
|
||||
|
||||
async function configurePage(page: Page) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, dirBase64, server, sessionIDs }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify(sessionIDs.map((sessionId) => ({ type: "session", server, dirBase64, sessionId }))),
|
||||
)
|
||||
},
|
||||
{ directory, dirBase64: base64Encode(directory), server, sessionIDs: [sourceID, otherID] },
|
||||
)
|
||||
}
|
||||
|
||||
function sessionHref(sessionID: string) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
async function switchSession(page: Page, sessionID: string, title: string) {
|
||||
const href = sessionHref(sessionID)
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||
await expect(tab).toBeVisible()
|
||||
await tab.click()
|
||||
await expectSessionTitle(page, title)
|
||||
}
|
||||
|
||||
function sampleDock(page: Page, duration: number) {
|
||||
return page.evaluate(async (duration) => {
|
||||
const samples: { present: boolean; height: number; opacity: number }[] = []
|
||||
const start = performance.now()
|
||||
while (performance.now() - start < duration) {
|
||||
const dock = document.querySelector<HTMLElement>('[data-component="session-todo-dock"]')
|
||||
const clip = dock?.parentElement?.parentElement
|
||||
const label = dock?.querySelector<HTMLElement>('[data-action="session-todo-toggle"] span[aria-label]')
|
||||
samples.push({
|
||||
present: !!dock,
|
||||
height: clip?.getBoundingClientRect().height ?? 0,
|
||||
opacity: label ? Number.parseFloat(getComputedStyle(label).opacity) : 0,
|
||||
})
|
||||
await new Promise(requestAnimationFrame)
|
||||
}
|
||||
return samples
|
||||
}, duration)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { currentSession, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -12,8 +13,6 @@ const childTitle = "Subagent child session"
|
||||
// Child session pages derive their heading from the task part that spawned them.
|
||||
const taskDescription = "Inspect child navigation"
|
||||
|
||||
type EventPayload = { directory: string; payload: Record<string, unknown> }
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("navigates to a subagent child session missing from the session list", async ({ page }) => {
|
||||
@@ -52,14 +51,18 @@ test("keeps the parent visible while child lineage resolves", async ({ page }) =
|
||||
})
|
||||
|
||||
test("shows the not found fallback when the viewed session is deleted", async ({ page }) => {
|
||||
const events: EventPayload[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
await setup(page, () => events.splice(0, 1))
|
||||
await openChildFromParent(page)
|
||||
await expectSessionTitle(page, taskDescription)
|
||||
|
||||
events.push({
|
||||
directory,
|
||||
payload: { type: "session.deleted", properties: { info: childSession() } },
|
||||
id: "evt_session_deleted",
|
||||
created: 1700000003000,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: childID, seq: 1, version: 2 },
|
||||
location: { directory },
|
||||
data: { sessionID: childID },
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
@@ -67,7 +70,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
async function setup(page: Page, events?: () => EventPayload[]) {
|
||||
async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -142,60 +145,36 @@ function childSession() {
|
||||
return session(childID, childTitle, 1700000001000, { parentID })
|
||||
}
|
||||
|
||||
function parentMessages() {
|
||||
function parentMessages(): SessionMessageInfo[] {
|
||||
const userID = "msg_user_0001"
|
||||
const assistantID = "msg_assistant_0001"
|
||||
return [
|
||||
{
|
||||
info: {
|
||||
id: userID,
|
||||
sessionID: parentID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 },
|
||||
agent: "build",
|
||||
model: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: "prt_user_text_0001",
|
||||
sessionID: parentID,
|
||||
messageID: userID,
|
||||
type: "text",
|
||||
text: "Delegate work to a subagent",
|
||||
},
|
||||
],
|
||||
id: userID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 },
|
||||
text: "Delegate work to a subagent",
|
||||
},
|
||||
{
|
||||
info: {
|
||||
id: assistantID,
|
||||
sessionID: parentID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000002000 },
|
||||
parentID: userID,
|
||||
modelID: "claude-opus-4-6",
|
||||
providerID: "opencode",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
},
|
||||
parts: [
|
||||
id: assistantID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000001000, completed: 1700000002000 },
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: [
|
||||
{
|
||||
id: "prt_tool_task_0001",
|
||||
sessionID: parentID,
|
||||
messageID: assistantID,
|
||||
type: "tool",
|
||||
callID: "call_task_0001",
|
||||
tool: "task",
|
||||
id: "call_task_0001",
|
||||
name: "task",
|
||||
time: { created: 1700000001000, ran: 1700000001000, completed: 1700000002000 },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { description: taskDescription, subagent_type: "explore" },
|
||||
output: "Subagent finished",
|
||||
title: taskDescription,
|
||||
content: [{ type: "text", text: "Subagent finished" }],
|
||||
metadata: { sessionId: childID },
|
||||
time: { start: 1700000001000, end: 1700000002000 },
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -85,8 +85,7 @@ async function mockServer(page: Page) {
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
if (url.origin !== server) return route.fallback()
|
||||
if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname))
|
||||
return new Promise(() => {})
|
||||
if (url.pathname === `/api/session/${unresolvedSessionID}`) return new Promise(() => {})
|
||||
if (url.pathname === "/api/event") return sse(route)
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
@@ -95,41 +94,29 @@ async function mockServer(page: Page) {
|
||||
if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) })
|
||||
if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`))
|
||||
return json(route, { data: [], cursor: {} })
|
||||
if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404)
|
||||
if (/^\/session\/[^/]+\/message$/.test(url.pathname)) return json(route, [])
|
||||
if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, [])
|
||||
if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname))
|
||||
return json(route, [])
|
||||
if (url.pathname === "/provider")
|
||||
return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } })
|
||||
if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }])
|
||||
if (url.pathname === "/project" || url.pathname === "/project/current") {
|
||||
if (["/api/agent", "/api/provider", "/api/model", "/api/command", "/api/reference"].includes(url.pathname))
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/question/request")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
const project = {
|
||||
id: sessionA.projectID,
|
||||
worktree: sessionA.directory,
|
||||
canonical: sessionA.directory,
|
||||
vcs: "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/project" ? [project] : project)
|
||||
return json(
|
||||
route,
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
}
|
||||
if (url.pathname === "/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/api/path")
|
||||
return json(route, {
|
||||
state: sessionA.directory,
|
||||
config: sessionA.directory,
|
||||
worktree: sessionA.directory,
|
||||
directory: sessionA.directory,
|
||||
home: sessionA.directory,
|
||||
})
|
||||
if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" })
|
||||
if (url.pathname === "/api/location") return json(route, { directory: sessionA.directory })
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, {
|
||||
location: { directory: sessionA.directory },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
@@ -8,12 +8,12 @@ const projectID = "proj_terminal_composer_focus"
|
||||
const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
const newPtyID = "pty_terminal_composer_focus_new"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
@@ -74,7 +74,7 @@ test.beforeEach(async ({ page }) => {
|
||||
})
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -113,7 +113,7 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p
|
||||
})
|
||||
await seedCachedTerminal(page)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`, { waitUntil: "commit" })
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -139,7 +139,7 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn
|
||||
await route.continue()
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -184,7 +184,7 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
@@ -211,12 +211,18 @@ function seedCachedTerminal(page: Page) {
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID },
|
||||
{ terminalKey: terminalStorageKey(), ptyID },
|
||||
)
|
||||
}
|
||||
|
||||
function terminalStorageKey() {
|
||||
const dir = base64Encode(directory)
|
||||
const head = dir.slice(0, 12).replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||
return `opencode.workspace.${head}.${checksum(dir) ?? "0"}.dat:workspace:terminal`
|
||||
}
|
||||
|
||||
function ptyLocation() {
|
||||
return { directory, project: { id: projectID, directory } }
|
||||
return { directory, project: { id: projectID, directory, canonical: directory } }
|
||||
}
|
||||
|
||||
function ptyInfo(id: string, title: string) {
|
||||
|
||||
@@ -6,6 +6,7 @@ const directory = "C:/OpenCode/HiddenTerminalRegression"
|
||||
const projectID = "proj_hidden_terminal_regression"
|
||||
const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
@@ -91,7 +92,7 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
)
|
||||
await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
|
||||
@@ -21,16 +21,29 @@ const words = [
|
||||
"vector",
|
||||
]
|
||||
|
||||
const serverKey = `http://127.0.0.1:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const serverKey = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const sourceID = "ses_smoke_source"
|
||||
const targetID = "ses_smoke_target"
|
||||
const directory = "C:/OpenCode/SmokeProject"
|
||||
const projectID = "proj_smoke_timeline"
|
||||
const model = { providerID: "opencode", modelID: "claude-opus-4-6", variant: "max" }
|
||||
|
||||
type MessageInfo = Record<string, unknown> & { id: string; role: "user" | "assistant" }
|
||||
type MessagePart = Record<string, unknown> & { id: string; type: string; text?: string; tool?: string }
|
||||
type Message = { info: MessageInfo; parts: MessagePart[] }
|
||||
type MessagePart = {
|
||||
id: string
|
||||
type: "text" | "reasoning" | "tool"
|
||||
text?: string
|
||||
time?: { start: number; end?: number }
|
||||
callID?: string
|
||||
tool?: string
|
||||
state?: {
|
||||
status: "completed"
|
||||
input: Record<string, unknown>
|
||||
output: string
|
||||
title: unknown
|
||||
metadata: Record<string, unknown>
|
||||
time: { start: number; end: number }
|
||||
}
|
||||
}
|
||||
|
||||
function lorem(seed: number, length: number) {
|
||||
let out = ""
|
||||
@@ -48,54 +61,59 @@ function id(prefix: string, value: number) {
|
||||
return `${prefix}_smoke_${String(value).padStart(4, "0")}`
|
||||
}
|
||||
|
||||
function userMessage(sessionID: string, index: number, textLength: number, diffs: unknown[] = []): Message {
|
||||
function userMessage(_sessionID: string, index: number, textLength: number, diffs: unknown[] = []): SessionMessageInfo {
|
||||
const messageID = id("msg_user", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
summary: { diffs },
|
||||
agent: "build",
|
||||
model,
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: id("prt_user_text", index),
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text: lorem(index, textLength),
|
||||
},
|
||||
],
|
||||
id: messageID,
|
||||
type: "user",
|
||||
time: { created: 1700000000000 + index * 10_000 },
|
||||
text: lorem(index, textLength),
|
||||
metadata: diffs.length ? { diffs: diffs as JsonValue } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
function assistantMessage(sessionID: string, index: number, parentID: string, parts: MessagePart[]): Message {
|
||||
function assistantMessage(
|
||||
_sessionID: string,
|
||||
index: number,
|
||||
_parentID: string,
|
||||
parts: MessagePart[],
|
||||
): SessionMessageInfo {
|
||||
const messageID = id("msg_assistant", index)
|
||||
return {
|
||||
info: {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
parentID,
|
||||
modelID: model.modelID,
|
||||
providerID: model.providerID,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: directory, root: directory },
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
variant: "max",
|
||||
finish: "stop",
|
||||
id: messageID,
|
||||
type: "assistant",
|
||||
time: { created: 1700000000000 + index * 10_000 + 1_000, completed: 1700000000000 + index * 10_000 + 8_000 },
|
||||
model: { id: model.modelID, providerID: model.providerID, variant: model.variant },
|
||||
agent: "build",
|
||||
cost: 0.01,
|
||||
tokens: { input: 100, output: 200, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
finish: "stop",
|
||||
content: parts.map(messageContent),
|
||||
}
|
||||
}
|
||||
|
||||
function messageContent(part: MessagePart): SessionMessageAssistant["content"][number] {
|
||||
if (part.type === "text") return { type: "text", text: part.text ?? "" }
|
||||
if (part.type === "reasoning")
|
||||
return {
|
||||
type: "reasoning",
|
||||
text: part.text ?? "",
|
||||
time: part.time
|
||||
? { created: part.time.start, ...(part.time.end === undefined ? {} : { completed: part.time.end }) }
|
||||
: undefined,
|
||||
}
|
||||
const state = part.state!
|
||||
return {
|
||||
type: "tool",
|
||||
id: part.callID ?? part.id,
|
||||
name: part.tool!,
|
||||
time: { created: state.time.start, ran: state.time.start, completed: state.time.end },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: state.input as Record<string, JsonValue>,
|
||||
content: [{ type: "text", text: state.output }],
|
||||
metadata: state.metadata as Record<string, JsonValue>,
|
||||
},
|
||||
parts: parts.map((part) => ({
|
||||
...part,
|
||||
sessionID,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,7 +198,7 @@ function code(seed: number, lines: number) {
|
||||
)
|
||||
}
|
||||
|
||||
function turn(index: number): Message[] {
|
||||
function turn(index: number): SessionMessageInfo[] {
|
||||
const diff = index % 9 === 0 ? [fileDiff(`src/generated/summary-${index}.ts`, index)] : []
|
||||
const user = userMessage(targetID, index, 100 + (index % 4) * 80, diff)
|
||||
const parts = [
|
||||
@@ -219,7 +237,7 @@ function turn(index: number): Message[] {
|
||||
? [toolPart(index, 12, "task", { description: "Inspect generated fixture", subagent_type: "explore" }, 160)]
|
||||
: []),
|
||||
]
|
||||
return [user, assistantMessage(targetID, index, user.info.id, parts)]
|
||||
return [user, assistantMessage(targetID, index, user.id, parts)]
|
||||
}
|
||||
|
||||
const targetMessages = Array.from({ length: 101 }, (_, index) => turn(index)).flat()
|
||||
@@ -227,22 +245,16 @@ const sourceMessages = Array.from({ length: 12 }, (_, index) => [
|
||||
userMessage(sourceID, index + 1000, 120),
|
||||
assistantMessage(sourceID, index + 1000, id("msg_user", index + 1000), [textPart(index + 1000, 0, 240)]),
|
||||
]).flat()
|
||||
const messages: Record<string, Message[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||
const messages: Record<string, SessionMessageInfo[]> = { [sourceID]: sourceMessages, [targetID]: targetMessages }
|
||||
|
||||
function renderable(part: MessagePart) {
|
||||
if (part.type === "tool" && part.tool === "todowrite") return false
|
||||
if (part.type === "text") return !!part.text?.trim()
|
||||
if (part.type === "reasoning") return !!part.text?.trim()
|
||||
return part.type !== "step-start" && part.type !== "step-finish" && part.type !== "patch"
|
||||
}
|
||||
|
||||
function currentPartIDs(message: Message) {
|
||||
function currentPartIDs(message: SessionMessageInfo) {
|
||||
if (message.type === "user") return message.text.trim() ? [`${message.id}:text:0`] : []
|
||||
if (message.type !== "assistant") return []
|
||||
const ordinals = { text: 0, reasoning: 0 }
|
||||
return message.parts.flatMap((part) => {
|
||||
if (!renderable(part)) return []
|
||||
if (part.type === "text") return [`${message.info.id}:text:${ordinals.text++}`]
|
||||
if (part.type === "reasoning") return [`${message.info.id}:reasoning:${ordinals.reasoning++}`]
|
||||
if (part.type === "tool") return [typeof part.callID === "string" ? part.callID : part.id]
|
||||
return message.content.flatMap((part) => {
|
||||
if (part.type === "text") return part.text.trim() ? [`${message.id}:text:${ordinals.text++}`] : []
|
||||
if (part.type === "reasoning") return part.text.trim() ? [`${message.id}:reasoning:${ordinals.reasoning++}`] : []
|
||||
if (part.type === "tool") return [part.id]
|
||||
return []
|
||||
})
|
||||
}
|
||||
@@ -295,12 +307,11 @@ export const fixture = {
|
||||
expected: {
|
||||
sourceTitle: "Uncommitted changes inquiry",
|
||||
targetTitle: "Example Game: sample jump movement & sample physics analysis",
|
||||
targetMessageIDs: targetMessages
|
||||
.filter((message) => message.info.role === "user")
|
||||
.map((message) => message.info.id),
|
||||
targetMessageIDs: targetMessages.filter((message) => message.type === "user").map((message) => message.id),
|
||||
targetPartIDs: targetMessages.flatMap(currentPartIDs),
|
||||
expandedShellPartID: targetMessages.flatMap((message) => message.parts).find((part) => part.tool === "bash")!
|
||||
.callID,
|
||||
expandedShellPartID: targetMessages
|
||||
.flatMap((message) => (message.type === "assistant" ? message.content : []))
|
||||
.flatMap((part) => (part.type === "tool" && part.name === "bash" ? [part.id] : []))[0],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -309,12 +320,13 @@ export function pageMessages(sessionID: string, limit: number, before?: string)
|
||||
const end = before
|
||||
? Math.max(
|
||||
0,
|
||||
messages.findIndex((message) => message.info.id === before),
|
||||
messages.findIndex((message) => message.id === before),
|
||||
)
|
||||
: messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start].info.id : undefined,
|
||||
cursor: start > 0 ? messages[start].id : undefined,
|
||||
}
|
||||
}
|
||||
import type { JsonValue, SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -33,7 +33,6 @@ test.describe("smoke: session timeline", () => {
|
||||
test("keeps the visible message fixed while prepending history", async ({ page }) => {
|
||||
const requests: { before?: string; phase: "start" | "end"; at: number }[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -44,7 +43,7 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
|
||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await waitForTimelineStable(page)
|
||||
const scroller = timelineScroller(page)
|
||||
await pointAtTimeline(page)
|
||||
@@ -92,7 +91,6 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("preserves the timeline gap above the composer", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -101,7 +99,7 @@ test.describe("smoke: session timeline", () => {
|
||||
})
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
|
||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
await waitForTimelineStable(page)
|
||||
const scroller = timelineScroller(page)
|
||||
await scroller.evaluate((element) => {
|
||||
@@ -119,7 +117,6 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("paints cached session tabs at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -143,11 +140,11 @@ test.describe("smoke: session timeline", () => {
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.targetID}`)
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.targetID}`)
|
||||
await expectSessionTitle(page, fixture.expected.targetTitle)
|
||||
await switchTitlebarSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
@@ -262,7 +259,6 @@ test.describe("smoke: session timeline", () => {
|
||||
|
||||
test("paints a cold session tab at the latest message", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -285,10 +281,10 @@ test.describe("smoke: session timeline", () => {
|
||||
},
|
||||
{ server: fixture.serverKey, sourceID: fixture.sourceID, targetID: fixture.targetID },
|
||||
)
|
||||
await page.goto(`/${base64Encode(fixture.directory)}/session/${fixture.sourceID}`)
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${fixture.sourceID}`)
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
const last = fixture.expected.targetMessageIDs.at(-1)!
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.info.id)
|
||||
const destination = fixture.messages[fixture.targetID].map((message) => message.id)
|
||||
await page.evaluate(
|
||||
({ destination, last }) => {
|
||||
const ids = new Set(destination)
|
||||
@@ -341,7 +337,6 @@ test.describe("smoke: session timeline", () => {
|
||||
test("renders seeded timeline in order while paging through history", async ({ page }) => {
|
||||
const errors = trackPageErrors(page)
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
@@ -351,9 +346,9 @@ test.describe("smoke: session timeline", () => {
|
||||
await configureSmokePage(page, fixture.directory)
|
||||
|
||||
await selectHomeProject(page, fixture.project.name)
|
||||
await navigateToSession(page, fixture.directory, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await navigateToSession(page, fixture.sourceID, fixture.expected.sourceTitle)
|
||||
await expectSessionReady(page)
|
||||
await navigateToSession(page, fixture.directory, fixture.targetID, fixture.expected.targetTitle)
|
||||
await navigateToSession(page, fixture.targetID, fixture.expected.targetTitle)
|
||||
const expectedPartIDs = fixture.expected.targetPartIDs
|
||||
const expectedMessageIDs = fixture.expected.targetMessageIDs
|
||||
await expectSessionTimelineReady(page, expectedPartIDs, expectedMessageIDs, errors)
|
||||
@@ -742,8 +737,8 @@ async function selectHomeProject(page: Page, projectName: string) {
|
||||
await expect(page).toHaveURL(/\/$/)
|
||||
}
|
||||
|
||||
async function navigateToSession(page: Page, directory: string, sessionId: string, expectedTitle: string) {
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionId}`)
|
||||
async function navigateToSession(page: Page, sessionId: string, expectedTitle: string) {
|
||||
await page.goto(`/server/${base64Encode(fixture.serverKey)}/session/${sessionId}`)
|
||||
await expectSessionTitle(page, expectedTitle)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ const directory = "C:/OpenCode/NewProject"
|
||||
|
||||
test("creates a session in a new project and selects its model", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
protocol: "v2",
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_model_selection_flow",
|
||||
|
||||
@@ -6,6 +6,9 @@ export function trackPageErrors(page: Page) {
|
||||
if (message.type() === "error") errors.push(message.text())
|
||||
})
|
||||
page.on("pageerror", (error) => errors.push(error.stack ?? error.message))
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) errors.push(`${response.status()} ${response.url()}`)
|
||||
})
|
||||
return errors
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +1,30 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import type {
|
||||
JsonValue,
|
||||
PromptAgentAttachment,
|
||||
PromptFileAttachment,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageInfo,
|
||||
SessionStructuredError,
|
||||
} from "@opencode-ai/client/promise"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
import type { JsonValue, OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
export interface MockServerConfig {
|
||||
protocol?: "v1" | "v2"
|
||||
provider: unknown | (() => unknown)
|
||||
integrationMethods?: Record<string, unknown[]>
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
onInstanceDispose?: () => void
|
||||
directory: string
|
||||
project: unknown
|
||||
sessions: ({ id: string } & Record<string, unknown>)[]
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
pageMessages: (
|
||||
sessionId: string,
|
||||
limit: number,
|
||||
before?: string,
|
||||
) => {
|
||||
items: SessionMessageInfo[]
|
||||
cursor?: string
|
||||
}
|
||||
vcsDiff?: unknown[]
|
||||
messageDelay?: number
|
||||
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
message?: (sessionID: string, messageID: string) => unknown
|
||||
message?: (sessionID: string, messageID: string) => SessionMessageInfo | undefined
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
events?: () => OpenCodeEvent[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
fileList?: (path: string) => unknown | Promise<unknown>
|
||||
fileContent?: (path: string) => unknown | Promise<unknown>
|
||||
@@ -46,10 +39,11 @@ type MockStreamWindow = Window & {
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const cursors = new Map<string, string>()
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
let nextCursor = 0
|
||||
|
||||
await page.addInitScript(
|
||||
({ port, retry }) => {
|
||||
({ server, retry }) => {
|
||||
const host = window as MockStreamWindow
|
||||
if (host.__testSseTransport || host.__mockServerStream) return
|
||||
const originalFetch = window.fetch.bind(window)
|
||||
@@ -74,7 +68,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const fetch = (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input, init)
|
||||
const url = new URL(request.url)
|
||||
if (url.port !== port || url.pathname !== "/api/event") return originalFetch(request)
|
||||
if (url.origin !== server || url.pathname !== "/api/event") return originalFetch(request)
|
||||
state.connections += 1
|
||||
const id = state.connections
|
||||
let ended = false
|
||||
@@ -114,7 +108,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}
|
||||
Object.defineProperty(window, "fetch", { configurable: true, writable: true, value: fetch })
|
||||
},
|
||||
{ port: process.env.PLAYWRIGHT_SERVER_PORT ?? "4096", retry: config.eventRetry },
|
||||
{ server, retry: config.eventRetry },
|
||||
)
|
||||
|
||||
if (config.events) {
|
||||
@@ -125,10 +119,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (batch.length === 0) return
|
||||
pump.busy = true
|
||||
void page
|
||||
.evaluate(
|
||||
(payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads),
|
||||
batch.map(currentEvent),
|
||||
)
|
||||
.evaluate((payloads) => (window as MockStreamWindow).__mockServerStream?.push(payloads), batch as unknown[])
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
pump.busy = false
|
||||
@@ -136,87 +127,33 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
}, 50)
|
||||
page.on("close", () => clearInterval(timer))
|
||||
}
|
||||
const staticRoutes: Record<string, unknown> = {
|
||||
"/path": {
|
||||
state: config.directory,
|
||||
config: config.directory,
|
||||
worktree: config.directory,
|
||||
directory: config.directory,
|
||||
home: "C:/OpenCode",
|
||||
},
|
||||
"/project": [config.project],
|
||||
"/project/current": config.project,
|
||||
"/agent": [{ name: "build", mode: "primary" }],
|
||||
"/vcs": { branch: "main", default_branch: "main" },
|
||||
"/session": config.sessions,
|
||||
}
|
||||
await page.route("**/*", async (route) => {
|
||||
const url = new URL(route.request().url())
|
||||
const targetPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
|
||||
const appPort = new URL(
|
||||
process.env.PLAYWRIGHT_BASE_URL ?? `http://127.0.0.1:${process.env.PLAYWRIGHT_PORT ?? "3000"}`,
|
||||
).port
|
||||
if (url.port !== targetPort && url.port !== appPort) return route.fallback()
|
||||
if (url.origin !== server && url.port !== appPort) return route.fallback()
|
||||
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event" || path === "/api/event") {
|
||||
if (path === "/api/event") {
|
||||
const events = config.events?.()
|
||||
return sse(
|
||||
route,
|
||||
path === "/api/event"
|
||||
? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])]
|
||||
: [
|
||||
...(path === "/global/event"
|
||||
? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }]
|
||||
: []),
|
||||
...(events ?? []),
|
||||
],
|
||||
[{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events ?? [])],
|
||||
config.eventRetry,
|
||||
)
|
||||
}
|
||||
if (path === "/global/health")
|
||||
return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true })
|
||||
if (path === "/api/health") return json(route, { healthy: true, version: "2.0.0", pid: 1 })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true })
|
||||
if (path === "/provider") return json(route, providerConfig(config))
|
||||
if (path === "/provider/auth") return json(route, config.integrationMethods ?? {})
|
||||
const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1]
|
||||
if (legacyAuth && route.request().method() === "PUT") {
|
||||
config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() })
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/instance/dispose" && route.request().method() === "POST") {
|
||||
config.onInstanceDispose?.()
|
||||
return json(route, true)
|
||||
}
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/session/status")
|
||||
return json(
|
||||
route,
|
||||
typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}),
|
||||
)
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (path === "/file" && config.fileList)
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/find/file" && config.findFiles)
|
||||
return json(
|
||||
route,
|
||||
await config.findFiles({
|
||||
query: url.searchParams.get("query") ?? "",
|
||||
dirs: url.searchParams.get("dirs") ?? undefined,
|
||||
limit: url.searchParams.has("limit") ? Number(url.searchParams.get("limit")) : undefined,
|
||||
}),
|
||||
)
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||
project: {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
},
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
@@ -277,7 +214,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
])
|
||||
}
|
||||
if (path === "/api/project/current")
|
||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
||||
return json(route, {
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
})
|
||||
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
|
||||
if (worktree && route.request().method() === "GET")
|
||||
return json(route, [
|
||||
@@ -303,18 +244,13 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
currentPermission,
|
||||
),
|
||||
})
|
||||
if (path === "/api/question/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []),
|
||||
})
|
||||
if (path === "/api/form/request")
|
||||
return json(route, {
|
||||
location: location(config),
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
})
|
||||
if (path === "/api/vcs")
|
||||
return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } })
|
||||
return json(route, { location: location(config), data: { branch: { current: "main", default: "main" } } })
|
||||
if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] })
|
||||
if (path === "/api/fs/list" && config.fileList)
|
||||
@@ -352,10 +288,26 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
: entries,
|
||||
})
|
||||
}
|
||||
if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] })
|
||||
if (path === "/api/shell" && route.request().method() === "GET")
|
||||
return json(route, { location: location(config), data: [] })
|
||||
if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path))
|
||||
return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } })
|
||||
if (path === "/api/session") {
|
||||
if (route.request().method() === "POST") {
|
||||
const payload = route.request().postDataJSON() as Record<string, unknown>
|
||||
const created = currentSession(
|
||||
{
|
||||
id: "ses_mock_created",
|
||||
projectID: (config.project as { id?: string }).id,
|
||||
title: typeof payload.title === "string" ? payload.title : "New session",
|
||||
parentID: typeof payload.parentID === "string" ? payload.parentID : undefined,
|
||||
},
|
||||
config.directory,
|
||||
)
|
||||
config.sessions.push(created)
|
||||
return json(route, { data: created })
|
||||
}
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
const directory = url.searchParams.get("directory")
|
||||
const parentID = url.searchParams.get("parentID")
|
||||
const limit = Number(url.searchParams.get("limit") ?? 50)
|
||||
@@ -365,7 +317,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const location = session.location as { directory?: string } | undefined
|
||||
return !directory || location?.directory === directory || session.directory === directory
|
||||
})
|
||||
.filter((session) => parentID !== "null" || session.parentID === undefined)
|
||||
.filter((session) => {
|
||||
if (parentID === null) return true
|
||||
if (parentID === "null") return session.parentID === undefined
|
||||
return session.parentID === parentID
|
||||
})
|
||||
.filter((session) => {
|
||||
const search = url.searchParams.get("search")?.toLowerCase()
|
||||
return (
|
||||
@@ -375,7 +331,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
.includes(search)
|
||||
)
|
||||
})
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions
|
||||
const ordered = url.searchParams.get("order") === "asc" ? sessions : sessions.toReversed()
|
||||
const data = ordered.slice(offset, offset + limit)
|
||||
const next = offset + limit < ordered.length ? String(offset + limit) : undefined
|
||||
return json(route, {
|
||||
@@ -398,9 +354,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
const sessionForm = path.match(/^\/api\/session\/([^/]+)\/form$/)?.[1]
|
||||
if (sessionForm && route.request().method() === "GET") {
|
||||
const forms = typeof config.forms === "function" ? config.forms() : (config.forms ?? [])
|
||||
@@ -416,12 +369,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST")
|
||||
return json(route, true)
|
||||
if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST")
|
||||
return json(route, true)
|
||||
if (
|
||||
/^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
/^\/api\/session\/[^/]+\/(rename|interrupt|revert\/clear|revert\/commit)$/.test(path) &&
|
||||
route.request().method() === "POST"
|
||||
) {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
@@ -429,10 +378,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||
}
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
|
||||
const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/)
|
||||
if (currentSessionMatch) {
|
||||
const session = config.sessions.find((item) => item.id === currentSessionMatch[1])
|
||||
@@ -442,69 +387,37 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
const currentMessageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (currentMessageMatch) {
|
||||
config.onMessage?.({ sessionID: currentMessageMatch[1]!, messageID: currentMessageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(currentMessageMatch[1]!, currentMessageMatch[2]!)
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, { data: currentMessage(message) })
|
||||
}
|
||||
|
||||
const sessionMatch = path.match(/^\/session\/([^/]+)$/)
|
||||
if (sessionMatch) return json(route, config.sessions.find((session) => session.id === sessionMatch[1]) ?? {})
|
||||
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
|
||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
const messageMatch = path.match(/^\/api\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
||||
const message =
|
||||
config.message?.(messageMatch[1]!, messageMatch[2]!) ??
|
||||
config.pageMessages(messageMatch[1]!, Number.MAX_SAFE_INTEGER).items.find((item) => item.id === messageMatch[2])
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, message)
|
||||
return json(route, { data: message })
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
|
||||
const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (currentMessagesMatch) {
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: pageData.items.map(currentMessage).reverse(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/)
|
||||
const messagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/)
|
||||
if (messagesMatch) {
|
||||
const token = url.searchParams.get("before") ?? undefined
|
||||
const token = url.searchParams.get("cursor") ?? undefined
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 80), before)
|
||||
const pageData = config.pageMessages(messagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
if (!pageData.cursor) return json(route, pageData.items)
|
||||
const cursor = `cursor_${++nextCursor}`
|
||||
cursors.set(cursor, pageData.cursor)
|
||||
return json(route, pageData.items, { "x-next-cursor": cursor })
|
||||
const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined
|
||||
if (cursor) cursors.set(cursor, pageData.cursor!)
|
||||
return json(route, {
|
||||
data: url.searchParams.get("order") === "asc" ? pageData.items : pageData.items.toReversed(),
|
||||
cursor: { next: cursor },
|
||||
})
|
||||
}
|
||||
|
||||
if (url.port === targetPort && targetPort !== appPort) return json(route, {})
|
||||
if (url.port === targetPort && targetPort !== appPort)
|
||||
return json(route, { error: `Unhandled mock route: ${path}` }, undefined, 404)
|
||||
return route.fallback()
|
||||
})
|
||||
}
|
||||
@@ -522,13 +435,21 @@ function providerConfig(config: MockServerConfig) {
|
||||
|
||||
function currentProviders(value: unknown) {
|
||||
if (!record(value) || !Array.isArray(value.all)) return Array.isArray(value) ? value : []
|
||||
return value.all
|
||||
.filter(record)
|
||||
.flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [{ id: provider.id, name: provider.name, package: provider.id }]
|
||||
: [],
|
||||
)
|
||||
const connected = new Set(
|
||||
Array.isArray(value.connected) ? value.connected.filter((id) => typeof id === "string") : [],
|
||||
)
|
||||
return value.all.filter(record).flatMap((provider) =>
|
||||
typeof provider.id === "string" && typeof provider.name === "string"
|
||||
? [
|
||||
{
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
package: provider.id,
|
||||
activation: connected.has(provider.id) ? "enabled" : "auto",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
function currentModels(value: unknown) {
|
||||
@@ -584,7 +505,7 @@ function currentDefaultModel(value: unknown) {
|
||||
function currentPermission(value: unknown) {
|
||||
const permission = value as Record<string, unknown>
|
||||
if (permission.action) return permission
|
||||
const tool = permission.tool as { messageID?: string; callID?: string } | undefined
|
||||
const tool = permission.tool as { messageID?: string; callID?: string; id?: string } | undefined
|
||||
return {
|
||||
id: permission.id,
|
||||
sessionID: permission.sessionID,
|
||||
@@ -593,7 +514,9 @@ function currentPermission(value: unknown) {
|
||||
save: permission.always,
|
||||
metadata: permission.metadata,
|
||||
source:
|
||||
tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined,
|
||||
tool?.messageID && (tool.id || tool.callID)
|
||||
? { type: "tool", messageID: tool.messageID, id: tool.id ?? tool.callID }
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -634,202 +557,6 @@ export function currentSession(session: { id: string } & Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export function currentMessage(value: unknown): SessionMessageInfo {
|
||||
if (isCurrentMessage(value)) return value
|
||||
if (!record(value) || !record(value.info) || !Array.isArray(value.parts)) throw new Error("Invalid message fixture")
|
||||
|
||||
const info = value.info
|
||||
const parts = value.parts.filter(record)
|
||||
if (typeof info.id !== "string" || !record(info.time) || typeof info.time.created !== "number")
|
||||
throw new Error("Invalid legacy message fixture")
|
||||
|
||||
const time = {
|
||||
created: info.time.created,
|
||||
...(typeof info.time.completed === "number" ? { completed: info.time.completed } : {}),
|
||||
}
|
||||
if (info.role === "user") {
|
||||
return {
|
||||
id: info.id,
|
||||
type: "user",
|
||||
time: { created: time.created },
|
||||
text: parts
|
||||
.flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : []))
|
||||
.join("\n"),
|
||||
files: parts.flatMap((part) => (part.type === "file" ? legacyFile(part) : [])),
|
||||
agents: parts.flatMap((part) => (part.type === "agent" ? legacyAgent(part) : [])),
|
||||
}
|
||||
}
|
||||
if (info.role !== "assistant") throw new Error("Invalid legacy message role")
|
||||
|
||||
return {
|
||||
id: info.id,
|
||||
type: "assistant",
|
||||
time,
|
||||
agent: typeof info.agent === "string" ? info.agent : typeof info.mode === "string" ? info.mode : "build",
|
||||
model: {
|
||||
id: typeof info.modelID === "string" ? info.modelID : "model",
|
||||
providerID: typeof info.providerID === "string" ? info.providerID : "provider",
|
||||
...(typeof info.variant === "string" ? { variant: info.variant } : {}),
|
||||
},
|
||||
content: parts.flatMap((part) => legacyAssistantContent(part, time.created)),
|
||||
...(typeof info.cost === "number" ? { cost: info.cost } : {}),
|
||||
...(tokens(info.tokens) ? { tokens: tokens(info.tokens) } : {}),
|
||||
...(structuredError(info.error) ? { error: structuredError(info.error) } : {}),
|
||||
...(finish(info.finish) ? { finish: finish(info.finish) } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function isCurrentMessage(value: unknown): value is SessionMessageInfo {
|
||||
return record(value) && typeof value.id === "string" && typeof value.type === "string" && !record(value.info)
|
||||
}
|
||||
|
||||
function legacyFile(part: Record<string, unknown>): PromptFileAttachment[] {
|
||||
if (typeof part.mime !== "string" || typeof part.url !== "string") return []
|
||||
const data = part.url.match(/^data:[^,]*;base64,(.*)$/)?.[1] ?? ""
|
||||
const source = record(part.source) ? part.source : undefined
|
||||
const sourceText = source && record(source.text) ? source.text : undefined
|
||||
const mention = mentionFrom(sourceText)
|
||||
const uri = source?.type === "resource" && typeof source.uri === "string" ? source.uri : part.url
|
||||
return [
|
||||
{
|
||||
data,
|
||||
mime: part.mime,
|
||||
source: part.url.startsWith("data:") ? { type: "inline" } : { type: "uri", uri },
|
||||
...(typeof part.filename === "string" ? { name: part.filename } : {}),
|
||||
...(mention ? { mention } : {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function legacyAgent(part: Record<string, unknown>): PromptAgentAttachment[] {
|
||||
if (typeof part.name !== "string") return []
|
||||
const mention = mentionFrom(record(part.source) ? part.source : undefined)
|
||||
return [{ name: part.name, ...(mention ? { mention } : {}) }]
|
||||
}
|
||||
|
||||
function mentionFrom(value: Record<string, unknown> | undefined) {
|
||||
if (!value || typeof value.value !== "string" || typeof value.start !== "number" || typeof value.end !== "number")
|
||||
return
|
||||
return { text: value.value, start: value.start, end: value.end }
|
||||
}
|
||||
|
||||
function legacyAssistantContent(part: Record<string, unknown>, created: number): SessionMessageAssistant["content"] {
|
||||
if (part.type === "text" && typeof part.text === "string")
|
||||
return [
|
||||
{ type: "text", text: part.text, ...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}) },
|
||||
]
|
||||
if (part.type === "reasoning" && typeof part.text === "string") {
|
||||
const time = record(part.time) ? part.time : undefined
|
||||
return [
|
||||
{
|
||||
type: "reasoning",
|
||||
text: part.text,
|
||||
...(jsonRecord(part.metadata) ? { state: jsonRecord(part.metadata) } : {}),
|
||||
...(time && typeof time.start === "number"
|
||||
? {
|
||||
time: {
|
||||
created: time.start,
|
||||
...(typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]
|
||||
}
|
||||
if (part.type !== "tool" || typeof part.id !== "string" || typeof part.tool !== "string" || !record(part.state))
|
||||
return []
|
||||
|
||||
const state = part.state
|
||||
const time = record(state.time) ? state.time : undefined
|
||||
const toolTime = {
|
||||
created: time && typeof time.start === "number" ? time.start : created,
|
||||
...(time && typeof time.start === "number" ? { ran: time.start } : {}),
|
||||
...(time && typeof time.end === "number" ? { completed: time.end } : {}),
|
||||
}
|
||||
const input = jsonRecord(state.input) ?? {}
|
||||
const metadata = jsonRecord(state.metadata)
|
||||
const base = {
|
||||
type: "tool" as const,
|
||||
id: typeof part.callID === "string" ? part.callID : part.id,
|
||||
name: part.tool,
|
||||
time: toolTime,
|
||||
...(typeof part.executed === "boolean" ? { executed: part.executed } : {}),
|
||||
...(jsonRecord(part.providerState) ? { providerState: jsonRecord(part.providerState) } : {}),
|
||||
...(jsonRecord(part.providerResultState) ? { providerResultState: jsonRecord(part.providerResultState) } : {}),
|
||||
}
|
||||
if (state.status === "pending")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: { status: "streaming", input: typeof state.raw === "string" ? state.raw : JSON.stringify(input) },
|
||||
},
|
||||
]
|
||||
if (state.status === "completed")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "completed",
|
||||
input,
|
||||
content: [{ type: "text", text: typeof state.output === "string" ? state.output : "" }],
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
if (state.status === "error")
|
||||
return [
|
||||
{
|
||||
...base,
|
||||
state: {
|
||||
status: "error",
|
||||
input,
|
||||
error: structuredError(state.error) ?? { type: "ToolError", message: "Tool failed" },
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
},
|
||||
]
|
||||
return [{ ...base, state: { status: "running", input, metadata: metadata ?? {} } }]
|
||||
}
|
||||
|
||||
function structuredError(value: unknown): SessionStructuredError | undefined {
|
||||
if (typeof value === "string") return { type: "Error", message: value }
|
||||
if (!record(value)) return
|
||||
if (typeof value.type === "string" && typeof value.message === "string")
|
||||
return { type: value.type, message: value.message }
|
||||
if (typeof value.name !== "string" || !record(value.data) || typeof value.data.message !== "string") return
|
||||
return { type: value.name, message: value.data.message }
|
||||
}
|
||||
|
||||
function tokens(value: unknown): SessionMessageAssistant["tokens"] | undefined {
|
||||
if (!record(value) || !record(value.cache)) return
|
||||
if (
|
||||
typeof value.input !== "number" ||
|
||||
typeof value.output !== "number" ||
|
||||
typeof value.reasoning !== "number" ||
|
||||
typeof value.cache.read !== "number" ||
|
||||
typeof value.cache.write !== "number"
|
||||
)
|
||||
return
|
||||
return {
|
||||
input: value.input,
|
||||
output: value.output,
|
||||
reasoning: value.reasoning,
|
||||
cache: { read: value.cache.read, write: value.cache.write },
|
||||
}
|
||||
}
|
||||
|
||||
function finish(value: unknown): SessionMessageAssistant["finish"] | undefined {
|
||||
if (
|
||||
value === "stop" ||
|
||||
value === "length" ||
|
||||
value === "tool-calls" ||
|
||||
value === "content-filter" ||
|
||||
value === "error" ||
|
||||
value === "unknown"
|
||||
)
|
||||
return value
|
||||
}
|
||||
|
||||
function jsonRecord(value: unknown): Record<string, JsonValue> | undefined {
|
||||
if (!record(value)) return
|
||||
return Object.fromEntries(
|
||||
@@ -871,18 +598,3 @@ function sse(route: Route, events?: unknown[], retry?: number) {
|
||||
body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`,
|
||||
})
|
||||
}
|
||||
|
||||
function currentEvent(input: unknown) {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
|
||||
export type SseConnectionRecord = {
|
||||
id: number
|
||||
@@ -27,7 +28,7 @@ export type SseEventOptions = {
|
||||
marker?: string
|
||||
}
|
||||
|
||||
export type SseTransport<T> = {
|
||||
export type SseTransport<T extends OpenCodeEvent> = {
|
||||
server: string
|
||||
waitForConnection(options?: { after?: number; timeout?: number }): Promise<SseConnectionRecord>
|
||||
send(payload: T, options?: SseEventOptions): Promise<SseDeliveryAcknowledgement>
|
||||
@@ -55,7 +56,7 @@ type BrowserTransport = Window & {
|
||||
}
|
||||
}
|
||||
|
||||
export async function installSseTransport<T>(
|
||||
export async function installSseTransport<T extends OpenCodeEvent = OpenCodeEvent>(
|
||||
page: Page,
|
||||
options: { server: string; retry?: number },
|
||||
): Promise<SseTransport<T>> {
|
||||
@@ -93,21 +94,6 @@ export async function installSseTransport<T>(
|
||||
eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`,
|
||||
`data: ${JSON.stringify(payload)}\n\n`,
|
||||
].join("")
|
||||
const currentEvent = (input: unknown) => {
|
||||
if (!input || typeof input !== "object" || !("payload" in input)) return input
|
||||
const envelope = input as { directory?: string; payload?: unknown }
|
||||
if (!envelope.payload || typeof envelope.payload !== "object") return input
|
||||
const payload = envelope.payload as { id?: string; type?: string; properties?: unknown }
|
||||
if (!payload.type) return input
|
||||
return {
|
||||
id: payload.id ?? `evt_mock_${Date.now()}`,
|
||||
created: Date.now(),
|
||||
type: payload.type,
|
||||
data: payload.properties ?? {},
|
||||
location:
|
||||
envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined,
|
||||
}
|
||||
}
|
||||
const acknowledge = (
|
||||
connection: Connection,
|
||||
bytes: number,
|
||||
@@ -155,10 +141,11 @@ export async function installSseTransport<T>(
|
||||
output.forEach((chunk) => connection.controller.enqueue(chunk))
|
||||
return acknowledge(connection, input.bytes.length, output.length)
|
||||
}
|
||||
const encoded = input.deliveries.map((delivery) => {
|
||||
const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload
|
||||
return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) }
|
||||
})
|
||||
const encoded = input.deliveries.map((delivery) => ({
|
||||
delivery,
|
||||
payload: delivery.payload,
|
||||
bytes: encoder.encode(frame(delivery.payload, delivery.options)),
|
||||
}))
|
||||
encoded.forEach((item) => marker(item.delivery.options?.marker))
|
||||
if (input.burst) {
|
||||
const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join(""))
|
||||
@@ -266,15 +253,11 @@ export async function installSseTransport<T>(
|
||||
return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false, cuts: [...cuts] })
|
||||
},
|
||||
heartbeat(eventOptions) {
|
||||
const bytes = new TextEncoder().encode(": heartbeat\n\n")
|
||||
return command({
|
||||
type: "send",
|
||||
deliveries: [
|
||||
{
|
||||
payload: { directory: "global", payload: { type: "server.heartbeat", properties: {} } } as T,
|
||||
options: eventOptions,
|
||||
},
|
||||
],
|
||||
burst: false,
|
||||
type: "raw",
|
||||
bytes: Array.from(bytes),
|
||||
marker: eventOptions?.marker,
|
||||
})
|
||||
},
|
||||
writeRaw(value, cuts, marker) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Page, TestInfo } from "@playwright/test"
|
||||
import { analyzeVisualObservations, analyzeVisualTraceByMarker } from "./visual-stability/analyzer"
|
||||
import { legacyVisualPlan, type LegacyVisualStabilityOptions } from "./visual-stability/invariant"
|
||||
import { stabilityPlan, type VisualStabilityOptions } from "./visual-stability/invariant"
|
||||
import type { CapturedFrame, VisualStabilityTrace } from "./visual-stability/model"
|
||||
import { markVisualProbe, startVisualProbe, stopVisualProbe } from "./visual-stability/probe"
|
||||
import type { VisualRegionDefinition } from "./visual-stability/regions"
|
||||
@@ -25,22 +25,19 @@ export async function markVisualStability(page: Page, label: string) {
|
||||
await markVisualProbe(page, label)
|
||||
}
|
||||
|
||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: LegacyVisualStabilityOptions = {}) {
|
||||
return analyzeVisualObservations(trace.samples, legacyVisualPlan(options))
|
||||
export function analyzeVisualStability(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
|
||||
return analyzeVisualObservations(trace.samples, stabilityPlan(options))
|
||||
}
|
||||
|
||||
export function analyzeVisualStabilityByMarker(
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
) {
|
||||
return analyzeVisualTraceByMarker(trace, legacyVisualPlan(options))
|
||||
export function analyzeVisualStabilityByMarker(trace: VisualStabilityTrace, options: VisualStabilityOptions = {}) {
|
||||
return analyzeVisualTraceByMarker(trace, stabilityPlan(options))
|
||||
}
|
||||
|
||||
export async function expectVisualStability(
|
||||
testInfo: TestInfo,
|
||||
name: string,
|
||||
trace: VisualStabilityTrace,
|
||||
options: LegacyVisualStabilityOptions = {},
|
||||
options: VisualStabilityOptions = {},
|
||||
) {
|
||||
await reportVisualStability(
|
||||
testInfo,
|
||||
@@ -49,6 +46,6 @@ export async function expectVisualStability(
|
||||
...trace,
|
||||
frames: (trace as VisualStabilityTrace & { [capturedFrames]?: CapturedFrame[] })[capturedFrames] ?? [],
|
||||
},
|
||||
legacyVisualPlan(options),
|
||||
stabilityPlan(options),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ export type VisualPlan<RegionName extends string = string> = {
|
||||
aggregateMotion?: boolean
|
||||
}
|
||||
|
||||
export type LegacyVisualStabilityOptions<RegionName extends string = string> = {
|
||||
export type VisualStabilityOptions<RegionName extends string = string> = {
|
||||
flow?: RegionName[]
|
||||
motionTolerance?: number
|
||||
opacityFloor?: number
|
||||
@@ -58,8 +58,8 @@ export function visualPlan<const Regions extends Record<string, VisualRegionDefi
|
||||
return { ...options, regionNames: Object.keys(regions) as Extract<keyof Regions, string>[], invariants }
|
||||
}
|
||||
|
||||
export function legacyVisualPlan<RegionName extends string>(
|
||||
options: LegacyVisualStabilityOptions<RegionName> = {},
|
||||
export function stabilityPlan<RegionName extends string>(
|
||||
options: VisualStabilityOptions<RegionName> = {},
|
||||
): VisualPlan<RegionName> {
|
||||
const inferred =
|
||||
options.inferRequired === false
|
||||
|
||||
@@ -2,13 +2,6 @@
|
||||
var key = "opencode-theme-id"
|
||||
var themeId = localStorage.getItem(key) || "oc-2"
|
||||
|
||||
if (themeId === "oc-1") {
|
||||
themeId = "oc-2"
|
||||
localStorage.setItem(key, themeId)
|
||||
localStorage.removeItem("opencode-theme-css-light")
|
||||
localStorage.removeItem("opencode-theme-css-dark")
|
||||
}
|
||||
|
||||
var scheme = localStorage.getItem("opencode-color-scheme") || "system"
|
||||
var isDark = scheme === "dark" || (scheme === "system" && matchMedia("(prefers-color-scheme: dark)").matches)
|
||||
var mode = isDark ? "dark" : "light"
|
||||
|
||||
@@ -12,7 +12,6 @@ import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchPara
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import {
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createRenderEffect,
|
||||
ErrorBoundary,
|
||||
@@ -32,7 +31,7 @@ import { LayoutProvider } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { PromptProvider } from "@/context/prompt"
|
||||
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
|
||||
import { ServerConnection, ServersProvider } from "@/context/servers"
|
||||
import { SettingsProvider } from "@/context/settings"
|
||||
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
@@ -40,8 +39,7 @@ import { WslServersProvider } from "@/wsl/context"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import Layout from "@/pages/layout"
|
||||
import { ErrorPage } from "./pages/error"
|
||||
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { requireServerKey } from "./utils/session-route"
|
||||
|
||||
import { TargetSessionRouteContent } from "@/pages/session"
|
||||
import { Home } from "@/pages/home"
|
||||
@@ -49,22 +47,6 @@ import { ServerProvider } from "./context/server"
|
||||
|
||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||
|
||||
const DirectoryDraftRedirect = () => {
|
||||
const params = useParams()
|
||||
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
|
||||
createEffect(() => {
|
||||
if (search.draftId || !tabs.ready()) return
|
||||
const directory = decode64(params.dir)
|
||||
if (!directory) return
|
||||
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
|
||||
})
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function TargetServerRoute(props: ParentProps) {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const global = useGlobal()
|
||||
@@ -264,8 +246,6 @@ export function AppInterface(props: {
|
||||
canonicalLocalServer?: ServerConnection.Key
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
startup?: Promise<void>
|
||||
}) {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
@@ -292,7 +272,6 @@ export function AppInterface(props: {
|
||||
<SettingsProvider>
|
||||
<GlobalProvider>
|
||||
<Dynamic component={props.router ?? Router} root={Root}>
|
||||
{/* Proper Routes */}
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route
|
||||
@@ -305,36 +284,9 @@ export function AppInterface(props: {
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
{/* Legacy Routes */}
|
||||
<Route>
|
||||
<Route path="/:dir" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
|
||||
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
|
||||
</Route>
|
||||
</Dynamic>
|
||||
</GlobalProvider>
|
||||
</SettingsProvider>
|
||||
</ServersProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function LegacySessionRedirect() {
|
||||
const server = useServers()
|
||||
const tabs = useTabs()
|
||||
const params = useParams<{ id: string }>()
|
||||
|
||||
return (
|
||||
<Show when={tabs.ready()}>
|
||||
<Navigate
|
||||
href={sessionHref(
|
||||
legacySessionServer(
|
||||
tabs.store.filter((item) => item.type === "session"),
|
||||
params.id,
|
||||
ServerConnection.key(server.list[0]!),
|
||||
),
|
||||
params.id,
|
||||
)}
|
||||
/>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServer } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
interface ForkableMessage {
|
||||
id: string
|
||||
@@ -30,6 +32,7 @@ export const DialogFork: Component = () => {
|
||||
const prompt = usePrompt()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
|
||||
const messages = createMemo((): ForkableMessage[] => {
|
||||
const sessionID = params.id
|
||||
@@ -73,7 +76,7 @@ export const DialogFork: Component = () => {
|
||||
.then((forked) => {
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
navigate(sessionHref(server.key, forked.id))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -16,6 +16,21 @@ export type PromptInputHistory = {
|
||||
|
||||
type PromptHistoryState = { entries: PromptHistoryStoredEntry[] }
|
||||
|
||||
export function upgradeHistoryState(value: unknown) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value) || !("entries" in value)) return value
|
||||
const entries = value.entries
|
||||
if (!Array.isArray(entries)) return value
|
||||
return {
|
||||
...value,
|
||||
entries: entries.flatMap((entry): PromptHistoryStoredEntry[] => {
|
||||
if (Array.isArray(entry)) return [{ prompt: clonePromptParts(entry as Prompt), comments: [] }]
|
||||
if (!entry || typeof entry !== "object" || !("prompt" in entry) || !Array.isArray(entry.prompt)) return []
|
||||
if (!("comments" in entry) || !Array.isArray(entry.comments)) return []
|
||||
return [entry as PromptHistoryStoredEntry]
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
function createPromptInputHistoryStore(
|
||||
normal: Store<PromptHistoryState>,
|
||||
setNormal: SetStoreFunction<PromptHistoryState>,
|
||||
@@ -42,11 +57,11 @@ export function createPromptInputHistory(): PromptInputHistory {
|
||||
|
||||
export function createPersistedPromptInputHistory() {
|
||||
const [normal, setNormal, normalInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history", ["prompt-history.v1"])),
|
||||
{ ...Persist.prompt(Persist.global("prompt-history")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const [shell, setShell, shellInit] = persisted(
|
||||
Persist.prompt(Persist.global("prompt-history-shell", ["prompt-history-shell.v1"])),
|
||||
{ ...Persist.prompt(Persist.global("prompt-history-shell")), migrate: upgradeHistoryState },
|
||||
createStore<PromptHistoryState>({ entries: [] }),
|
||||
)
|
||||
const history = createPromptInputHistoryStore(normal, setNormal, shell, setShell)
|
||||
|
||||
@@ -3,16 +3,17 @@ import type { Prompt } from "@/context/prompt"
|
||||
import {
|
||||
canNavigateHistoryAtCursor,
|
||||
clonePromptParts,
|
||||
normalizePromptHistoryEntry,
|
||||
navigatePromptHistory,
|
||||
prependHistoryEntry,
|
||||
promptLength,
|
||||
type PromptHistoryComment,
|
||||
} from "./history"
|
||||
import { upgradeHistoryState } from "./history-store"
|
||||
|
||||
const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }]
|
||||
|
||||
const text = (value: string): Prompt => [{ type: "text", content: value, start: 0, end: value.length }]
|
||||
const entry = (value: string) => ({ prompt: text(value), comments: [] })
|
||||
const comment = (id: string, value = "note"): PromptHistoryComment => ({
|
||||
id,
|
||||
path: "src/a.ts",
|
||||
@@ -42,7 +43,7 @@ describe("prompt-input history", () => {
|
||||
})
|
||||
|
||||
test("navigatePromptHistory restores saved prompt when moving down from newest", () => {
|
||||
const entries = [text("third"), text("second"), text("first")]
|
||||
const entries = [entry("third"), entry("second"), entry("first")]
|
||||
const up = navigatePromptHistory({
|
||||
direction: "up",
|
||||
entries,
|
||||
@@ -95,10 +96,10 @@ describe("prompt-input history", () => {
|
||||
expect(up.entry.comments).toEqual([comment("c1")])
|
||||
})
|
||||
|
||||
test("normalizePromptHistoryEntry supports legacy prompt arrays", () => {
|
||||
const entry = normalizePromptHistoryEntry(text("legacy"))
|
||||
expect(entry.prompt[0]?.type === "text" ? entry.prompt[0].content : "").toBe("legacy")
|
||||
expect(entry.comments).toEqual([])
|
||||
test("upgrades stored prompt arrays once at the persistence boundary", () => {
|
||||
expect(upgradeHistoryState({ entries: [text("stored")] })).toEqual({
|
||||
entries: [{ prompt: text("stored"), comments: [] }],
|
||||
})
|
||||
})
|
||||
|
||||
test("helpers clone prompt and count text content length", () => {
|
||||
|
||||
@@ -20,7 +20,7 @@ export type PromptHistoryEntry = {
|
||||
comments: PromptHistoryComment[]
|
||||
}
|
||||
|
||||
export type PromptHistoryStoredEntry = Prompt | PromptHistoryEntry
|
||||
export type PromptHistoryStoredEntry = PromptHistoryEntry
|
||||
|
||||
export function canNavigateHistoryAtCursor(direction: "up" | "down", text: string, cursor: number, inHistory = false) {
|
||||
const position = Math.max(0, Math.min(cursor, text.length))
|
||||
@@ -60,12 +60,6 @@ export function clonePromptHistoryComments(comments: PromptHistoryComment[]) {
|
||||
}
|
||||
|
||||
export function normalizePromptHistoryEntry(entry: PromptHistoryStoredEntry): PromptHistoryEntry {
|
||||
if (Array.isArray(entry)) {
|
||||
return {
|
||||
prompt: clonePromptParts(entry),
|
||||
comments: [],
|
||||
}
|
||||
}
|
||||
return {
|
||||
prompt: clonePromptParts(entry.prompt),
|
||||
comments: clonePromptHistoryComments(entry.comments),
|
||||
|
||||
@@ -39,6 +39,7 @@ const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const admittedServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
const navigations: string[] = []
|
||||
let serverSessionSyncs = 0
|
||||
let restoredPrompts = 0
|
||||
let clearEchoCalls = 0
|
||||
@@ -55,12 +56,12 @@ let promptFailure: Error | undefined
|
||||
let clearEchoResult = true
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServer = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
const draftServers: Record<string, string> = {}
|
||||
const sessionDirectories: Record<string, string> = {}
|
||||
|
||||
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
@@ -178,7 +179,7 @@ beforeAll(async () => {
|
||||
const rootClient = clientFor("/repo/main")
|
||||
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useNavigate: () => (href: string) => navigations.push(href),
|
||||
useParams: () => params,
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [search, () => undefined],
|
||||
@@ -191,7 +192,10 @@ beforeAll(async () => {
|
||||
}))
|
||||
|
||||
mock.module("@opencode-ai/core/util/encode", () => ({
|
||||
base64Decode: (value: string) => value,
|
||||
base64Encode: (value: string) => value,
|
||||
checksum: (value: string) => value,
|
||||
sampledChecksum: (value: string) => value,
|
||||
}))
|
||||
|
||||
mock.module("@/context/local", () => ({
|
||||
@@ -214,12 +218,11 @@ beforeAll(async () => {
|
||||
})
|
||||
|
||||
mock.module("@/context/server", () => ({
|
||||
useServer: () => ({ key: "server-key" }),
|
||||
useServer: () => ({ key: activeServer }),
|
||||
}))
|
||||
|
||||
mock.module("@/context/tabs", () => ({
|
||||
useTabs: () => ({
|
||||
draft: (draftID: string) => ({ server: draftServers[draftID] ?? "project-server" }),
|
||||
updateDraft: (draftID: string, draft: { worktree?: string }) => {
|
||||
updatedDrafts.push({ draftID, ...draft })
|
||||
},
|
||||
@@ -343,6 +346,7 @@ beforeEach(() => {
|
||||
syncedServers.length = 0
|
||||
admittedServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
navigations.length = 0
|
||||
restoredPrompts = 0
|
||||
clearEchoCalls = 0
|
||||
params = {}
|
||||
@@ -352,6 +356,7 @@ beforeEach(() => {
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
activeSDK = "server-a"
|
||||
activeServer = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
@@ -365,7 +370,6 @@ beforeEach(() => {
|
||||
promptFailure = undefined
|
||||
clearEchoResult = true
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
@@ -414,6 +418,7 @@ describe("prompt submit worktree selection", () => {
|
||||
expect(worktreeCreates).toBe(1)
|
||||
expect(createdSessions).toHaveLength(1)
|
||||
expect(sentPrompts).toEqual([worktreeDirectory])
|
||||
expect(navigations).toEqual(["/server/server-a/session/session-1"])
|
||||
})
|
||||
|
||||
test("stops when the created workspace cannot initialize", async () => {
|
||||
@@ -429,8 +434,6 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
test("keeps async submission effects bound to the initiating context", async () => {
|
||||
search = { draftId: "draft-1" }
|
||||
draftServers["draft-1"] = "project-server-a"
|
||||
draftServers["draft-2"] = "project-server-b"
|
||||
let release = () => {}
|
||||
createSessionGate = new Promise<void>((resolve) => {
|
||||
release = resolve
|
||||
@@ -442,6 +445,7 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServer = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
@@ -450,10 +454,10 @@ describe("prompt submit worktree selection", () => {
|
||||
await settle()
|
||||
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(admittedServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "server-a", scope: ServerScope.local })
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import { ScopedKey } from "@/utils/server-scope"
|
||||
import { createPromptSubmissionState } from "./submission-state"
|
||||
import { Event } from "@opencode-ai/schema/event"
|
||||
import { blobDataUrl } from "@/utils/draft-store"
|
||||
import { useServer } from "@/context/server"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
|
||||
const submitting = new Set<string>()
|
||||
|
||||
@@ -189,6 +191,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const server = useServer()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = input.prompt
|
||||
@@ -291,12 +294,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync
|
||||
const submissionScope = submissionSDK.scope
|
||||
const submissionServer = server.key
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const sessionID = params.id
|
||||
const isNewSession = !sessionID
|
||||
const currentSession = input.info()
|
||||
const draftID = search.draftId
|
||||
const draftServer = draftID ? tabs.draft(draftID).server : undefined
|
||||
const capturePrompt = prompt.capture
|
||||
const localSession = local.session
|
||||
const resetWorktree = input.onNewSessionWorktreeReset
|
||||
@@ -376,12 +379,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
|
||||
variant: variant ?? null,
|
||||
})
|
||||
if (draftID && draftServer) tabs.promoteDraft(draftID, { server: draftServer, sessionId: session.id })
|
||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||
if (draftID) tabs.promoteDraft(draftID, { server: submissionServer, sessionId: session.id })
|
||||
else navigate(sessionHref(submissionServer, session.id))
|
||||
submission.retarget(
|
||||
capturePrompt(
|
||||
{ dir: base64Encode(sessionDirectory), id: session.id },
|
||||
{ server: draftServer, scope: submissionScope },
|
||||
{ server: submissionServer, scope: submissionScope },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -210,15 +210,6 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-v2-legacy-panel {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-v2-legacy-panel [data-component="dialog"] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.settings-v2-provider-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -176,12 +176,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(
|
||||
workspace.directory,
|
||||
preflight.sessions.map((session) => session.id),
|
||||
platform,
|
||||
context.sdk.scope,
|
||||
)
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
context.sync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
|
||||
@@ -327,7 +327,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
|
||||
>
|
||||
<ChannelIndicator debugTools={props.debugTools} />
|
||||
<Show when={windows() || linux()}>
|
||||
<WindowsAppMenu command={command} platform={platform} variant="v2" />
|
||||
<WindowsAppMenu command={command} platform={platform} />
|
||||
</Show>
|
||||
<TooltipV2
|
||||
placement="bottom"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { For, Show, type JSX } from "solid-js"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
|
||||
@@ -13,7 +12,6 @@ import { useLanguage } from "@/context/language"
|
||||
export function WindowsAppMenu(props: {
|
||||
command: ReturnType<typeof useCommand>
|
||||
platform: ReturnType<typeof usePlatform>
|
||||
variant?: "legacy" | "v2"
|
||||
}) {
|
||||
let lastFocused: HTMLElement | undefined
|
||||
const language = useLanguage()
|
||||
@@ -50,35 +48,20 @@ export function WindowsAppMenu(props: {
|
||||
|
||||
return (
|
||||
<DropdownMenu gutter={4} modal={false} placement="bottom-start">
|
||||
<Show
|
||||
when={props.variant === "v2"}
|
||||
fallback={
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButton}
|
||||
icon="menu"
|
||||
variant="ghost"
|
||||
class="titlebar-icon rounded-md shrink-0"
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
}
|
||||
<div
|
||||
data-component="desktop-icon-button"
|
||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||
>
|
||||
<div
|
||||
data-component="desktop-icon-button"
|
||||
class="flex h-7 w-9 shrink-0 items-center justify-center rounded-[6px] px-1"
|
||||
>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<DropdownMenu.Trigger
|
||||
as={IconButtonV2}
|
||||
variant="ghost-muted"
|
||||
size="large"
|
||||
icon={<IconV2 name="menu" />}
|
||||
aria-label={language.t("desktop.menu.ariaLabel")}
|
||||
onPointerDown={rememberFocus}
|
||||
onKeyDown={rememberFocus}
|
||||
/>
|
||||
</div>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content class="desktop-app-menu">
|
||||
<DropdownMenu.Group>
|
||||
|
||||
@@ -179,10 +179,8 @@ export function createCommentSessionForTest(comments: Record<string, LineComment
|
||||
}
|
||||
|
||||
function createCommentSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||
const legacy = `${dir}/comments${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "comments", [legacy]),
|
||||
Persist.serverScoped(scope, dir, id, "comments"),
|
||||
createStore<CommentStore>({
|
||||
comments: {},
|
||||
}),
|
||||
|
||||
@@ -35,10 +35,8 @@ function equalSelectedLines(a: SelectedLineRange | null | undefined, b: Selected
|
||||
}
|
||||
|
||||
function createViewSession(scope: ServerScope, dir: string, id: string | undefined) {
|
||||
const legacyViewKey = `${dir}/file${id ? "/" + id : ""}.v1`
|
||||
|
||||
const [view, setView, _, ready] = persisted(
|
||||
Persist.serverScoped(scope, dir, id, "file-view", [legacyViewKey]),
|
||||
Persist.serverScoped(scope, dir, id, "file-view"),
|
||||
createStore<{
|
||||
file: Record<string, FileViewState>
|
||||
}>({
|
||||
|
||||
@@ -156,7 +156,7 @@ export function createChildStoreManager(input: {
|
||||
if (!children[key]) {
|
||||
const vcs = runWithOwner(input.owner, () =>
|
||||
input.persist(
|
||||
Persist.serverWorkspace(input.scope, directory, "vcs", ["vcs.v1"]),
|
||||
Persist.serverWorkspace(input.scope, directory, "vcs"),
|
||||
createStore({ value: undefined as VcsInfo | undefined }),
|
||||
),
|
||||
)
|
||||
@@ -166,7 +166,7 @@ export function createChildStoreManager(input: {
|
||||
|
||||
const meta = runWithOwner(input.owner, () =>
|
||||
input.persist(
|
||||
Persist.serverWorkspace(input.scope, directory, "project", ["project.v1"]),
|
||||
Persist.serverWorkspace(input.scope, directory, "project"),
|
||||
createStore({ value: undefined as ProjectMeta | undefined }),
|
||||
),
|
||||
)
|
||||
@@ -175,7 +175,7 @@ export function createChildStoreManager(input: {
|
||||
|
||||
const icon = runWithOwner(input.owner, () =>
|
||||
input.persist(
|
||||
Persist.serverWorkspace(input.scope, directory, "icon", ["icon.v1"]),
|
||||
Persist.serverWorkspace(input.scope, directory, "icon"),
|
||||
createStore({ value: undefined as string | undefined }),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -181,7 +181,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont
|
||||
init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => {
|
||||
const initial = props.locale ?? readStoredLocale() ?? detectLocale()
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("language", ["language.v1"]),
|
||||
Persist.global("language"),
|
||||
createStore({
|
||||
locale: initial,
|
||||
}),
|
||||
|
||||
@@ -13,7 +13,7 @@ import { same } from "@/utils/same"
|
||||
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||
import { SessionStateKey } from "@/utils/server-scope"
|
||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
|
||||
@@ -133,11 +133,6 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||
}
|
||||
}
|
||||
|
||||
const dirBase64 = parts[0]
|
||||
const dir = decode64(dirBase64)
|
||||
if (!dir) return { type: "home" }
|
||||
if (parts[1] === "session") return { type: "home" }
|
||||
|
||||
throw new Error("Unrecognised route!")
|
||||
}
|
||||
|
||||
@@ -157,6 +152,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
const currentSessionState = (value: unknown) => {
|
||||
if (!isRecord(value)) return value
|
||||
const entries = Object.entries(value)
|
||||
if (entries.every(([key]) => SessionStateKey.is(key))) return value
|
||||
return Object.fromEntries(entries.filter(([key]) => SessionStateKey.is(key)))
|
||||
}
|
||||
|
||||
const migrate = (value: unknown) => {
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
@@ -198,8 +200,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
})()
|
||||
|
||||
const sessionTabs = migrateLegacySessionStateKeys(value.sessionTabs)
|
||||
const sessionView = migrateLegacySessionStateKeys(value.sessionView)
|
||||
const sessionTabs = currentSessionState(value.sessionTabs)
|
||||
const sessionView = currentSessionState(value.sessionView)
|
||||
const migratedSessionTabs = (() => {
|
||||
if (!isRecord(sessionTabs)) return sessionTabs
|
||||
|
||||
@@ -245,7 +247,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
}
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{ ...Persist.global("layout", ["layout.v6"]), migrate },
|
||||
{ ...Persist.global("layout"), previousKey: "layout.v6", migrate },
|
||||
createStore({
|
||||
sidebar: {
|
||||
opened: false,
|
||||
@@ -292,11 +294,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
used: new Map<string, number>(),
|
||||
}
|
||||
|
||||
const SESSION_STATE_KEYS = [
|
||||
{ key: "prompt", legacy: "prompt", version: "v2" },
|
||||
{ key: "terminal", legacy: "terminal", version: "v1" },
|
||||
{ key: "file-view", legacy: "file", version: "v1" },
|
||||
] as const
|
||||
const SESSION_STATE_KEYS = ["prompt", "terminal", "file-view"] as const
|
||||
|
||||
const dropSessionState = (keys: string[]) => {
|
||||
for (const key of keys) {
|
||||
@@ -308,13 +306,9 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
|
||||
for (const entry of SESSION_STATE_KEYS) {
|
||||
const target = session
|
||||
? Persist.serverSession(scope, dir, session, entry.key)
|
||||
: Persist.serverWorkspace(scope, dir, entry.key)
|
||||
? Persist.serverSession(scope, dir, session, entry)
|
||||
: Persist.serverWorkspace(scope, dir, entry)
|
||||
void removePersisted(target, platform)
|
||||
|
||||
if (scope !== ServerScope.local) continue
|
||||
const legacyKey = `${dir}/${entry["legacy"]}${session ? "/" + session : ""}.${entry.version}`
|
||||
void removePersisted({ key: legacyKey }, platform)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
|
||||
const [saved, setSaved, , savedReady] = persisted(
|
||||
{
|
||||
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection", ["model-selection.v1"]),
|
||||
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection"),
|
||||
migrate,
|
||||
},
|
||||
createStore<Saved>({
|
||||
|
||||
@@ -24,7 +24,7 @@ function modelKey(model: ModelKey) {
|
||||
|
||||
const createModelsPersistedState = () => {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.global("model", ["model.v1"]),
|
||||
Persist.global("model"),
|
||||
createStore<Store>({
|
||||
user: [],
|
||||
recent: [],
|
||||
|
||||
@@ -116,7 +116,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
const empty: Notification[] = []
|
||||
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
Persist.serverGlobal(input.sdk.scope, "notification", ["notification.v1"]),
|
||||
Persist.serverGlobal(input.sdk.scope, "notification"),
|
||||
createStore({
|
||||
list: [] as Notification[],
|
||||
}),
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { autoRespondsPermission, isDirectoryAutoAccepting, sessionAutoAccept } from "./permission-auto-respond"
|
||||
import {
|
||||
autoRespondsPermission,
|
||||
isDirectoryAutoAccepting,
|
||||
relocateAutoAccept,
|
||||
sessionAutoAccept,
|
||||
} from "./permission-auto-respond"
|
||||
|
||||
const session = (input: { id: string; parentID?: string }) =>
|
||||
({
|
||||
@@ -25,12 +30,6 @@ describe("autoRespondsPermission", () => {
|
||||
expect(autoRespondsPermission(autoAccept, sessions, permission("child"), directory)).toBe(true)
|
||||
})
|
||||
|
||||
test("uses a parent session's legacy auto-accept key", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" })]
|
||||
|
||||
expect(autoRespondsPermission({ root: true }, sessions, permission("child"), "/tmp/project")).toBe(true)
|
||||
})
|
||||
|
||||
test("defaults to requiring approval when no lineage override exists", () => {
|
||||
const sessions = [session({ id: "root" }), session({ id: "child", parentID: "root" }), session({ id: "other" })]
|
||||
const autoAccept = {
|
||||
@@ -123,3 +122,13 @@ describe("isDirectoryAutoAccepting", () => {
|
||||
expect(isDirectoryAutoAccepting(autoAccept, directory)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
test("relocates bare session settings when the directory becomes known", () => {
|
||||
const directory = "/tmp/project"
|
||||
expect(relocateAutoAccept({ root: true }, [{ id: "root" }], directory)).toEqual({
|
||||
[`${base64Encode(directory)}/root`]: true,
|
||||
})
|
||||
expect(
|
||||
relocateAutoAccept({ root: true, [`${base64Encode(directory)}/root`]: false }, [{ id: "root" }], directory),
|
||||
).toEqual({ [`${base64Encode(directory)}/root`]: false })
|
||||
})
|
||||
|
||||
@@ -10,8 +10,7 @@ export function directoryAcceptKey(directory: string) {
|
||||
}
|
||||
|
||||
function accepted(autoAccept: Record<string, boolean>, sessionID: string, directory?: string) {
|
||||
const key = acceptKey(sessionID, directory)
|
||||
return autoAccept[key] ?? autoAccept[sessionID]
|
||||
return autoAccept[acceptKey(sessionID, directory)]
|
||||
}
|
||||
|
||||
export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, directory: string) {
|
||||
@@ -19,6 +18,26 @@ export function isDirectoryAutoAccepting(autoAccept: Record<string, boolean>, di
|
||||
return autoAccept[key] ?? false
|
||||
}
|
||||
|
||||
export function relocateAutoAccept(
|
||||
autoAccept: Record<string, boolean>,
|
||||
sessions: readonly { id: string }[],
|
||||
directory: string,
|
||||
) {
|
||||
const moves = sessions.flatMap((session) => {
|
||||
const value = autoAccept[session.id]
|
||||
if (value === undefined) return []
|
||||
return [{ source: session.id, target: acceptKey(session.id, directory), value }]
|
||||
})
|
||||
if (moves.length === 0) return autoAccept
|
||||
|
||||
const next = { ...autoAccept }
|
||||
for (const move of moves) {
|
||||
if (next[move.target] === undefined) next[move.target] = move.value
|
||||
delete next[move.source]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function sessionLineage(session: { id: string; parentID?: string }[], sessionID: string) {
|
||||
const parent = session.reduce((acc, item) => {
|
||||
if (item.parentID) acc.set(item.id, item.parentID)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -12,12 +12,13 @@ import { ServerConnection, useServers } from "./servers"
|
||||
import { type DraftTab, useTabs } from "./tabs"
|
||||
import { useSettings } from "./settings"
|
||||
import { requireServerKey } from "@/utils/session-route"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import {
|
||||
acceptKey,
|
||||
directoryAcceptKey,
|
||||
isDirectoryAutoAccepting,
|
||||
autoRespondsPermission,
|
||||
relocateAutoAccept,
|
||||
sessionAutoAccept,
|
||||
} from "./permission-auto-respond"
|
||||
import { useServer } from "./server"
|
||||
@@ -57,7 +58,8 @@ type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission"),
|
||||
...(input.sdk.scope === ServerScope.local ? { previousKey: "permission.v3" } : {}),
|
||||
migrate(value) {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return value
|
||||
|
||||
@@ -147,8 +149,15 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||
}
|
||||
|
||||
function autoAccept(directory?: string) {
|
||||
if (!directory) return store.autoAccept
|
||||
const next = relocateAutoAccept(store.autoAccept, sessions(directory), directory)
|
||||
if (next !== store.autoAccept) setStore("autoAccept", reconcile(next))
|
||||
return next
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), { sessionID }, directory)
|
||||
return autoRespondsPermission(autoAccept(directory), sessions(directory), { sessionID }, directory)
|
||||
}
|
||||
|
||||
function isAutoAcceptingDirectory(directory: string) {
|
||||
@@ -156,7 +165,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
}
|
||||
|
||||
function shouldAutoRespond(permission: PermissionRequest, directory?: string) {
|
||||
return autoRespondsPermission(store.autoAccept, sessions(directory), permission, directory)
|
||||
return autoRespondsPermission(autoAccept(directory), sessions(directory), permission, directory)
|
||||
}
|
||||
|
||||
function isPending(permission: PermissionRequest) {
|
||||
@@ -165,7 +174,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
}
|
||||
|
||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||
const override = sessionAutoAccept(autoAccept(directory), sessions(directory), permission, directory)
|
||||
if (override !== undefined) return override
|
||||
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||
|
||||
@@ -68,9 +68,6 @@ type PlatformBase = {
|
||||
/** Prompt drafts, history, and their blobs. */
|
||||
draftStore?: DraftStore
|
||||
|
||||
/** Stable platform window identity for window-scoped persistence */
|
||||
windowID?: string
|
||||
|
||||
/** Application-global desktop updater */
|
||||
updater?: UpdaterPlatform
|
||||
|
||||
@@ -86,12 +83,6 @@ type PlatformBase = {
|
||||
/** Manage WSL sidecar servers (Electron on Windows only) */
|
||||
wslServers?: WslServersPlatform
|
||||
|
||||
/** Get the preferred display backend (desktop only) */
|
||||
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null
|
||||
|
||||
/** Set the preferred display backend (desktop only) */
|
||||
setDisplayBackend?(backend: DisplayBackend): Promise<void>
|
||||
|
||||
/** Webview zoom level (desktop only) */
|
||||
webviewZoom?: Accessor<number>
|
||||
|
||||
@@ -125,16 +116,16 @@ type PlatformBase = {
|
||||
|
||||
export type Platform = PlatformBase &
|
||||
(
|
||||
| { platform: "web"; os?: never }
|
||||
| { platform: "web"; os?: never; windowID?: never }
|
||||
| {
|
||||
platform: "desktop"
|
||||
os?: DesktopOS
|
||||
/** Stable platform window identity for window-scoped persistence */
|
||||
windowID: string
|
||||
openDirectoryPickerDialog(opts?: OpenDirectoryPickerOptions): Promise<PickerPaths>
|
||||
}
|
||||
)
|
||||
|
||||
export type DisplayBackend = "auto" | "wayland"
|
||||
|
||||
export const { use: usePlatform, provider: PlatformProvider } = createSimpleContext({
|
||||
name: "Platform",
|
||||
init: (props: { value: Platform }) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { batch, createMemo, type Accessor } from "solid-js"
|
||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
||||
import type { FileSelection } from "@/context/file"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { BlobReference } from "@/utils/draft-store"
|
||||
import type { Platform } from "@/context/platform"
|
||||
|
||||
@@ -171,8 +171,12 @@ function createPromptActions(setStore: SetStoreFunction<PromptStore>) {
|
||||
|
||||
function promptTarget(serverScope: ServerScope, scope: PromptScope) {
|
||||
if ("draftID" in scope) return Persist.prompt(Persist.draft(scope.draftID, "prompt"))
|
||||
const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2`
|
||||
return Persist.prompt(Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]))
|
||||
return Persist.prompt({
|
||||
...Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt"),
|
||||
...(serverScope === ServerScope.local
|
||||
? { previousKey: `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` }
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
|
||||
function promptStore(initial?: InitialPrompt): PromptStore {
|
||||
|
||||
@@ -129,11 +129,11 @@ describe("v2 session reducer", () => {
|
||||
},
|
||||
}),
|
||||
)
|
||||
const legacyAgent = reducer.reduce(
|
||||
const derivedAgent = reducer.reduce(
|
||||
source,
|
||||
event({
|
||||
...base,
|
||||
id: "evt_legacy_agent",
|
||||
id: "evt_derived_agent",
|
||||
type: "session.agent.selected",
|
||||
data: { sessionID: "ses_1", agent: "plan" },
|
||||
}),
|
||||
@@ -145,7 +145,7 @@ describe("v2 session reducer", () => {
|
||||
model: { id: "new" },
|
||||
previous: { id: "durable" },
|
||||
})
|
||||
expect(legacyAgent?.messages.at(-1)).toMatchObject({
|
||||
expect(derivedAgent?.messages.at(-1)).toMatchObject({
|
||||
type: "agent-switched",
|
||||
agent: "plan",
|
||||
previous: "build",
|
||||
|
||||
@@ -28,14 +28,12 @@ const session = (id: string, parentID?: string): SessionInfo => ({
|
||||
})
|
||||
|
||||
type UserMessage = Extract<Message, { role: "user" }>
|
||||
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||
type TextPart = Extract<Part, { type: "text" }>
|
||||
type CurrentToolObject = Extract<SessionMessageAssistantTool["state"], { status: "running" }>["input"]
|
||||
type MessageResponse = {
|
||||
data: { info: Message; parts: Part[] }[]
|
||||
response: { headers: Headers }
|
||||
}
|
||||
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
||||
|
||||
function sessionInfo(value: SessionInfo): SessionInfo {
|
||||
return value
|
||||
@@ -157,25 +155,6 @@ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage
|
||||
...input,
|
||||
})
|
||||
|
||||
const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
|
||||
id,
|
||||
sessionID: "child",
|
||||
role: "assistant",
|
||||
time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
|
||||
parentID,
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
variant: undefined,
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: "", root: "" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
error: undefined,
|
||||
finish: undefined,
|
||||
...input,
|
||||
})
|
||||
|
||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||
sessionID: "child",
|
||||
messageID,
|
||||
@@ -200,8 +179,6 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR
|
||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||
})
|
||||
|
||||
const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
|
||||
|
||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||
|
||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||
@@ -236,43 +213,6 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
|
||||
})
|
||||
}
|
||||
|
||||
function rootMessageClient(
|
||||
pages: Array<MessageResponse | Promise<MessageResponse>>,
|
||||
roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
|
||||
) {
|
||||
let pageIndex = 0
|
||||
let rootIndex = 0
|
||||
const requests: unknown[] = []
|
||||
const rootRequests: unknown[] = []
|
||||
const rootWaiting = new Map<number, () => void>()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => sessionInfo(session("child", "root")),
|
||||
message: async (input: unknown) => {
|
||||
rootRequests.push(input)
|
||||
rootWaiting.get(rootRequests.length)?.()
|
||||
rootWaiting.delete(rootRequests.length)
|
||||
const value = await roots[rootIndex++]!
|
||||
return currentMessages([value.data]).find((message) => message.id === value.data.info.id)!
|
||||
},
|
||||
},
|
||||
message: {
|
||||
list: async (input: unknown) => {
|
||||
requests.push(input)
|
||||
return currentPage(await pages[pageIndex++]!)
|
||||
},
|
||||
},
|
||||
} as unknown as { session: SessionApi; message: MessageApi }
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
rootRequests,
|
||||
rootRequested(count: number) {
|
||||
if (rootRequests.length >= count) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const retryImmediately: typeof retry = async (task, options = {}) => {
|
||||
const attempts = options.attempts ?? 3
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
@@ -329,7 +269,7 @@ describe("server session", () => {
|
||||
expect(ctx.get).toEqual([{ sessionID: "created" }])
|
||||
})
|
||||
|
||||
test("projects V2 session events into current and legacy message state", () => {
|
||||
test("projects V2 session events into native and rendering message state", () => {
|
||||
const ctx = setup({ child: session("child") })
|
||||
ctx.store.remember(session("child"))
|
||||
ctx.store.set("session_message", "child", [
|
||||
@@ -785,239 +725,6 @@ describe("server session", () => {
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
// V2 messages are ordered projections and do not expose V1 assistant parent IDs.
|
||||
describe.skip("V1 assistant parent projections", () => {
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 200, order: "desc" }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const assistant = assistantMessage("message-2", "message-missing")
|
||||
const client = rootMessageClient([response([{ info: assistant, parts: [] }])], [missing.promise])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.history.more("child")).toBe(false)
|
||||
})
|
||||
|
||||
test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
|
||||
const missing = Promise.withResolvers<SingleMessageResponse>()
|
||||
const parent = userMessage("message-1")
|
||||
const part = textPart(parent.id)
|
||||
const assistant = assistantMessage("message-2", parent.id)
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([
|
||||
{ info: parent, parts: [part] },
|
||||
{ info: assistant, parts: [] },
|
||||
]),
|
||||
response([{ info: assistant, parts: [] }]),
|
||||
],
|
||||
[missing.promise],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
const loading = store.sync("child", { force: true })
|
||||
await client.rootRequested(1)
|
||||
|
||||
missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([assistant])
|
||||
expect(store.data.part[parent.id]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("does not let an admitted user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.inbox.echo(promptEcho(user.id, "text"))
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("backfills the parent of fetched assistants when another user is cached", async () => {
|
||||
const unrelated = userMessage("message-0", { time: { created: 0 } })
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: unrelated, parts: [] }]), response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.requests).toHaveLength(2)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves cached history between an injected parent and the page boundary", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||
const assistant = assistantMessage("message-4", user.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([user, cached, assistant])
|
||||
})
|
||||
|
||||
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const stalePart = textPart(stale.id, { text: "stale" })
|
||||
const freshPart = { ...stalePart, text: "fresh" }
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }])],
|
||||
[singleResponse(fresh, [freshPart])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
|
||||
test("uses a parent received by SSE during the replacement load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const client = rootMessageClient([pending.promise], [])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: user } })
|
||||
pending.resolve(response([{ info: assistant, parts: [] }]))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([])
|
||||
expect(store.data.message.child).toEqual([user, assistant])
|
||||
})
|
||||
|
||||
test("uses a successful retry over events received by a failed backfill attempt", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const live = { ...user, agent: "stale" }
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[response(assistants.map((info) => ({ info, parts: [] })))],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(2)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves newer-page events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = { ...assistant, cost: 1 }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, live])
|
||||
})
|
||||
|
||||
test("preserves unrelated message events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = userMessage("message-4", { time: { created: 4 } })
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, assistant, live])
|
||||
})
|
||||
|
||||
test("preserves newer-page part events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const stale = textPart(assistant.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [stale] }])],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[assistant.id]).toEqual([live])
|
||||
})
|
||||
})
|
||||
|
||||
test("merges live events into the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
|
||||
@@ -273,7 +273,8 @@ export const { use: useServers, provider: ServersProvider } = createSimpleContex
|
||||
}) => {
|
||||
const [store, setStore, _] = persisted(
|
||||
{
|
||||
...Persist.global("server", ["server.v3"]),
|
||||
...Persist.global("server"),
|
||||
previousKey: "server.v3",
|
||||
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
|
||||
},
|
||||
createStore({
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { ServerConnection } from "./servers"
|
||||
import type { Tab } from "./tabs"
|
||||
|
||||
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
|
||||
export function migrateTabs(value: unknown): Tab[] {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.flatMap<Tab>((tab) => {
|
||||
if (!tab || typeof tab !== "object") return []
|
||||
if ("server" in tab && typeof tab.server !== "string") return []
|
||||
const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key
|
||||
if (!("server" in tab) || typeof tab.server !== "string") return []
|
||||
const server = tab.server as ServerConnection.Key
|
||||
if (tab.type === "session" && typeof tab.sessionId === "string") {
|
||||
return [{ type: tab.type, server, sessionId: tab.sessionId }]
|
||||
}
|
||||
|
||||
@@ -15,17 +15,17 @@ function sessionTab(sessionId: string): SessionTab {
|
||||
describe("tab migration", () => {
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
expect(
|
||||
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server),
|
||||
migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"]),
|
||||
).toEqual([sessionTab("a")])
|
||||
})
|
||||
|
||||
test("adds the fallback server to valid legacy tabs", () => {
|
||||
expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")])
|
||||
test("drops persisted tabs without a server", () => {
|
||||
expect(migrateTabs([{ type: "session", sessionId: "a" }])).toEqual([])
|
||||
})
|
||||
|
||||
test("replaces invalid top-level persisted data", () => {
|
||||
expect(migrateTabs(null, server)).toEqual([])
|
||||
expect(migrateTabs({}, server)).toEqual([])
|
||||
expect(migrateTabs(null)).toEqual([])
|
||||
expect(migrateTabs({})).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user