diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 28535b57799..ddfa7fd161b 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -21,3 +21,4 @@ r44vc0rp rekram1-node -spider-yamet clawdbot/llm psychosis, spam pinging the team thdxr +-OpenCode2026 diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index f53f20fcdb9..d1e3bfc25d0 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -41,5 +41,13 @@ runs: shell: bash - name: Install dependencies - run: bun install + run: | + # Workaround for patched peer variants + # e.g. ./patches/ for standard-openapi + # https://github.com/oven-sh/bun/issues/28147 + if [ "$RUNNER_OS" = "Windows" ]; then + bun install --linker hoisted + else + bun install + fi shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d9eded3f175..c928e822346 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,7 +8,9 @@ on: workflow_dispatch: concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + # Keep every run on dev so cancelled checks do not pollute the default branch + # commit history. PRs and other branches still share a group and cancel stale runs. + group: ${{ case(github.ref == 'refs/heads/dev', format('{0}-{1}', github.workflow, github.run_id), format('{0}-{1}', github.workflow, github.event.pull_request.number || github.ref)) }} cancel-in-progress: true permissions: diff --git a/.gitignore b/.gitignore index bf78c046d4b..c287d91ac12 100644 --- a/.gitignore +++ b/.gitignore @@ -17,7 +17,7 @@ ts-dist /result refs Session.vim -opencode.json +/opencode.json a.out target .scripts diff --git a/bun.lock b/bun.lock index 248caffa8d2..2aa72ea94af 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -46,7 +46,7 @@ "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#main", "luxon": "catalog:", @@ -77,7 +77,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -111,7 +111,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -138,7 +138,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@ai-sdk/anthropic": "2.0.0", "@ai-sdk/openai": "2.0.2", @@ -162,7 +162,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -186,7 +186,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -219,7 +219,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -227,7 +227,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "electron-log": "^5", "electron-store": "^10", "electron-updater": "^6", @@ -250,7 +250,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@opencode-ai/ui": "workspace:*", "@opencode-ai/util": "workspace:*", @@ -279,7 +279,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -295,7 +295,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.2.24", + "version": "1.2.27", "bin": { "opencode": "./bin/opencode", }, @@ -324,6 +324,7 @@ "@ai-sdk/xai": "2.0.51", "@aws-sdk/credential-providers": "3.993.0", "@clack/prompts": "1.0.0-alpha.1", + "@effect/platform-node": "4.0.0-beta.31", "@gitlab/gitlab-ai-provider": "3.6.0", "@gitlab/opencode-gitlab-auth": "1.3.3", "@hono/standard-validator": "0.1.5", @@ -416,7 +417,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@opencode-ai/sdk": "workspace:*", "zod": "catalog:", @@ -440,7 +441,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.2.24", + "version": "1.2.27", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -451,7 +452,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -486,7 +487,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", @@ -532,7 +533,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "zod": "catalog:", }, @@ -543,7 +544,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.2.24", + "version": "1.2.27", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", @@ -614,7 +615,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.16-ea816b6", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -972,6 +973,10 @@ "@effect/language-service": ["@effect/language-service@0.79.0", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-DEmIOsg1GjjP6s9HXH1oJrW+gDmzkhVv9WOZl6to5eNyyCrjz1S2PDqQ7aYrW/HuifhfwI5Bik1pK4pj7Z+lrg=="], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.31", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.31", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.31", "ioredis": "^5.7.0" } }, "sha512-KmVZwGsQRBMZZYPJwpL2vj6sxjBzfXhyA8RgsH5/cmckDTsZpVTyqODQ/FFzmCnMWuYjZoJGPghTDrVVDn/6ZA=="], + + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.33", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.33" } }, "sha512-jaJnvYz1IiPZyN//fCJsvwnmujJS5KD8noCVVLhb4ZGCWKhQpt0x2iuax6HFzMlPEQSfl04GLU+PVKh0nkzPyA=="], + "@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=="], "@electron/fuses": ["@electron/fuses@1.8.0", "", { "dependencies": { "chalk": "^4.1.1", "fs-extra": "^9.0.1", "minimist": "^1.2.5" }, "bin": { "electron-fuses": "dist/bin.js" } }, "sha512-zx0EIq78WlY/lBb1uXlziZmDZI4ubcCXIMJ4uGjXzZW0nS19TjSPeXPAjzzTmKQlJUZm0SbmZhPKP7tuQ1SsEw=="], @@ -1168,6 +1173,8 @@ "@internationalized/number": ["@internationalized/number@3.6.5", "", { "dependencies": { "@swc/helpers": "^0.5.0" } }, "sha512-6hY4Kl4HPBvtfS62asS/R22JzNNy8vi/Ssev7x6EobfCp+9QIB2hKvI2EtbdJ0VSQacxVNtqhE/NmF/NZ0gm6g=="], + "@ioredis/commands": ["@ioredis/commands@1.5.1", "", {}, "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw=="], + "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], @@ -2536,6 +2543,8 @@ "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -2738,7 +2747,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="], + "effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -3176,6 +3185,8 @@ "internal-slot": ["internal-slot@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "hasown": "^2.0.2", "side-channel": "^1.1.0" } }, "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw=="], + "ioredis": ["ioredis@5.10.0", "", { "dependencies": { "@ioredis/commands": "1.5.1", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-HVBe9OFuqs+Z6n64q09PQvP1/R4Bm+30PAyyD4wIEqssh3v9L21QjCVk4kRLucMBcDokJTcLjsGeVRlq/nH6DA=="], + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], @@ -3408,10 +3419,14 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="], + "lodash.escaperegexp": ["lodash.escaperegexp@4.1.2", "", {}, "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw=="], "lodash.includes": ["lodash.includes@4.3.0", "", {}, "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w=="], + "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="], + "lodash.isboolean": ["lodash.isboolean@3.0.3", "", {}, "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg=="], "lodash.isequal": ["lodash.isequal@4.5.0", "", {}, "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ=="], @@ -3598,7 +3613,7 @@ "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], - "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime": ["mime@4.1.0", "", { "bin": { "mime": "bin/cli.js" } }, "sha512-X5ju04+cAzsojXKes0B/S4tcYtFAJ6tTMuSPBEn9CPGlrWr8Fiw7qYeLT0XyH80HSoAoqWCaz+MWKh22P7G1cw=="], "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], @@ -4022,6 +4037,10 @@ "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], + + "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], + "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], @@ -4280,6 +4299,8 @@ "stage-js": ["stage-js@1.0.1", "", {}, "sha512-cz14aPp/wY0s3bkb/B93BPP5ZAEhgBbRmAT3CCDqert8eCAqIpQ0RB2zpK8Ksxf+Pisl5oTzvPHtL4CVzzeHcw=="], + "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], + "stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -4986,12 +5007,16 @@ "@bufbuild/protoplugin/typescript": ["typescript@5.4.5", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ=="], + "@cloudflare/kv-asset-handler/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@cspotcode/source-map-support/@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], "@develar/schema-utils/ajv": ["ajv@6.14.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw=="], "@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.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "@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=="], @@ -5032,6 +5057,8 @@ "@hono/zod-validator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -5226,6 +5253,10 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="], + + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -6124,6 +6155,10 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], diff --git a/infra/console.ts b/infra/console.ts index c7889c587f9..7b6f21001e4 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -201,6 +201,10 @@ const bucketNew = new sst.cloudflare.Bucket("ZenDataNew") const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID") const AWS_SES_SECRET_ACCESS_KEY = new sst.Secret("AWS_SES_SECRET_ACCESS_KEY") +const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID") +const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET") +const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL") + const logProcessor = new sst.cloudflare.Worker("LogProcessor", { handler: "packages/console/function/src/log-processor.ts", link: [new sst.Secret("HONEYCOMB_API_KEY")], @@ -219,6 +223,9 @@ new sst.cloudflare.x.SolidStart("Console", { EMAILOCTOPUS_API_KEY, AWS_SES_ACCESS_KEY_ID, AWS_SES_SECRET_ACCESS_KEY, + SALESFORCE_CLIENT_ID, + SALESFORCE_CLIENT_SECRET, + SALESFORCE_INSTANCE_URL, ZEN_BLACK_PRICE, ZEN_LITE_PRICE, new sst.Secret("ZEN_LIMITS"), diff --git a/nix/hashes.json b/nix/hashes.json index 1f1ef9d31af..d71f544ff91 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-5+9GAHej/EWz87Z3eTI9yBDRL1Ko0RoXsLo/Q3t42WA=", - "aarch64-linux": "sha256-4FWmoWkLKWKita3+XHZEiDy5grOQgdzOY1AZzb0TDWE=", - "aarch64-darwin": "sha256-L4FPB1E5AtV3V6qZjmX6YM7Q/mwSYlhYyZXPXAxrLFU=", - "x86_64-darwin": "sha256-bJCcrzDF2tIsKScxw5CoW+ZRUHe4KbUWLSqiR/M7vu8=" + "x86_64-linux": "sha256-VF3rXpIz9XbTTfM8YB98DJJOs4Sotaq5cSwIBUfbNDA=", + "aarch64-linux": "sha256-cIE10+0xhb5u0TQedaDbEu6e40ypHnSBmh8unnhCDZE=", + "aarch64-darwin": "sha256-d/l7g/4angRw/oxoSGpcYL0i9pNphgRChJwhva5Kypo=", + "x86_64-darwin": "sha256-WQyuUKMfHpO1rpWsjhCXuG99iX2jEdSe3AVltxvt+1Y=" } } diff --git a/package.json b/package.json index d1358a39663..00e251f500c 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dev": "bun run --cwd packages/opencode --conditions=browser src/index.ts", "dev:desktop": "bun --cwd packages/desktop tauri dev", "dev:web": "bun --cwd packages/app dev", + "dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev", "dev:storybook": "bun --cwd packages/storybook storybook", "typecheck": "bun turbo typecheck", "prepare": "husky", @@ -43,7 +44,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.16-ea816b6", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "ai": "5.0.124", "hono": "4.10.7", "hono-openapi": "1.1.2", diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md index cb8080fb252..f263e49a023 100644 --- a/packages/app/e2e/AGENTS.md +++ b/packages/app/e2e/AGENTS.md @@ -174,8 +174,37 @@ await page.keyboard.press(`${modKey}+Comma`) // Open settings - In terminal tests, type through the browser. Do not write to the PTY through the SDK. - Use `waitTerminalReady(page, { term? })` and `runTerminal(page, { cmd, token, term?, timeout? })` from `actions.ts`. - These helpers use the fixture-enabled test-only terminal driver and wait for output after the terminal writer settles. +- After opening the terminal, use `waitTerminalFocusIdle(...)` before the next keyboard action when prompt focus or keyboard routing matters. +- This avoids racing terminal mount, focus handoff, and prompt readiness when the next step types or sends shortcuts. - Avoid `waitForTimeout` and custom DOM or `data-*` readiness checks. +### Wait on state + +- Never use wall-clock waits like `page.waitForTimeout(...)` to make a test pass +- Avoid race-prone flows that assume work is finished after an action +- Wait or poll on observable state with `expect(...)`, `expect.poll(...)`, or existing helpers +- Prefer locator assertions like `toBeVisible()`, `toHaveCount(0)`, and `toHaveAttribute(...)` for normal UI state, and reserve `expect.poll(...)` for probe, mock, or backend state +- Prefer semantic app state over transient DOM visibility when behavior depends on active selection, focus ownership, or async retry loops +- Do not treat a visible element as proof that the app will route the next action to it +- When fixing a flake, validate with `--repeat-each` and multiple workers when practical + +### Add hooks + +- If required state is not observable from the UI, add a small test-only driver or probe in app code instead of sleeps or fragile DOM checks +- Keep these hooks minimal and purpose-built, following the style of `packages/app/src/testing/terminal.ts` +- Test-only hooks must be inert unless explicitly enabled; do not add normal-runtime listeners, reactive subscriptions, or per-update allocations for e2e ceremony +- When mocking routes or APIs, expose explicit mock state and wait on that before asserting post-action UI +- Add minimal test-only probes for semantic state like the active list item or selected command when DOM intermediates are unstable +- Prefer probing committed app state over asserting on transient highlight, visibility, or animation states + +### Prefer helpers + +- Prefer fluent helpers and drivers when they make intent obvious and reduce locator-heavy noise +- Use direct locators when the interaction is simple and a helper would not add clarity +- Prefer helpers that both perform an action and verify the app consumed it +- Avoid composing helpers redundantly when one already includes the other or already waits for the resulting state +- If a helper already covers the required wait or verification, use it directly instead of layering extra clicks, keypresses, or assertions + ## Writing New Tests 1. Choose appropriate folder or create new one diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a56001248d3..aa047fb287a 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -16,6 +16,7 @@ import { listItemSelector, listItemKeySelector, listItemKeyStartsWithSelector, + promptSelector, terminalSelector, workspaceItemSelector, workspaceMenuTriggerSelector, @@ -36,6 +37,22 @@ async function terminalID(term: Locator) { throw new Error(`Active terminal missing ${terminalAttr}`) } +export async function terminalConnects(page: Page, input?: { term?: Locator }) { + const term = input?.term ?? page.locator(terminalSelector).first() + const id = await terminalID(term) + return page.evaluate((id) => { + return (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id]?.connects ?? 0 + }, id) +} + +export async function disconnectTerminal(page: Page, input?: { term?: Locator }) { + const term = input?.term ?? page.locator(terminalSelector).first() + const id = await terminalID(term) + await page.evaluate((id) => { + ;(window as E2EWindow).__opencode_e2e?.terminal?.controls?.[id]?.disconnect?.() + }, id) +} + async function terminalReady(page: Page, term?: Locator) { const next = term ?? page.locator(terminalSelector).first() const id = await terminalID(next) @@ -45,6 +62,15 @@ async function terminalReady(page: Page, term?: Locator) { }, id) } +async function terminalFocusIdle(page: Page, term?: Locator) { + const next = term ?? page.locator(terminalSelector).first() + const id = await terminalID(next) + return page.evaluate((id) => { + const state = (window as E2EWindow).__opencode_e2e?.terminal?.terminals?.[id] + return (state?.focusing ?? 0) === 0 + }, id) +} + async function terminalHas(page: Page, input: { term?: Locator; token: string }) { const next = input.term ?? page.locator(terminalSelector).first() const id = await terminalID(next) @@ -57,6 +83,29 @@ async function terminalHas(page: Page, input: { term?: Locator; token: string }) ) } +async function promptSlashActive(page: Page, id: string) { + return page.evaluate((id) => { + const state = (window as E2EWindow).__opencode_e2e?.prompt?.current + if (state?.popover !== "slash") return false + if (!state.slash.ids.includes(id)) return false + return state.slash.active === id + }, id) +} + +async function promptSlashSelects(page: Page) { + return page.evaluate(() => { + return (window as E2EWindow).__opencode_e2e?.prompt?.current?.selects ?? 0 + }) +} + +async function promptSlashSelected(page: Page, input: { id: string; count: number }) { + return page.evaluate((input) => { + const state = (window as E2EWindow).__opencode_e2e?.prompt?.current + if (!state) return false + return state.selected === input.id && state.selects >= input.count + }, input) +} + export async function waitTerminalReady(page: Page, input?: { term?: Locator; timeout?: number }) { const term = input?.term ?? page.locator(terminalSelector).first() const timeout = input?.timeout ?? 10_000 @@ -65,6 +114,43 @@ export async function waitTerminalReady(page: Page, input?: { term?: Locator; ti await expect.poll(() => terminalReady(page, term), { timeout }).toBe(true) } +export async function waitTerminalFocusIdle(page: Page, input?: { term?: Locator; timeout?: number }) { + const term = input?.term ?? page.locator(terminalSelector).first() + const timeout = input?.timeout ?? 10_000 + await waitTerminalReady(page, { term, timeout }) + await expect.poll(() => terminalFocusIdle(page, term), { timeout }).toBe(true) +} + +export async function showPromptSlash( + page: Page, + input: { id: string; text: string; prompt?: Locator; timeout?: number }, +) { + const prompt = input.prompt ?? page.locator(promptSelector) + const timeout = input.timeout ?? 10_000 + await expect + .poll( + async () => { + await prompt.click().catch(() => false) + await prompt.fill(input.text).catch(() => false) + return promptSlashActive(page, input.id).catch(() => false) + }, + { timeout }, + ) + .toBe(true) +} + +export async function runPromptSlash( + page: Page, + input: { id: string; text: string; prompt?: Locator; timeout?: number }, +) { + const prompt = input.prompt ?? page.locator(promptSelector) + const timeout = input.timeout ?? 10_000 + const count = await promptSlashSelects(page) + await showPromptSlash(page, input) + await prompt.press("Enter") + await expect.poll(() => promptSlashSelected(page, { id: input.id, count: count + 1 }), { timeout }).toBe(true) +} + export async function runTerminal(page: Page, input: { cmd: string; token: string; term?: Locator; timeout?: number }) { const term = input.term ?? page.locator(terminalSelector).first() const timeout = input.timeout ?? 10_000 @@ -588,12 +674,19 @@ export async function seedSessionTask( .flatMap((message) => message.parts) .find((part) => { if (part.type !== "tool" || part.tool !== "task") return false - if (part.state.input?.description !== input.description) return false - return typeof part.state.metadata?.sessionId === "string" && part.state.metadata.sessionId.length > 0 + if (!("state" in part) || !part.state || typeof part.state !== "object") return false + if (!("input" in part.state) || !part.state.input || typeof part.state.input !== "object") return false + if (!("description" in part.state.input) || part.state.input.description !== input.description) return false + if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") + return false + if (!("sessionId" in part.state.metadata)) return false + return typeof part.state.metadata.sessionId === "string" && part.state.metadata.sessionId.length > 0 }) - if (!part) return - const id = part.state.metadata?.sessionId + if (!part || !("state" in part) || !part.state || typeof part.state !== "object") return + if (!("metadata" in part.state) || !part.state.metadata || typeof part.state.metadata !== "object") return + if (!("sessionId" in part.state.metadata)) return + const id = part.state.metadata.sessionId if (typeof id !== "string" || !id) return const child = await sdk.session .get({ sessionID: id }) diff --git a/packages/app/e2e/app/home.spec.ts b/packages/app/e2e/app/home.spec.ts index a3cedf7cb6f..5deba4300cb 100644 --- a/packages/app/e2e/app/home.spec.ts +++ b/packages/app/e2e/app/home.spec.ts @@ -3,8 +3,11 @@ import { serverNamePattern } from "../utils" test("home renders and shows core entrypoints", async ({ page }) => { await page.goto("/") + const nav = page.locator('[data-component="sidebar-nav-desktop"]') await expect(page.getByRole("button", { name: "Open project" }).first()).toBeVisible() + await expect(nav.getByText("No projects open")).toBeVisible() + await expect(nav.getByText("Open a project to get started")).toBeVisible() await expect(page.getByRole("button", { name: serverNamePattern })).toBeVisible() }) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index cf59eeb4761..7bc994e5076 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -95,6 +95,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin const win = window as E2EWindow win.__opencode_e2e = { ...win.__opencode_e2e, + model: { + enabled: true, + }, + prompt: { + enabled: true, + }, terminal: { enabled: true, terminals: {}, diff --git a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts index 100d1878ab4..466b3ba1bb8 100644 --- a/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts +++ b/packages/app/e2e/prompt/prompt-slash-terminal.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from "../fixtures" -import { waitTerminalReady } from "../actions" +import { runPromptSlash, waitTerminalFocusIdle } from "../actions" import { promptSelector, terminalSelector } from "../selectors" test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { @@ -7,29 +7,12 @@ test("/terminal toggles the terminal panel", async ({ page, gotoSession }) => { const prompt = page.locator(promptSelector) const terminal = page.locator(terminalSelector) - const slash = page.locator('[data-slash-id="terminal.toggle"]').first() await expect(terminal).not.toBeVisible() - await prompt.fill("/terminal") - await expect(slash).toBeVisible() - await page.keyboard.press("Enter") - await waitTerminalReady(page, { term: terminal }) + await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) + await waitTerminalFocusIdle(page, { term: terminal }) - // Terminal panel retries focus (immediate, RAF, 120ms, 240ms) after opening, - // which can steal focus from the prompt and prevent fill() from triggering - // the slash popover. Re-attempt click+fill until all retries are exhausted - // and the popover appears. - await expect - .poll( - async () => { - await prompt.click().catch(() => false) - await prompt.fill("/terminal").catch(() => false) - return slash.isVisible().catch(() => false) - }, - { timeout: 10_000 }, - ) - .toBe(true) - await page.keyboard.press("Enter") + await runPromptSlash(page, { prompt, text: "/terminal", id: "terminal.toggle" }) await expect(terminal).not.toBeVisible() }) diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 64b7bfe5456..80b6c473d6e 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -13,6 +13,9 @@ export const sessionTodoToggleButtonSelector = '[data-action="session-todo-toggl export const sessionTodoListSelector = '[data-slot="session-todo-list"]' export const modelVariantCycleSelector = '[data-action="model-variant-cycle"]' +export const promptAgentSelector = '[data-component="prompt-agent-control"]' +export const promptModelSelector = '[data-component="prompt-model-control"]' +export const promptVariantSelector = '[data-component="prompt-variant-control"]' export const settingsLanguageSelectSelector = '[data-action="settings-language"]' export const settingsColorSchemeSelector = '[data-action="settings-color-scheme"]' export const settingsThemeSelector = '[data-action="settings-theme"]' diff --git a/packages/app/e2e/session/session-composer-dock.spec.ts b/packages/app/e2e/session/session-composer-dock.spec.ts index 055e8eed292..5b2e8a8c6f1 100644 --- a/packages/app/e2e/session/session-composer-dock.spec.ts +++ b/packages/app/e2e/session/session-composer-dock.spec.ts @@ -1,12 +1,16 @@ import { test, expect } from "../fixtures" -import { cleanupSession, clearSessionDockSeed, seedSessionQuestion, seedSessionTodos } from "../actions" +import { + composerEvent, + type ComposerDriverState, + type ComposerProbeState, + type ComposerWindow, +} from "../../src/testing/session-composer" +import { cleanupSession, clearSessionDockSeed, seedSessionQuestion } from "../actions" import { permissionDockSelector, promptSelector, questionDockSelector, sessionComposerDockSelector, - sessionTodoDockSelector, - sessionTodoListSelector, sessionTodoToggleButtonSelector, } from "../selectors" @@ -42,12 +46,8 @@ async function withDockSeed(sdk: Sdk, sessionID: string, fn: () => Promise async function clearPermissionDock(page: any, label: RegExp) { const dock = page.locator(permissionDockSelector) - for (let i = 0; i < 3; i++) { - const count = await dock.count() - if (count === 0) return - await dock.getByRole("button", { name: label }).click() - await page.waitForTimeout(150) - } + await expect(dock).toBeVisible() + await dock.getByRole("button", { name: label }).click() } async function setAutoAccept(page: any, enabled: boolean) { @@ -59,6 +59,120 @@ async function setAutoAccept(page: any, enabled: boolean) { await expect(button).toHaveAttribute("aria-pressed", enabled ? "true" : "false") } +async function expectQuestionBlocked(page: any) { + await expect(page.locator(questionDockSelector)).toBeVisible() + await expect(page.locator(promptSelector)).toHaveCount(0) +} + +async function expectQuestionOpen(page: any) { + await expect(page.locator(questionDockSelector)).toHaveCount(0) + await expect(page.locator(promptSelector)).toBeVisible() +} + +async function expectPermissionBlocked(page: any) { + await expect(page.locator(permissionDockSelector)).toBeVisible() + await expect(page.locator(promptSelector)).toHaveCount(0) +} + +async function expectPermissionOpen(page: any) { + await expect(page.locator(permissionDockSelector)).toHaveCount(0) + await expect(page.locator(promptSelector)).toBeVisible() +} + +async function todoDock(page: any, sessionID: string) { + await page.addInitScript(() => { + const win = window as ComposerWindow + win.__opencode_e2e = { + ...win.__opencode_e2e, + composer: { + enabled: true, + sessions: {}, + }, + } + }) + + const write = async (driver: ComposerDriverState | undefined) => { + await page.evaluate( + (input) => { + const win = window as ComposerWindow + const composer = win.__opencode_e2e?.composer + if (!composer?.enabled) throw new Error("Composer e2e driver is not enabled") + composer.sessions ??= {} + const prev = composer.sessions[input.sessionID] ?? {} + if (!input.driver) { + if (!prev.probe) { + delete composer.sessions[input.sessionID] + } else { + composer.sessions[input.sessionID] = { probe: prev.probe } + } + } else { + composer.sessions[input.sessionID] = { + ...prev, + driver: input.driver, + } + } + window.dispatchEvent(new CustomEvent(input.event, { detail: { sessionID: input.sessionID } })) + }, + { event: composerEvent, sessionID, driver }, + ) + } + + const read = () => + page.evaluate((sessionID) => { + const win = window as ComposerWindow + return win.__opencode_e2e?.composer?.sessions?.[sessionID]?.probe ?? null + }, sessionID) as Promise + + const api = { + async clear() { + await write(undefined) + return api + }, + async open(todos: NonNullable) { + await write({ live: true, todos }) + return api + }, + async finish(todos: NonNullable) { + await write({ live: false, todos }) + return api + }, + async expectOpen(states: ComposerProbeState["states"]) { + await expect.poll(read, { timeout: 10_000 }).toMatchObject({ + mounted: true, + collapsed: false, + hidden: false, + count: states.length, + states, + }) + return api + }, + async expectCollapsed(states: ComposerProbeState["states"]) { + await expect.poll(read, { timeout: 10_000 }).toMatchObject({ + mounted: true, + collapsed: true, + hidden: true, + count: states.length, + states, + }) + return api + }, + async expectClosed() { + await expect.poll(read, { timeout: 10_000 }).toMatchObject({ mounted: false }) + return api + }, + async collapse() { + await page.locator(sessionTodoToggleButtonSelector).click() + return api + }, + async expand() { + await page.locator(sessionTodoToggleButtonSelector).click() + return api + }, + } + + return api +} + async function withMockPermission( page: any, request: { @@ -70,7 +184,7 @@ async function withMockPermission( always?: string[] }, opts: { child?: any } | undefined, - fn: () => Promise, + fn: (state: { resolved: () => Promise }) => Promise, ) { let pending = [ { @@ -119,8 +233,14 @@ async function withMockPermission( if (sessionList) await page.route("**/session?*", sessionList) + const state = { + async resolved() { + await expect.poll(() => pending.length, { timeout: 10_000 }).toBe(0) + }, + } + try { - return await fn() + return await fn(state) } finally { await page.unroute("**/permission", list) await page.unroute("**/session/*/permissions/*", reply) @@ -173,14 +293,12 @@ test("blocked question flow unblocks after submit", async ({ page, sdk, gotoSess }) const dock = page.locator(questionDockSelector) - await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectQuestionBlocked(page) await dock.locator('[data-slot="question-option"]').first().click() await dock.getByRole("button", { name: /submit/i }).click() - await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectQuestionOpen(page) }) }) }) @@ -199,15 +317,14 @@ test("blocked permission flow supports allow once", async ({ page, sdk, gotoSess metadata: { description: "Need permission for command" }, }, undefined, - async () => { + async (state) => { await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /allow once/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) }) @@ -226,15 +343,14 @@ test("blocked permission flow supports reject", async ({ page, sdk, gotoSession patterns: ["/tmp/opencode-e2e-perm-reject"], }, undefined, - async () => { + async (state) => { await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /deny/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) }) @@ -254,15 +370,14 @@ test("blocked permission flow supports allow always", async ({ page, sdk, gotoSe metadata: { description: "Need permission for command" }, }, undefined, - async () => { + async (state) => { await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /allow always/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) }) @@ -301,14 +416,12 @@ test("child session question request blocks parent dock and unblocks after submi }) const dock = page.locator(questionDockSelector) - await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectQuestionBlocked(page) await dock.locator('[data-slot="question-option"]').first().click() await dock.getByRole("button", { name: /submit/i }).click() - await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectQuestionOpen(page) }) } finally { await cleanupSession({ sdk, sessionID: child.id }) @@ -344,17 +457,15 @@ test("child session permission request blocks parent dock and supports allow onc metadata: { description: "Need child permission" }, }, { child }, - async () => { + async (state) => { await page.goto(page.url()) - const dock = page.locator(permissionDockSelector) - await expect.poll(() => dock.count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectPermissionBlocked(page) await clearPermissionDock(page, /allow once/i) + await state.resolved() await page.goto(page.url()) - await expect.poll(() => page.locator(permissionDockSelector).count(), { timeout: 10_000 }).toBe(0) - await expect(page.locator(promptSelector)).toBeVisible() + await expectPermissionOpen(page) }, ) } finally { @@ -365,36 +476,31 @@ test("child session permission request blocks parent dock and supports allow onc test("todo dock transitions and collapse behavior", async ({ page, sdk, gotoSession }) => { await withDockSession(sdk, "e2e composer dock todo", async (session) => { - await withDockSeed(sdk, session.id, async () => { - await gotoSession(session.id) - - await seedSessionTodos(sdk, { - sessionID: session.id, - todos: [ - { content: "first task", status: "pending", priority: "high" }, - { content: "second task", status: "in_progress", priority: "medium" }, - ], - }) - - await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(sessionTodoListSelector)).toBeVisible() - - await page.locator(sessionTodoToggleButtonSelector).click() - await expect(page.locator(sessionTodoListSelector)).toBeHidden() - - await page.locator(sessionTodoToggleButtonSelector).click() - await expect(page.locator(sessionTodoListSelector)).toBeVisible() - - await seedSessionTodos(sdk, { - sessionID: session.id, - todos: [ - { content: "first task", status: "completed", priority: "high" }, - { content: "second task", status: "cancelled", priority: "medium" }, - ], - }) + const dock = await todoDock(page, session.id) + await gotoSession(session.id) + await expect(page.locator(sessionComposerDockSelector)).toBeVisible() - await expect.poll(() => page.locator(sessionTodoDockSelector).count(), { timeout: 10_000 }).toBe(0) - }) + try { + await dock.open([ + { content: "first task", status: "pending", priority: "high" }, + { content: "second task", status: "in_progress", priority: "medium" }, + ]) + await dock.expectOpen(["pending", "in_progress"]) + + await dock.collapse() + await dock.expectCollapsed(["pending", "in_progress"]) + + await dock.expand() + await dock.expectOpen(["pending", "in_progress"]) + + await dock.finish([ + { content: "first task", status: "completed", priority: "high" }, + { content: "second task", status: "cancelled", priority: "medium" }, + ]) + await dock.expectClosed() + } finally { + await dock.clear() + } }) }) @@ -414,8 +520,7 @@ test("keyboard focus stays off prompt while blocked", async ({ page, sdk, gotoSe ], }) - await expect.poll(() => page.locator(questionDockSelector).count(), { timeout: 10_000 }).toBe(1) - await expect(page.locator(promptSelector)).toHaveCount(0) + await expectQuestionBlocked(page) await page.locator("main").click({ position: { x: 5, y: 5 } }) await page.keyboard.type("abc") diff --git a/packages/app/e2e/session/session-model-persistence.spec.ts b/packages/app/e2e/session/session-model-persistence.spec.ts new file mode 100644 index 00000000000..933d5e6f96d --- /dev/null +++ b/packages/app/e2e/session/session-model-persistence.spec.ts @@ -0,0 +1,373 @@ +import { base64Decode } from "@opencode-ai/util/encode" +import type { Locator, Page } from "@playwright/test" +import { test, expect } from "../fixtures" +import { openSidebar, sessionIDFromUrl, setWorkspacesEnabled, waitSessionIdle, waitSlug } from "../actions" +import { + promptAgentSelector, + promptModelSelector, + promptSelector, + promptVariantSelector, + workspaceItemSelector, + workspaceNewSessionSelector, +} from "../selectors" +import { createSdk, sessionPath } from "../utils" + +type Footer = { + agent: string + model: string + variant: string +} + +type Probe = { + dir?: string + sessionID?: string + model?: { providerID: string; modelID: string } +} + +const escape = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + +const text = async (locator: Locator) => ((await locator.textContent()) ?? "").trim() + +const modelKey = (state: Probe | null) => (state?.model ? `${state.model.providerID}:${state.model.modelID}` : null) + +const dirKey = (state: Probe | null) => state?.dir ?? "" + +async function probe(page: Page): Promise { + return page.evaluate(() => { + const win = window as Window & { + __opencode_e2e?: { + model?: { + current?: Probe + } + } + } + return win.__opencode_e2e?.model?.current ?? null + }) +} + +async function currentDir(page: Page) { + let hit = "" + await expect + .poll( + async () => { + const next = dirKey(await probe(page)) + if (next) hit = next + return next + }, + { timeout: 30_000 }, + ) + .not.toBe("") + return hit +} + +async function read(page: Page): Promise