mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-12 20:06:24 +00:00
Compare commits
85
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a64c10b960 | ||
|
|
cf4f1fb45e | ||
|
|
0a21042523 | ||
|
|
6add96698e | ||
|
|
3f579f53f4 | ||
|
|
9f3ba44c4a | ||
|
|
c4fe0f676a | ||
|
|
625c469854 | ||
|
|
d6c22b3bf5 | ||
|
|
bbf4cd4975 | ||
|
|
27027777d2 | ||
|
|
5d1841e0aa | ||
|
|
2253d4c31d | ||
|
|
877b04f0b9 | ||
|
|
41e5d1b6b6 | ||
|
|
60fb97d59e | ||
|
|
b2e6e764a8 | ||
|
|
0d8e64112f | ||
|
|
b0b85cbb77 | ||
|
|
0f26ad8787 | ||
|
|
b08ad82518 | ||
|
|
5319f063d6 | ||
|
|
63f7ceecbe | ||
|
|
770420bdad | ||
|
|
4994a7d694 | ||
|
|
c55ee2a815 | ||
|
|
312651f68e | ||
|
|
654f411a5b | ||
|
|
68b862da49 | ||
|
|
8f2b13e864 | ||
|
|
bb564f96a3 | ||
|
|
a555f24ded | ||
|
|
10ae321bf3 | ||
|
|
6e8ff0c2b7 | ||
|
|
923bda07a2 | ||
|
|
4c34ee5eab | ||
|
|
2df00955cb | ||
|
|
e0deffa083 | ||
|
|
4e97b78d98 | ||
|
|
6bb4b35399 | ||
|
|
0ce1383030 | ||
|
|
010cd6131e | ||
|
|
c2348f8f69 | ||
|
|
71317ec7c9 | ||
|
|
4261fe749d | ||
|
|
9dd7149e75 | ||
|
|
0c0a431c9f | ||
|
|
3368e049d2 | ||
|
|
f0b5da1c11 | ||
|
|
15c525dcfb | ||
|
|
4d12e01824 | ||
|
|
70afbac80c | ||
|
|
1c723c56fa | ||
|
|
181428a2f3 | ||
|
|
9b1891fb7e | ||
|
|
d4bf78b348 | ||
|
|
d4ceffe787 | ||
|
|
872e38055e | ||
|
|
0c1dfa9186 | ||
|
|
8f4d706647 | ||
|
|
929374cdfd | ||
|
|
cfa5ba700e | ||
|
|
45a2ed9a97 | ||
|
|
f6333546f8 | ||
|
|
9e153ce7b3 | ||
|
|
eb357f17cf | ||
|
|
573d76933f | ||
|
|
bb8194395a | ||
|
|
2e8ed86658 | ||
|
|
2695607fbc | ||
|
|
f3ef84556a | ||
|
|
08ff21179c | ||
|
|
98a36fb1a4 | ||
|
|
e22cd0a585 | ||
|
|
8475783700 | ||
|
|
1452aadc87 | ||
|
|
1417976257 | ||
|
|
5ec7dd968c | ||
|
|
43fb543e3b | ||
|
|
ac7f3c5ece | ||
|
|
3edbc88225 | ||
|
|
20aff6d9f6 | ||
|
|
bdb66747e7 | ||
|
|
f91c6d8b25 | ||
|
|
30f8b2f4b6 |
@@ -25,7 +25,7 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.version || inputs.bump }}
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
@@ -33,7 +33,7 @@ permissions:
|
||||
packages: write
|
||||
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && 'dev') || '' }}
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'v2' && !inputs.bump && !inputs.version && 'dev') || '' }}
|
||||
|
||||
jobs:
|
||||
version:
|
||||
@@ -168,7 +168,7 @@ jobs:
|
||||
fi
|
||||
|
||||
found=0
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
|
||||
for file in packages/cli/dist/cli-darwin-*/bin/opencode; do
|
||||
if [ ! -f "$file" ]; then
|
||||
continue
|
||||
fi
|
||||
@@ -191,7 +191,7 @@ jobs:
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-preview-cli
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
needs: version
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
@@ -221,7 +221,7 @@ jobs:
|
||||
needs:
|
||||
- version
|
||||
- build-node-app-archive
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'v2' && (inputs.bump || inputs.version))
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -276,10 +276,9 @@ jobs:
|
||||
|
||||
sign-cli-windows:
|
||||
needs:
|
||||
- build-cli
|
||||
- version
|
||||
- sign-cli-macos
|
||||
runs-on: blacksmith-4vcpu-windows-2025
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2' && github.ref_name != 'beta'
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'v2' || github.ref_name == 'beta')
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
|
||||
@@ -292,15 +291,8 @@ jobs:
|
||||
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
with:
|
||||
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
name: opencode-preview-cli-macos
|
||||
path: packages/cli/dist
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
@@ -315,9 +307,9 @@ jobs:
|
||||
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
certificate-profile-name: ${{ env.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
files: |
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe
|
||||
${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe
|
||||
exclude-environment-credential: true
|
||||
exclude-workload-identity-credential: true
|
||||
exclude-managed-identity-credential: true
|
||||
@@ -333,9 +325,9 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$files = @(
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline\bin\opencode.exe"
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-arm64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64\bin\opencode.exe",
|
||||
"${{ github.workspace }}\packages\cli\dist\cli-windows-x64-baseline\bin\opencode.exe"
|
||||
)
|
||||
|
||||
foreach ($file in $files) {
|
||||
@@ -345,40 +337,17 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- name: Repack Windows CLI archives
|
||||
working-directory: packages/opencode/dist
|
||||
shell: pwsh
|
||||
run: |
|
||||
Compress-Archive -Path "opencode-windows-arm64\bin\*" -DestinationPath "opencode-windows-arm64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64\bin\*" -DestinationPath "opencode-windows-x64.zip" -Force
|
||||
Compress-Archive -Path "opencode-windows-x64-baseline\bin\*" -DestinationPath "opencode-windows-x64-baseline.zip" -Force
|
||||
|
||||
- name: Upload signed Windows CLI release assets
|
||||
if: needs.version.outputs.release != ''
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
run: |
|
||||
gh release upload "v${{ needs.version.outputs.version }}" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-arm64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64.zip" `
|
||||
"${{ github.workspace }}\packages\opencode\dist\opencode-windows-x64-baseline.zip" `
|
||||
--clobber `
|
||||
--repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: |
|
||||
packages/opencode/dist/opencode-windows-arm64
|
||||
packages/opencode/dist/opencode-windows-x64
|
||||
packages/opencode/dist/opencode-windows-x64-baseline
|
||||
name: opencode-preview-cli
|
||||
path: packages/cli/dist/cli-*
|
||||
if-no-files-found: error
|
||||
|
||||
build-electron:
|
||||
needs:
|
||||
- version
|
||||
- sign-cli-macos
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'v2'
|
||||
- sign-cli-windows
|
||||
if: github.repository == 'anomalyco/opencode' && (github.ref_name != 'v2' || needs.version.outputs.release != '')
|
||||
continue-on-error: false
|
||||
env:
|
||||
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
|
||||
@@ -8,9 +8,9 @@
|
||||
|
||||
## Live V2 TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode2` background server and live sessions.
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## V2 TUI Stories
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "1.17.20",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -82,6 +82,7 @@
|
||||
"@solidjs/router": "catalog:",
|
||||
"@tanstack/solid-query": "5.91.4",
|
||||
"@tanstack/solid-virtual": "catalog:",
|
||||
"core-js": "3.50.0",
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"ghostty-web": "github:anomalyco/ghostty-web#83c0a07b8628b748aed073b232cb4b52a6ca11c1",
|
||||
@@ -111,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.1",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "1.17.13",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -215,7 +216,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -251,7 +252,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -278,7 +279,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -295,7 +296,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -319,7 +320,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -339,7 +340,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
@@ -411,7 +412,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -463,7 +464,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -500,7 +501,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -516,7 +517,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -535,7 +536,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -548,7 +549,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -562,7 +563,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -577,7 +578,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -616,7 +617,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -646,7 +647,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "1.17.11",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -661,7 +662,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "1.17.11",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -685,7 +686,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -706,7 +707,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -728,7 +729,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -763,7 +764,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "1.17.13",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -783,7 +784,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -817,7 +818,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -836,7 +837,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -882,7 +883,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -896,7 +897,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -931,7 +932,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -966,7 +967,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "1.18.3",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -999,7 +1000,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "1.18.15",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1040,7 +1041,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "1.18.4",
|
||||
"version": "2.0.1",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3702,6 +3703,8 @@
|
||||
|
||||
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
|
||||
|
||||
"core-js": ["core-js@3.50.0", "", {}, "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw=="],
|
||||
|
||||
"core-js-compat": ["core-js-compat@3.50.0", "", { "dependencies": { "browserslist": "^4.28.7" } }, "sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q=="],
|
||||
|
||||
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
APP=opencode2
|
||||
APP=opencode
|
||||
SOURCE_APP=opencode
|
||||
|
||||
MUTED='\033[0;2m'
|
||||
RED='\033[0;31m'
|
||||
@@ -22,7 +23,7 @@ Options:
|
||||
Examples:
|
||||
curl -fsSL https://opencode.ai/v2/install | bash
|
||||
curl -fsSL https://opencode.ai/v2/install | bash -s -- --version 0.0.0-beta-17236
|
||||
./install --binary /path/to/opencode2
|
||||
./install --binary /path/to/opencode
|
||||
EOF
|
||||
}
|
||||
|
||||
@@ -201,9 +202,9 @@ else
|
||||
|
||||
filename="cli-$target-$specific_version.tgz"
|
||||
url="https://registry.npmjs.org/$package_name/-/$filename"
|
||||
binary_name="$APP"
|
||||
binary_name="$SOURCE_APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
binary_name="$APP.exe"
|
||||
binary_name="$SOURCE_APP.exe"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -230,12 +231,7 @@ check_version() {
|
||||
installed_version="${installed_version##* }"
|
||||
installed_version="${installed_version#v}"
|
||||
|
||||
if [[ "$installed_version" != "$specific_version" ]]; then
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
else
|
||||
print_message info "${MUTED}Version ${NC}$specific_version${MUTED} already installed"
|
||||
exit 0
|
||||
fi
|
||||
print_message info "${MUTED}Installed version: ${NC}$installed_version."
|
||||
fi
|
||||
}
|
||||
|
||||
@@ -340,15 +336,46 @@ download_and_install() {
|
||||
fi
|
||||
|
||||
tar -xzf "$tmp_dir/$filename" -C "$tmp_dir"
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR"
|
||||
chmod 755 "${INSTALL_DIR}/$binary_name"
|
||||
local installed_binary="$APP"
|
||||
if [ "$os" = "windows" ]; then
|
||||
installed_binary="$APP.exe"
|
||||
fi
|
||||
mv "$tmp_dir/package/bin/$binary_name" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
|
||||
install_from_binary() {
|
||||
print_message info "\n${MUTED}Installing ${NC}$APP ${MUTED}from: ${NC}$binary_path"
|
||||
cp "$binary_path" "${INSTALL_DIR}/$APP"
|
||||
chmod 755 "${INSTALL_DIR}/$APP"
|
||||
local installed_binary="$APP"
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) installed_binary="$APP.exe" ;;
|
||||
esac
|
||||
cp "$binary_path" "$INSTALL_DIR/$installed_binary"
|
||||
chmod 755 "$INSTALL_DIR/$installed_binary"
|
||||
}
|
||||
|
||||
install_legacy_shim() {
|
||||
local shim_os="${os:-}"
|
||||
if [[ -z "$shim_os" ]]; then
|
||||
case "$(uname -s)" in
|
||||
MINGW*|MSYS*|CYGWIN*) shim_os="windows" ;;
|
||||
esac
|
||||
fi
|
||||
rm -f "$INSTALL_DIR/opencode2" "$INSTALL_DIR/opencode2.exe" "$INSTALL_DIR/opencode2.cmd"
|
||||
if [[ "$shim_os" == "windows" ]]; then
|
||||
cat > "$INSTALL_DIR/opencode2.cmd" <<'EOF'
|
||||
@echo off
|
||||
"%~dp0opencode.exe" %*
|
||||
exit /b %errorlevel%
|
||||
EOF
|
||||
return
|
||||
fi
|
||||
cat > "$INSTALL_DIR/opencode2" <<'EOF'
|
||||
#!/bin/sh
|
||||
exec "$(dirname "$0")/opencode" "$@"
|
||||
EOF
|
||||
chmod 755 "$INSTALL_DIR/opencode2"
|
||||
}
|
||||
|
||||
if [ -n "$binary_path" ]; then
|
||||
@@ -357,6 +384,7 @@ else
|
||||
check_version
|
||||
download_and_install
|
||||
fi
|
||||
install_legacy_shim
|
||||
|
||||
|
||||
add_to_path() {
|
||||
@@ -453,7 +481,7 @@ echo -e ""
|
||||
echo -e "${MUTED}OpenCode includes free models, to start:${NC}"
|
||||
echo -e ""
|
||||
echo -e "cd <project> ${MUTED}# Open directory${NC}"
|
||||
echo -e "opencode2 ${MUTED}# Run command${NC}"
|
||||
echo -e "opencode ${MUTED}# Run command${NC}"
|
||||
echo -e ""
|
||||
echo -e "${MUTED}For more information visit ${NC}https://opencode.ai/v2/docs"
|
||||
echo -e ""
|
||||
|
||||
+3
-3
@@ -2,15 +2,15 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "0.0.0",
|
||||
"version": "2.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
"scripts": {
|
||||
"dev": "bun run --cwd packages/cli src/index.ts",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:vite": "bun run --cwd packages/cli --conditions=browser dev/vite.ts",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode2 service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode2 service status)\"' --",
|
||||
"dev:vite:live": "sh -c 'OPENCODE_TUI_CHANNEL=dev OPENCODE_PASSWORD=\"$(opencode service get password)\" exec bun run dev:vite \"$@\" --server \"$(opencode service status)\"' --",
|
||||
"dev:desktop": "bun --cwd packages/desktop dev",
|
||||
"dev:web": "bun --cwd packages/app dev",
|
||||
"dev:console": "ulimit -n 10240 2>/dev/null; bun run --cwd packages/console/app dev",
|
||||
|
||||
@@ -122,13 +122,14 @@ Keep provider facades small and explicit:
|
||||
|
||||
### Provider Package Entrypoints
|
||||
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are serializable provider configuration plus common `headers`, `body`, and `limits` overlays.
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
```
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "1.17.20",
|
||||
"version": "2.0.1",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1708,7 +1708,10 @@ export const transport = <
|
||||
}
|
||||
|
||||
function requiredBetaHeaders(body: Pick<AnthropicMessagesBody, "messages" | "context_management" | "thinking">) {
|
||||
const betas: string[] = []
|
||||
// Always request interleaved thinking. The API accepts the header on any
|
||||
// model and ignores it where unsupported, while manual-thinking models need
|
||||
// it for thinking between tool calls.
|
||||
const betas: string[] = ["interleaved-thinking-2025-05-14"]
|
||||
const requestsCompaction = (body.context_management?.edits.length ?? 0) > 0
|
||||
const replaysCompaction = body.messages.some((message) =>
|
||||
message.content.some((block) => block.type === "compaction"),
|
||||
|
||||
@@ -106,7 +106,7 @@ type BedrockSystemBlock = Schema.Schema.Type<typeof BedrockSystemBlock>
|
||||
const BedrockToolSpec = Schema.Struct({
|
||||
toolSpec: Schema.Struct({
|
||||
name: Schema.String,
|
||||
description: Schema.String,
|
||||
description: Schema.optional(Schema.String),
|
||||
inputSchema: Schema.Struct({
|
||||
json: JsonObject,
|
||||
}),
|
||||
@@ -222,7 +222,7 @@ type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
|
||||
const lowerToolSpec = (tool: ToolDefinition, inputSchema: JsonSchema): BedrockToolSpec => ({
|
||||
toolSpec: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
...(tool.description.trim().length > 0 ? { description: tool.description } : {}),
|
||||
inputSchema: { json: inputSchema },
|
||||
},
|
||||
})
|
||||
|
||||
@@ -18,7 +18,6 @@ const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Sche
|
||||
])
|
||||
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
|
||||
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
export interface Options {
|
||||
readonly id: string
|
||||
@@ -27,6 +26,7 @@ export interface Options {
|
||||
readonly enabled?: (url: string) => boolean
|
||||
readonly url?: (url: string) => string
|
||||
readonly headers?: (headers: Headers.Headers) => Headers.Headers
|
||||
readonly continuation?: OpenResponsesContinuation.Shape
|
||||
}
|
||||
|
||||
export interface Prepared {
|
||||
@@ -60,7 +60,7 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
}),
|
||||
observe: (_create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -163,6 +163,7 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
request: create.request,
|
||||
message: create.message,
|
||||
base,
|
||||
continuation: options.continuation,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,7 +6,6 @@ import { OpenResponses } from "./open-responses.js"
|
||||
|
||||
const PROTOCOL = "open-responses.websocket.v1"
|
||||
const VERSION = 1
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
interface CheckpointValue {
|
||||
readonly version: typeof VERSION
|
||||
@@ -15,12 +14,19 @@ interface CheckpointValue {
|
||||
readonly output: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields to send next to `previous_response_id` on an incremental step, or undefined to send the step in full.
|
||||
* Whether omitted fields carry over from the continued response is provider behavior the route must know.
|
||||
*/
|
||||
export type Shape = (request: Readonly<Record<string, unknown>>) => Readonly<Record<string, unknown>> | undefined
|
||||
|
||||
export interface DriverInput {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
readonly request: Readonly<Record<string, unknown>>
|
||||
readonly message: string
|
||||
readonly base: WebSocketChannelDriver
|
||||
readonly continuation?: Shape
|
||||
}
|
||||
|
||||
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
|
||||
@@ -127,22 +133,26 @@ const rejected = (
|
||||
|
||||
export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
const { previous_response_id: _previousResponseID, ...request } = input.request
|
||||
const shape = input.continuation ?? ((fields: Readonly<Record<string, unknown>>) => fields)
|
||||
let output: OpenResponses.StreamItem[] = []
|
||||
return {
|
||||
create: (checkpoint) =>
|
||||
Effect.sync(() => {
|
||||
output = []
|
||||
const previous = checkpointValue(checkpoint)
|
||||
const delta = previous ? incremental(request, previous) : undefined
|
||||
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
// Ask the route first: diffing the whole history is wasted when it declines the continuation.
|
||||
const fields = previous ? shape(request) : undefined
|
||||
const delta = previous && fields ? incremental(request, previous) : undefined
|
||||
if (!previous || !fields || !delta)
|
||||
return { message: ProviderShared.encodeJson(request), mode: "full" as const }
|
||||
return {
|
||||
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
|
||||
message: ProviderShared.encodeJson({ ...fields, input: delta, previous_response_id: previous.responseID }),
|
||||
mode: "incremental" as const,
|
||||
}
|
||||
}),
|
||||
observe: (create, frame) =>
|
||||
Effect.gen(function* () {
|
||||
const event = yield* decodeEvent(frame).pipe(
|
||||
const event = yield* OpenResponses.decodeChannelEvent(frame).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame, cause),
|
||||
),
|
||||
@@ -181,7 +191,7 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
responseID,
|
||||
request,
|
||||
// Completion can re-encrypt reasoning. Callers replay the item already emitted by output_item.done.
|
||||
output: event.response?.output
|
||||
output: event.response?.output?.length
|
||||
? event.response.output.map((item) =>
|
||||
item.type === "reasoning" && item.id !== undefined
|
||||
? (output.find((done) => done.type === item.type && done.id === item.id) ?? item)
|
||||
@@ -195,4 +205,4 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
export const OpenResponsesContinuation = { driver } as const
|
||||
export * as OpenResponsesContinuation from "./open-responses-continuation.js"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, SchemaGetter } from "effect"
|
||||
import type { Content } from "@opencode/schema/tool"
|
||||
import { HttpTransport } from "../route/transport/index.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
@@ -325,9 +325,8 @@ export const StreamItem = Schema.StructWithRest(
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
|
||||
// The Responses schema puts streaming error details at the top level and
|
||||
// response failures under `response.error`. WebSocket failures use an
|
||||
// event-level `error` envelope, so accept all three shapes here.
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
@@ -401,10 +400,39 @@ export const Event = Schema.StructWithRest(
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
).pipe(
|
||||
Schema.decode({
|
||||
decode: SchemaGetter.transform((event) => {
|
||||
if (event.type !== "error" || event.error != null) return event
|
||||
const { code, message, param, ...rest } = event
|
||||
if (code === undefined && message === undefined && param === undefined) return event
|
||||
// Flat errors (for example, Meta's) can also arrive through generic Responses endpoints.
|
||||
return { ...rest, error: { code, message, param } }
|
||||
}),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
export type NormalizedEvent = Event & { readonly item?: OutputItem | null }
|
||||
|
||||
const decodeEventValue = Schema.decodeUnknownEffect(Event)
|
||||
const decodeFrame = Schema.decodeUnknownEffect(ProviderShared.Json)
|
||||
|
||||
/**
|
||||
* Decodes one WebSocket frame. xAI answers a rejected `response.create` with `{ "error": { "message", "type" } }` and no
|
||||
* event type; that envelope reads as an error event so the failure classifies instead of failing decoding.
|
||||
*/
|
||||
export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
export interface ProviderAdapter {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { LanguageModel, ProviderOptions } from "./schema/index.js"
|
||||
import type { CompactionOperations } from "./route/client.js"
|
||||
|
||||
/**
|
||||
* Flat, serializable settings for `model(modelID, settings)`. Each entrypoint declares the connection keys it
|
||||
* reads; every other key is a request option for the route's protocol.
|
||||
*/
|
||||
export interface Settings extends Readonly<Record<string, unknown>> {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Readonly<Record<string, string>>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Struct } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { AlibabaChat } from "../protocols/alibaba-chat.js"
|
||||
import { AlibabaMessages } from "../protocols/alibaba-messages.js"
|
||||
@@ -6,7 +7,7 @@ import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, ToolDefinition, type ModelID } from "../schema/index.js"
|
||||
|
||||
export const id = ProviderID.make("alibaba")
|
||||
|
||||
@@ -34,9 +35,9 @@ export type Config = Location &
|
||||
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
|
||||
}
|
||||
export type Settings<Options = ChatOptionsInput> = Location &
|
||||
ProviderPackage.Settings & {
|
||||
ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
|
||||
const hosts = new Map<string, string>([
|
||||
@@ -82,8 +83,13 @@ export const configure = (input: Config) => {
|
||||
? hosts.get(region)
|
||||
: `${workspaceID}.${region}.maas.aliyuncs.com`
|
||||
if (baseURL === undefined) {
|
||||
if (region === undefined) throw new Error("Alibaba requires region or baseURL")
|
||||
if (host === undefined) throw new Error(`Alibaba region ${region} requires workspaceID or baseURL`)
|
||||
if (region === undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Alibaba requires region or baseURL" })
|
||||
if (host === undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
})
|
||||
}
|
||||
const opts = { ...rest, auth: AuthOptions.bearer(input, ["DASHSCOPE_API_KEY", "ALIBABA_API_KEY"]) }
|
||||
const common = { ...opts, endpoint: { baseURL: baseURL ?? `https://${host}/compatible-mode/v1` } }
|
||||
@@ -115,7 +121,11 @@ export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
function fromSettings(input: Settings<Config["providerOptions"]>) {
|
||||
const { body, ...rest } = input
|
||||
return configure({ ...rest, http: body === undefined ? undefined : { body } })
|
||||
return configure({
|
||||
...rest,
|
||||
http: body === undefined ? undefined : { body },
|
||||
providerOptions: Struct.omit(rest, ["apiKey", "baseURL", "headers", "region", "workspaceID"]),
|
||||
})
|
||||
}
|
||||
|
||||
export const webSearch = () => hostedTool("web_search", "Search the web with Alibaba's hosted search tool.")
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenAIResponses } from "../protocols/openai-responses.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { BedrockAuth, type Credentials } from "../protocols/utils/bedrock-auth.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("amazon-bedrock")
|
||||
@@ -22,26 +23,25 @@ export type Config = RouteDefaultsInput & {
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly auth?: "bearer" | "sigv4"
|
||||
readonly baseURL?: string
|
||||
readonly credentials?: Credentials
|
||||
readonly profile?: string
|
||||
readonly region?: string
|
||||
readonly topP?: number
|
||||
}
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "bedrock-mantle-responses",
|
||||
provider: id,
|
||||
providerMetadataKey: "mantle",
|
||||
protocol: OpenAIResponses.protocol,
|
||||
endpoint: OpenAIResponses.route.endpoint,
|
||||
auth: OpenAIResponses.route.auth,
|
||||
transport: OpenAIResponses.httpTransport,
|
||||
defaults: OpenAIResponses.route.defaults,
|
||||
protocol: OpenResponses.protocol,
|
||||
endpoint: Endpoint.path(OpenResponses.PATH),
|
||||
transport: OpenResponses.httpTransport,
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
|
||||
const chatRoute = OpenAIChat.route.with({
|
||||
@@ -79,9 +79,12 @@ const defaults = (input: Config) => {
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
if (input.auth === "bearer" && input.apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock Mantle bearer auth requires apiKey")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock Mantle bearer auth requires apiKey" })
|
||||
if (input.auth === "sigv4" && input.apiKey !== undefined)
|
||||
throw new Error("Amazon Bedrock Mantle SigV4 auth does not accept apiKey")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Amazon Bedrock Mantle SigV4 auth does not accept apiKey",
|
||||
})
|
||||
const configuredResponsesRoute = configuredRoute(responsesRoute, input)
|
||||
const configuredChatRoute = configuredRoute(chatRoute, input)
|
||||
const modelDefaults = defaults(input)
|
||||
@@ -105,18 +108,29 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const fromSettings = (settings: Settings) =>
|
||||
const fromSettings = ({
|
||||
apiKey,
|
||||
auth,
|
||||
baseURL,
|
||||
body,
|
||||
credentials,
|
||||
headers,
|
||||
profile,
|
||||
region,
|
||||
topP,
|
||||
...providerOptions
|
||||
}: Settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
auth: settings.auth,
|
||||
baseURL: settings.baseURL,
|
||||
credentials: settings.credentials,
|
||||
generation: settings.topP === undefined ? undefined : { topP: settings.topP },
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
profile: settings.profile,
|
||||
providerOptions: settings.providerOptions,
|
||||
region: settings.region,
|
||||
apiKey,
|
||||
auth,
|
||||
baseURL,
|
||||
credentials,
|
||||
generation: topP === undefined ? undefined : { topP },
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
profile,
|
||||
providerOptions,
|
||||
region,
|
||||
})
|
||||
|
||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as BedrockConverse from "../protocols/bedrock-converse.js"
|
||||
import type { BedrockCredentials } from "../protocols/bedrock-converse.js"
|
||||
import { BedrockAuth } from "../protocols/utils/bedrock-auth.js"
|
||||
@@ -39,8 +39,9 @@ const bedrockBaseURL = (region: string) => `https://bedrock-runtime.${region}.am
|
||||
const configuredRoute = (input: Config) => {
|
||||
const { apiKey, auth, credentials, profile, region, baseURL, ...rest } = input
|
||||
if (auth === "bearer" && apiKey === undefined && process.env.AWS_BEARER_TOKEN_BEDROCK === undefined)
|
||||
throw new Error("Amazon Bedrock bearer auth requires apiKey")
|
||||
if (auth === "sigv4" && apiKey !== undefined) throw new Error("Amazon Bedrock SigV4 auth does not accept apiKey")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock bearer auth requires apiKey" })
|
||||
if (auth === "sigv4" && apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Amazon Bedrock SigV4 auth does not accept apiKey" })
|
||||
const resolvedRegion = BedrockAuth.resolveRegion(input)
|
||||
return BedrockConverse.route.with({
|
||||
...rest,
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
export type AnthropicProviderOptionsInput = AnthropicMessages.ProviderOptionsInput
|
||||
@@ -19,13 +19,13 @@ export type Config = RouteDefaultsInput &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
AnthropicMessages.ProviderOptionsInput &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export const routes = [AnthropicMessages.route]
|
||||
@@ -36,8 +36,12 @@ const auth = (input: ProviderAuthOption<"optional">) => {
|
||||
}
|
||||
|
||||
export const configure = (input: Config) => {
|
||||
if (!input.baseURL) throw new Error("Anthropic-compatible providers require a baseURL")
|
||||
const provider = input.provider ?? "anthropic-compatible"
|
||||
if (!input.baseURL)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: "Anthropic-compatible providers require a baseURL",
|
||||
})
|
||||
const { provider: _, baseURL, apiKey: _apiKey, auth: _auth, ...rest } = input
|
||||
const route = AnthropicMessages.route.with({
|
||||
...rest,
|
||||
@@ -59,17 +63,20 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, authToken, baseURL, body, headers, provider, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
if (apiKey !== undefined && authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider ?? id),
|
||||
message: "Anthropic-compatible apiKey cannot be combined with authToken",
|
||||
})
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
provider,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { RouteDefaultsInput } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import type { ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AnthropicMessages } from "../protocols/anthropic-messages.js"
|
||||
import { AnthropicCompatible } from "./anthropic-compatible.js"
|
||||
|
||||
@@ -21,12 +21,12 @@ export type Config = RouteDefaultsInput &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
AnthropicMessages.ProviderOptionsInput &
|
||||
(
|
||||
| { readonly apiKey?: string; readonly authToken?: never }
|
||||
| { readonly apiKey?: never; readonly authToken?: string }
|
||||
) & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
@@ -54,15 +54,18 @@ export const configure = (input: Config = {}) => {
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, authToken, baseURL, body, headers, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined && settings.authToken !== undefined)
|
||||
throw new Error("Anthropic apiKey cannot be combined with authToken")
|
||||
if (apiKey !== undefined && authToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Anthropic apiKey cannot be combined with authToken",
|
||||
})
|
||||
return configure({
|
||||
...(settings.authToken === undefined ? { apiKey: settings.apiKey } : { auth: Auth.bearer(settings.authToken) }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
...(authToken === undefined ? { apiKey: apiKey } : { auth: Auth.bearer(authToken) }),
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import * as OpenAIChat from "../protocols/openai-chat.js"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
@@ -28,12 +28,12 @@ export type LanguageModelOptions = AzureURL &
|
||||
export type Config = LanguageModelOptions
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput &
|
||||
AzureURL & {
|
||||
readonly apiKey?: string
|
||||
readonly apiVersion?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly useDeploymentBasedUrls?: boolean
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
const resourceBaseURL = (resourceName: string) => `https://${resourceName.trim()}.openai.azure.com/openai`
|
||||
@@ -151,19 +151,29 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const config = ({
|
||||
apiKey,
|
||||
apiVersion,
|
||||
baseURL,
|
||||
body,
|
||||
headers,
|
||||
queryParams,
|
||||
resourceName,
|
||||
useDeploymentBasedUrls,
|
||||
...providerOptions
|
||||
}: Settings): Config => {
|
||||
const common = {
|
||||
apiKey: settings.apiKey,
|
||||
apiVersion: settings.apiVersion,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
useDeploymentBasedUrls: settings.useDeploymentBasedUrls,
|
||||
apiKey,
|
||||
apiVersion,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
queryParams: queryParams === undefined ? undefined : { ...queryParams },
|
||||
useDeploymentBasedUrls,
|
||||
}
|
||||
if (settings.baseURL !== undefined) return { ...common, baseURL: settings.baseURL }
|
||||
if (settings.resourceName !== undefined) return { ...common, resourceName: settings.resourceName }
|
||||
throw new Error("Azure requires resourceName or baseURL")
|
||||
if (baseURL !== undefined) return { ...common, baseURL }
|
||||
if (resourceName !== undefined) return { ...common, resourceName }
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Azure requires resourceName or baseURL" })
|
||||
}
|
||||
|
||||
export const responsesModel: ProviderPackage.Definition<
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "baseten-chat",
|
||||
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Baseten from "./baseten.js"
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "cerebras-chat",
|
||||
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import type { AtLeastOne, ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-ai-gateway")
|
||||
@@ -27,15 +27,19 @@ export type LanguageModelOptions = GatewayURL &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput &
|
||||
GatewayURL & {
|
||||
readonly apiKey?: string
|
||||
readonly gatewayApiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: GatewayURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareAIGateway.configure requires accountId unless baseURL is supplied")
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareAIGateway.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://gateway.ai.cloudflare.com/v1/${encodeURIComponent(input.accountId)}/${encodeURIComponent(input.gatewayId?.trim() || "default")}/compat`
|
||||
}
|
||||
|
||||
@@ -85,14 +89,25 @@ export const configure = (input: LanguageModelOptions) => {
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
gatewayApiKey: settings.gatewayApiKey,
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
const {
|
||||
accountId: _,
|
||||
apiKey,
|
||||
baseURL: _url,
|
||||
body,
|
||||
gatewayApiKey,
|
||||
gatewayId: _id,
|
||||
headers,
|
||||
...providerOptions
|
||||
} = settings
|
||||
return configure({
|
||||
apiKey,
|
||||
gatewayApiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
export * as CloudflareAIGateway from "./cloudflare-ai-gateway.js"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { AuthOptions, type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("cloudflare-workers-ai")
|
||||
@@ -21,14 +21,18 @@ export type LanguageModelOptions = WorkersAIURL &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput &
|
||||
WorkersAIURL & {
|
||||
readonly apiKey?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export const baseURL = (input: WorkersAIURL) => {
|
||||
if (input.baseURL) return input.baseURL
|
||||
if (!input.accountId) throw new Error("CloudflareWorkersAI.configure requires accountId unless baseURL is supplied")
|
||||
if (!input.accountId)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "CloudflareWorkersAI.configure requires accountId unless baseURL is supplied",
|
||||
})
|
||||
return `https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(input.accountId)}/ai/v1`
|
||||
}
|
||||
|
||||
@@ -59,13 +63,15 @@ export const configure = (input: LanguageModelOptions) => {
|
||||
|
||||
export const provider = { id, configure }
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
const { accountId: _, apiKey, baseURL: _url, body, headers, ...providerOptions } = settings
|
||||
return configure({
|
||||
apiKey,
|
||||
baseURL: baseURL(settings),
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
export * as CloudflareWorkersAI from "./cloudflare-workers-ai.js"
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "deepinfra-chat",
|
||||
@@ -55,11 +55,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "deepseek-chat",
|
||||
@@ -52,13 +52,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as DeepSeek from "./deepseek.js"
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "fireworks-chat",
|
||||
@@ -48,13 +48,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Fireworks from "./fireworks.js"
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-chat",
|
||||
@@ -37,7 +37,8 @@ const route = Route.make({
|
||||
export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -73,15 +74,19 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Chat does not support API keys")
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Chat does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
accessToken,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export type AnthropicOptionsInput = AnthropicMessages.OptionsInput
|
||||
@@ -25,14 +25,14 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: AnthropicMessages.ProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
AnthropicMessages.ProviderOptionsInput & {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-messages",
|
||||
@@ -67,7 +67,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Messages does not support API keys")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -105,16 +105,17 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, AnthropicMessages.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Messages does not support API keys")
|
||||
if (apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Messages does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
accessToken,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenResponses } from "../protocols/open-responses.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
import type { OpenResponsesProviderOptionsInput } from "./open-responses-options.js"
|
||||
|
||||
@@ -16,14 +16,14 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenResponsesProviderOptionsInput & {
|
||||
readonly accessToken?: string
|
||||
readonly apiKey?: never
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
}
|
||||
|
||||
const route = Route.make({
|
||||
id: "google-vertex-responses",
|
||||
@@ -39,7 +39,7 @@ export const routes = [route]
|
||||
|
||||
const configuredRoute = (input: Config) => {
|
||||
if ("apiKey" in input && input.apiKey !== undefined)
|
||||
throw new Error("Google Vertex Responses does not support API keys")
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
const {
|
||||
accessToken: _accessToken,
|
||||
auth: _auth,
|
||||
@@ -77,16 +77,17 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (settings.apiKey !== undefined) throw new Error("Google Vertex Responses does not support API keys")
|
||||
if (apiKey !== undefined)
|
||||
throw new ProviderConfigurationError({ provider: id, message: "Google Vertex Responses does not support API keys" })
|
||||
return configure({
|
||||
accessToken: settings.accessToken,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
accessToken,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { AnyAuthClient } from "google-auth-library"
|
||||
import { Effect, Redacted } from "effect"
|
||||
import { Auth, MissingCredentialError } from "../route/auth.js"
|
||||
import { ProviderConfigurationError, ProviderID } from "../schema/index.js"
|
||||
|
||||
const SCOPE = "https://www.googleapis.com/auth/cloud-platform"
|
||||
const id = ProviderID.make("google-vertex")
|
||||
|
||||
export type OAuthOptions =
|
||||
| { readonly accessToken?: string; readonly auth?: never }
|
||||
@@ -35,12 +37,18 @@ export const host = (location: string) => {
|
||||
|
||||
export const requireProject = (value: string | undefined) => {
|
||||
if (value) return value
|
||||
throw new Error("Google Vertex requires a project when baseURL is not configured")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex requires a project when baseURL is not configured",
|
||||
})
|
||||
}
|
||||
|
||||
export const apiKey = (input: ApiKeyOptions) => {
|
||||
if (input.apiKey !== undefined && (input.accessToken !== undefined || input.auth !== undefined))
|
||||
throw new Error("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
if (input.accessToken !== undefined || input.auth !== undefined) return undefined
|
||||
return input.apiKey ?? process.env.GOOGLE_VERTEX_API_KEY
|
||||
}
|
||||
@@ -68,7 +76,10 @@ const adc = (project?: string) => {
|
||||
|
||||
export const oauth = (input: OAuthOptions, project?: string) => {
|
||||
if (input.accessToken !== undefined && input.auth !== undefined)
|
||||
throw new Error("Google Vertex accessToken cannot be combined with auth")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex accessToken cannot be combined with auth",
|
||||
})
|
||||
if (input.auth) return input.auth
|
||||
if (input.accessToken !== undefined) return Auth.bearer(input.accessToken)
|
||||
return adc(project)
|
||||
|
||||
@@ -6,7 +6,7 @@ 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 LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
export interface GeminiOptionsInput extends Gemini.OptionsInput {
|
||||
@@ -26,6 +26,7 @@ export type Config = RouteDefaultsInput &
|
||||
}
|
||||
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
GeminiProviderOptionsInput &
|
||||
(
|
||||
| { readonly accessToken?: string; readonly apiKey?: never }
|
||||
| { readonly accessToken?: never; readonly apiKey?: string }
|
||||
@@ -33,7 +34,6 @@ export type Settings = ProviderPackage.Settings &
|
||||
readonly baseURL?: string
|
||||
readonly location?: string
|
||||
readonly project?: string
|
||||
readonly providerOptions?: GeminiProviderOptionsInput
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
@@ -93,7 +93,10 @@ const configuredRoute = (input: Config, modelID: string | ModelID) => {
|
||||
const apiKey = GoogleVertexShared.apiKey(input)
|
||||
const endpointModel = String(modelID).startsWith("endpoints/")
|
||||
if (apiKey !== undefined && endpointModel)
|
||||
throw new Error("Google Vertex tuned models do not support Express Mode API keys")
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
})
|
||||
const location = GoogleVertexShared.location(inputLocation, "us-central1")
|
||||
const project = GoogleVertexShared.project(inputProject)
|
||||
const endpoint =
|
||||
@@ -121,16 +124,22 @@ export const provider = {
|
||||
id,
|
||||
configure,
|
||||
}
|
||||
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")
|
||||
export const model: ProviderPackage.Definition<Settings, GeminiProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ accessToken, apiKey, baseURL, body, headers, location, project, ...providerOptions },
|
||||
) => {
|
||||
if (apiKey !== undefined && accessToken !== undefined)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: id,
|
||||
message: "Google Vertex apiKey cannot be combined with accessToken or auth",
|
||||
})
|
||||
return configure({
|
||||
...(settings.apiKey === undefined ? { accessToken: settings.accessToken } : { apiKey: settings.apiKey }),
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
location: settings.location,
|
||||
project: settings.project,
|
||||
providerOptions: settings.providerOptions,
|
||||
...(apiKey === undefined ? { accessToken: accessToken } : { apiKey: apiKey }),
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
location,
|
||||
project,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
}
|
||||
|
||||
@@ -20,11 +20,11 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Gemini.ProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
Gemini.ProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => {
|
||||
if ("auth" in options && options.auth) return options.auth
|
||||
@@ -57,13 +57,16 @@ export const configure = (input: Config = {}) => {
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, Gemini.ProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const image = provider.image
|
||||
|
||||
@@ -26,11 +26,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ProviderOptions & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
includeReasoning: Schema.optional(Schema.Boolean),
|
||||
@@ -103,13 +103,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Groq from "./groq.js"
|
||||
|
||||
@@ -79,11 +79,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const responsesRoute = Route.make({
|
||||
id: "meta-responses",
|
||||
@@ -169,13 +169,13 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenResponsesProvid
|
||||
export const messagesModel: ProviderPackage.Definition<Settings, MessagesOptionsInput>["model"] = (modelID, settings) =>
|
||||
fromSettings(settings).messages(modelID)
|
||||
|
||||
function fromSettings(settings: Settings) {
|
||||
function fromSettings({ apiKey, baseURL, body, headers, ...providerOptions }: Settings) {
|
||||
return configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -40,11 +40,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings<Options = MessagesOptionsInput> extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
export type Settings<Options = MessagesOptionsInput> = ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const ChatOptions = Schema.Struct({
|
||||
thinking: Schema.optional(Schema.Struct({ type: Schema.String })),
|
||||
@@ -127,14 +127,14 @@ export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings<MessagesOptionsInput>, MessagesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export const messages = provider.messages
|
||||
|
||||
@@ -3,11 +3,14 @@ import { MiniMax } from "../minimax.js"
|
||||
|
||||
export type Settings = MiniMax.Settings<MiniMax.ChatOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
MiniMax.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).chat(modelID)
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = MiniMax.Settings<MiniMax.ResponsesOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, MiniMax.ResponsesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
MiniMax.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).responses(modelID)
|
||||
|
||||
@@ -14,11 +14,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ProviderOptions & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = MistralChat.route
|
||||
export const routes = [route]
|
||||
@@ -39,13 +39,16 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Mistral from "./mistral.js"
|
||||
|
||||
@@ -42,11 +42,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const ChatOptions = Schema.Struct({
|
||||
reasoningEffort: Schema.optional(Schema.String),
|
||||
@@ -133,13 +133,16 @@ export const chat = provider.chat
|
||||
export const messages = provider.messages
|
||||
export const responses = provider.responses
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Moonshot from "./moonshot.js"
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.MessagesOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, Moonshot.MessagesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
Moonshot.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).messages(modelID)
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = Moonshot.Settings<Moonshot.ResponsesOptionsInput>
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, Moonshot.ResponsesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
Moonshot.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).responses(modelID)
|
||||
|
||||
@@ -16,12 +16,12 @@ export type Config = RouteDefaultsInput &
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenResponsesProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenResponsesProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleResponses.route]
|
||||
|
||||
@@ -48,13 +48,13 @@ export const provider = {
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenResponsesProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
provider,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -14,12 +14,12 @@ type GenericModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL: string
|
||||
readonly provider?: string
|
||||
}
|
||||
|
||||
export const routes = [OpenAICompatibleChat.route]
|
||||
|
||||
@@ -45,14 +45,17 @@ export const provider = {
|
||||
configure,
|
||||
}
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, provider, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
provider: settings.provider,
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
provider,
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
|
||||
@@ -57,14 +57,14 @@ export const imageGeneration = (options: ImageGenerationOptions = {}) =>
|
||||
},
|
||||
})
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly organization?: string
|
||||
readonly project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly organization?: string
|
||||
readonly project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
const auth = (options: ProviderAuthOption<"optional">) => AuthOptions.bearer(options, "OPENAI_API_KEY")
|
||||
|
||||
@@ -116,19 +116,28 @@ export const configure = (input: Config = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
const config = (settings: Settings): Config => {
|
||||
const config = ({
|
||||
apiKey,
|
||||
baseURL,
|
||||
body,
|
||||
headers: given,
|
||||
organization,
|
||||
project,
|
||||
queryParams,
|
||||
...providerOptions
|
||||
}: Settings): Config => {
|
||||
const headers = {
|
||||
...(settings.organization === undefined ? {} : { "OpenAI-Organization": settings.organization }),
|
||||
...(settings.project === undefined ? {} : { "OpenAI-Project": settings.project }),
|
||||
...settings.headers,
|
||||
...(organization === undefined ? {} : { "OpenAI-Organization": organization }),
|
||||
...(project === undefined ? {} : { "OpenAI-Project": project }),
|
||||
...given,
|
||||
}
|
||||
return {
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: Object.keys(headers).length === 0 ? undefined : headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
queryParams: settings.queryParams === undefined ? undefined : { ...settings.queryParams },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
queryParams: queryParams === undefined ? undefined : { ...queryParams },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -77,11 +77,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenRouterProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenRouterProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
|
||||
Schema.Record(Schema.String, Schema.Any),
|
||||
@@ -191,12 +191,12 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
export const provider = configure()
|
||||
export const model: ProviderPackage.Definition<Settings, OpenRouterProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -15,11 +15,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
OpenAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export const route = Route.make({
|
||||
id: "togetherai-chat",
|
||||
@@ -52,11 +52,14 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers: headers === undefined ? undefined : { ...headers },
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
@@ -20,11 +20,11 @@ export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: XAIProviderOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
XAIProviderOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
|
||||
@@ -41,6 +41,10 @@ const responsesRoute = Route.make({
|
||||
id: "openai-responses",
|
||||
name: "xAI Responses",
|
||||
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
|
||||
// xAI continues a chain only from stored responses: with `store: false` (the route default) `previous_response_id`
|
||||
// fails with "Response with id=… not found", so those steps are sent in full over the reused connection. It also
|
||||
// rejects `instructions` next to `previous_response_id` and keeps the instructions of the response it continues.
|
||||
continuation: ({ instructions: _instructions, ...request }) => (request.store === false ? undefined : request),
|
||||
}),
|
||||
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
})
|
||||
@@ -106,13 +110,13 @@ export const model: ProviderPackage.Definition<
|
||||
Settings,
|
||||
XAIProviderOptionsInput,
|
||||
typeof responsesRoute.compact
|
||||
>["model"] = (modelID, settings) =>
|
||||
>["model"] = (modelID, { apiKey, baseURL, body, headers, ...providerOptions }) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
|
||||
@@ -23,11 +23,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ChatOptionsInput | MessagesOptionsInput | ResponsesOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings<Options = ChatOptionsInput> extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: Options
|
||||
}
|
||||
export type Settings<Options = ChatOptionsInput> = ProviderPackage.Settings &
|
||||
Options & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
const chatRoute = Route.make({
|
||||
id: "zai-coding-chat",
|
||||
@@ -80,13 +80,16 @@ export const chat = provider.chat
|
||||
export const messages = provider.messages
|
||||
export const responses = provider.responses
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as ZAICodingPlan from "./zai-coding-plan.js"
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.MessagesOptionsInput
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.MessagesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
ZAICodingPlan.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).messages(modelID)
|
||||
|
||||
@@ -5,12 +5,12 @@ export type Settings = ZAICodingPlan.Settings<ZAICodingPlan.ResponsesOptionsInpu
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ZAICodingPlan.ResponsesOptionsInput>["model"] = (
|
||||
modelID,
|
||||
settings,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
ZAICodingPlan.configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).responses(modelID)
|
||||
|
||||
@@ -17,11 +17,11 @@ export type Config = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
readonly providerOptions?: ChatOptionsInput
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ChatOptionsInput
|
||||
}
|
||||
export type Settings = ProviderPackage.Settings &
|
||||
ChatOptionsInput & {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
|
||||
export type { ZAIImageOptions } from "../protocols/zai-images.js"
|
||||
|
||||
@@ -70,13 +70,16 @@ export const provider = configure()
|
||||
export const image = provider.image
|
||||
export const chat = provider.chat
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (modelID, settings) =>
|
||||
export const model: ProviderPackage.Definition<Settings, ChatOptionsInput>["model"] = (
|
||||
modelID,
|
||||
{ apiKey, baseURL, body, headers, ...providerOptions },
|
||||
) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers,
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
apiKey,
|
||||
baseURL,
|
||||
headers,
|
||||
http: body === undefined ? undefined : { body: { ...body } },
|
||||
providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as ZAI from "./zai.js"
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
LanguageModel,
|
||||
LLMEvent,
|
||||
InvalidProviderOutputError,
|
||||
ProviderConfigurationError,
|
||||
ProviderID,
|
||||
mergeGenerationOptions,
|
||||
mergeHttpOptions,
|
||||
@@ -128,7 +129,10 @@ const makeRouteLanguageModel = <Options extends ProviderOptions, Compact extends
|
||||
const provider = route.provider ?? ("provider" in mapped ? mapped.provider : undefined)
|
||||
if (!provider) throw new Error(`Route.model(${route.id}) requires a provider`)
|
||||
if (!endpointBaseURL(route.endpoint))
|
||||
throw new Error(`Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`)
|
||||
throw new ProviderConfigurationError({
|
||||
provider: ProviderID.make(provider),
|
||||
message: `Route.model(${route.id}) requires an endpoint baseURL — configure it on the route first`,
|
||||
})
|
||||
return LanguageModel.make<Options, Compact>({
|
||||
...mapped,
|
||||
provider,
|
||||
|
||||
@@ -50,6 +50,19 @@ export class UnsupportedOperationError extends Schema.TaggedError<UnsupportedOpe
|
||||
route: Schema.optional(RouteID),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* Provider settings that are missing, conflicting, or unsupported, such as
|
||||
* Azure without `resourceName` or `baseURL`. Thrown synchronously while a
|
||||
* provider facade or package entrypoint configures a model, before any
|
||||
* request exists, so it is not an `AIError` reason.
|
||||
*/
|
||||
export class ProviderConfigurationError extends Schema.TaggedError<ProviderConfigurationError>(
|
||||
"AI.Error.ProviderConfiguration",
|
||||
)("ProviderConfiguration", {
|
||||
provider: ProviderID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export class NoRouteError extends Schema.TaggedError<NoRouteError>("AI.Error.NoRoute")("NoRoute", {
|
||||
...ReasonFields,
|
||||
route: RouteID,
|
||||
|
||||
@@ -23,7 +23,13 @@ export interface DispatchResult extends ToolSettlement {
|
||||
export const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<DispatchResult> => {
|
||||
const name = call.namespace === undefined ? call.name : `${call.namespace}.${call.name}`
|
||||
const tool = tools[name]
|
||||
if (!tool) return Effect.succeed(result(call, { type: "error", value: `Unknown tool: ${name}` }))
|
||||
if (!tool)
|
||||
return Effect.succeed(
|
||||
result(call, {
|
||||
type: "error",
|
||||
value: `No tool named "${name}" is currently available. Please use a tool from the available tool list.`,
|
||||
}),
|
||||
)
|
||||
if (!tool.execute)
|
||||
return Effect.succeed(result(call, { type: "error", value: `Tool has no execute handler: ${name}` }))
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ import { model } from "@opencode/ai/providers/openai"
|
||||
import { LLM } from "../src/index.js"
|
||||
import { Endpoint } from "../src/route/endpoint.js"
|
||||
|
||||
const configuration = (provider: string, message: string) =>
|
||||
expect.objectContaining({ _tag: "ProviderConfiguration", provider, message })
|
||||
|
||||
describe("provider package entrypoints", () => {
|
||||
test("semantic API aliases expose the same contract", async () => {
|
||||
const modules = await Promise.all([
|
||||
@@ -185,13 +188,13 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://provider.example.test/v1/",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" as const },
|
||||
reasoningEffort: "high" as const,
|
||||
}
|
||||
const deepinfra = DeepInfra.model("google/gemma-3-27b-it", settings)
|
||||
|
||||
expect(deepinfra.route.id).toBe("deepinfra-chat")
|
||||
expect(deepinfra.route.endpoint.baseURL).toBe("https://provider.example.test/v1/openai")
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual(settings.providerOptions)
|
||||
expect(deepinfra.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(deepinfra.route.defaults.headers).toEqual(settings.headers)
|
||||
expect(deepinfra.route.defaults.http?.body).toEqual(settings.body)
|
||||
})
|
||||
@@ -207,7 +210,7 @@ describe("provider package entrypoints", () => {
|
||||
apiKey: "fixture",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { custom: true },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
expect(selected.route.endpoint.baseURL).toBe(provider.baseURL({ accountId: "account" }))
|
||||
@@ -228,11 +231,11 @@ describe("provider package entrypoints", () => {
|
||||
}
|
||||
const openrouter = OpenRouter.model("anthropic/claude-sonnet-4", {
|
||||
...settings,
|
||||
providerOptions: { usage: true },
|
||||
usage: true,
|
||||
})
|
||||
const xai = XAI.model("grok-4", {
|
||||
...settings,
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
for (const selected of [openrouter, xai]) {
|
||||
@@ -266,7 +269,8 @@ describe("provider package entrypoints", () => {
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "low", store: true },
|
||||
reasoningEffort: "low",
|
||||
store: true,
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
@@ -292,7 +296,7 @@ describe("provider package entrypoints", () => {
|
||||
provider: "example",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { metadata: { user_id: "user_1" } },
|
||||
providerOptions: { effort: "low" },
|
||||
effort: "low",
|
||||
})
|
||||
|
||||
expect(String(selected.provider)).toBe("example")
|
||||
@@ -312,7 +316,7 @@ describe("provider package entrypoints", () => {
|
||||
const Anthropic = await import("@opencode/ai/providers/anthropic")
|
||||
const selected = Anthropic.model("claude-sonnet-4-6", {
|
||||
apiKey: "fixture",
|
||||
providerOptions: { thinking: { type: "adaptive" } },
|
||||
thinking: { type: "adaptive" },
|
||||
})
|
||||
|
||||
expect(selected.route.defaults.providerOptions).toEqual({ thinking: { type: "adaptive" } })
|
||||
@@ -322,7 +326,7 @@ describe("provider package entrypoints", () => {
|
||||
const AnthropicCompatible = await import("@opencode/ai/providers/anthropic-compatible")
|
||||
expect(() =>
|
||||
Reflect.apply(AnthropicCompatible.model, undefined, ["compatible-model", { apiKey: "fixture" }]),
|
||||
).toThrow("Anthropic-compatible providers require a baseURL")
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible providers require a baseURL"))
|
||||
})
|
||||
|
||||
test("rejects conflicting Anthropic-compatible auth settings at runtime", async () => {
|
||||
@@ -337,10 +341,10 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://messages.example.test/v1",
|
||||
},
|
||||
]),
|
||||
).toThrow("Anthropic-compatible apiKey cannot be combined with authToken")
|
||||
).toThrow(configuration("anthropic-compatible", "Anthropic-compatible apiKey cannot be combined with authToken"))
|
||||
expect(() =>
|
||||
Reflect.apply(Anthropic.model, undefined, ["claude-sonnet-4-6", { apiKey: "fixture", authToken: "token" }]),
|
||||
).toThrow("Anthropic apiKey cannot be combined with authToken")
|
||||
).toThrow(configuration("anthropic", "Anthropic apiKey cannot be combined with authToken"))
|
||||
})
|
||||
|
||||
test("maps legacy OpenAI organization and project settings to headers", () => {
|
||||
@@ -406,7 +410,7 @@ describe("provider package entrypoints", () => {
|
||||
baseURL: "https://generativelanguage.test/v1beta",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { safetySettings: [] },
|
||||
providerOptions: { thinkingConfig: { thinkingBudget: 1_024 } },
|
||||
thinkingConfig: { thinkingBudget: 1_024 },
|
||||
})
|
||||
|
||||
expect(selected.route.id).toBe("gemini")
|
||||
@@ -490,43 +494,45 @@ describe("provider package entrypoints", () => {
|
||||
"gemini-3.5-flash",
|
||||
{ accessToken: "token", apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex apiKey cannot be combined with accessToken or auth")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex apiKey cannot be combined with accessToken or auth"))
|
||||
const configured = Reflect.apply(GoogleVertex.configure, undefined, [
|
||||
{ accessToken: "token", auth: {}, project: "vertex-project" },
|
||||
])
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow("Google Vertex accessToken cannot be combined with auth")
|
||||
expect(() => configured.model("gemini-3.5-flash")).toThrow(
|
||||
configuration("google-vertex", "Google Vertex accessToken cannot be combined with auth"),
|
||||
)
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexMessages.model, undefined, [
|
||||
"claude-sonnet-4-6",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexMessages.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Messages does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Messages does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexChat.model, undefined, [
|
||||
"deepseek-ai/deepseek-v3.2-maas",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexChat.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Chat does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Chat does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(GoogleVertexResponses.model, undefined, [
|
||||
"xai/grok-4.20-reasoning",
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
expect(() =>
|
||||
Reflect.apply(Providers.GoogleVertexResponses.configure, undefined, [
|
||||
{ apiKey: "fixture", project: "vertex-project" },
|
||||
]),
|
||||
).toThrow("Google Vertex Responses does not support API keys")
|
||||
).toThrow(configuration("google-vertex", "Google Vertex Responses does not support API keys"))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -76,7 +76,13 @@ it.effect("Alibaba owns regional shared and workspace-specific endpoints", () =>
|
||||
|
||||
test("Alibaba requires explicit placement and supports complete base URL overrides", () => {
|
||||
for (const region of ["eu-central-1", "ap-northeast-1", "future-region"])
|
||||
expect(() => Alibaba.configure({ region })).toThrow("requires workspaceID or baseURL")
|
||||
expect(() => Alibaba.configure({ region })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "alibaba",
|
||||
message: `Alibaba region ${region} requires workspaceID or baseURL`,
|
||||
}),
|
||||
)
|
||||
for (const config of [
|
||||
{ baseURL: "https://gateway.example/prefix" },
|
||||
{ region: "future-region", workspaceID: "ignored", baseURL: "https://gateway.example/prefix" },
|
||||
|
||||
@@ -69,7 +69,9 @@ for (const model of [
|
||||
dynamicResponse(({ request, text, respond }) =>
|
||||
Effect.sync(() => {
|
||||
const body = JSON.parse(text)
|
||||
expect(request.headers["anthropic-beta"]).toBe("existing-beta,compact-2026-01-12")
|
||||
expect(request.headers["anthropic-beta"]).toBe(
|
||||
"existing-beta,interleaved-thinking-2025-05-14,compact-2026-01-12",
|
||||
)
|
||||
if (body.messages.length === 1) {
|
||||
expect(body.context_management.edits).toEqual([
|
||||
{
|
||||
|
||||
@@ -32,7 +32,9 @@ for (const [id, enabled] of [
|
||||
enabled ? { type: "adaptive", block_binding: { prefix_mismatch_behavior: "drop_block" } } : undefined,
|
||||
)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(
|
||||
enabled ? "existing-beta,thinking-binding-controls-2026-08-01" : "existing-beta",
|
||||
enabled
|
||||
? "existing-beta,interleaved-thinking-2025-05-14,thinking-binding-controls-2026-08-01"
|
||||
: "existing-beta,interleaved-thinking-2025-05-14",
|
||||
)
|
||||
}),
|
||||
)
|
||||
@@ -53,7 +55,9 @@ it.effect("preserves explicit thinking settings and combines required beta heade
|
||||
const prepared = yield* AnthropicMessages.route.prepareTransport(compiled.body, request)
|
||||
expect(compiled.body.thinking).toEqual(thinking)
|
||||
expect(prepared.request.headers["anthropic-beta"]).toBe(
|
||||
thinking.type === "disabled" ? "compact-2026-01-12" : "compact-2026-01-12,thinking-binding-controls-2026-08-01",
|
||||
thinking.type === "disabled"
|
||||
? "interleaved-thinking-2025-05-14,compact-2026-01-12"
|
||||
: "interleaved-thinking-2025-05-14,compact-2026-01-12,thinking-binding-controls-2026-08-01",
|
||||
)
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -279,6 +279,52 @@ describe("Bedrock Converse route", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
;["", " \t\r\n"].forEach((description) => {
|
||||
it.effect(`omits blank tool description ${JSON.stringify(description)}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description,
|
||||
inputSchema: { type: "object", properties: { query: { type: "string" } } },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig.tools).toEqual([
|
||||
{
|
||||
toolSpec: {
|
||||
name: "lookup",
|
||||
inputSchema: { json: { type: "object", properties: { query: { type: "string" } } } },
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(prepared.body.toolConfig.tools[0].toolSpec).not.toHaveProperty("description")
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("preserves meaningful tool descriptions including surrounding whitespace", () =>
|
||||
Effect.gen(function* () {
|
||||
const description = " \tLookup data.\n"
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(baseRequest, {
|
||||
tools: [
|
||||
ToolDefinition.make({
|
||||
name: "lookup",
|
||||
description,
|
||||
inputSchema: { type: "object" },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.toolConfig.tools[0].toolSpec.description).toBe(description)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps tools and omits the unsupported choice when tool choice is none", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -1458,7 +1504,13 @@ describe("Bedrock Converse route", () => {
|
||||
expect(headers.get("authorization")).toContain("Credential=AKIACHAINEXAMPLE/")
|
||||
expect(headers.get("authorization")).toContain("/ap-southeast-2/bedrock/aws4_request")
|
||||
}
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow("does not accept apiKey")
|
||||
expect(() => AmazonBedrock.configure({ auth: "sigv4", apiKey: "k" })).toThrow(
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "amazon-bedrock",
|
||||
message: "Amazon Bedrock SigV4 auth does not accept apiKey",
|
||||
}),
|
||||
)
|
||||
}).pipe(
|
||||
withProcessEnv({
|
||||
...noAmbientAWS,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message } from "../../src/index.js"
|
||||
import { AmazonBedrockMantle } from "../../src/providers.js"
|
||||
import { model } from "../../src/providers/amazon-bedrock/mantle.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { compileRequest, LLMClient } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { withProcessEnv } from "../lib/env.js"
|
||||
@@ -25,7 +25,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
expect(provider.model).toBe(provider.responses)
|
||||
expect(AmazonBedrockMantle.model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(model).toBe(AmazonBedrockMantle.responsesModel)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenAIResponses.httpTransport)
|
||||
expect(provider.model("openai.gpt-oss-120b").route.transport).toBe(OpenResponses.httpTransport)
|
||||
const chat = yield* compileRequest(LLM.request({ model: provider.chat("openai.gpt-oss-120b"), prompt: "Hi" }))
|
||||
const responses = yield* compileRequest(
|
||||
LLM.request({ model: provider.model("openai.gpt-oss-120b"), prompt: "Hi" }),
|
||||
@@ -38,7 +38,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
})
|
||||
expect(responses).toMatchObject({
|
||||
route: "bedrock-mantle-responses",
|
||||
protocol: "openai-responses",
|
||||
protocol: "open-responses",
|
||||
body: { model: "openai.gpt-oss-120b", store: false },
|
||||
})
|
||||
expect(provider.model("openai.gpt-oss-120b").route.providerMetadataKey).toBe("mantle")
|
||||
@@ -178,7 +178,7 @@ describe("Amazon Bedrock Mantle provider", () => {
|
||||
const recorded = recordedTests({
|
||||
prefix: "bedrock-mantle",
|
||||
provider: "amazon-bedrock",
|
||||
protocol: "openai-responses",
|
||||
protocol: "open-responses",
|
||||
requires: ["AWS_BEARER_TOKEN_BEDROCK"],
|
||||
metadata: { model: "openai.gpt-oss-120b" },
|
||||
})
|
||||
|
||||
@@ -378,7 +378,11 @@ describe("Google Vertex providers", () => {
|
||||
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
"Google Vertex tuned models do not support Express Mode API keys",
|
||||
expect.objectContaining({
|
||||
_tag: "ProviderConfiguration",
|
||||
provider: "google-vertex",
|
||||
message: "Google Vertex tuned models do not support Express Mode API keys",
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -34,12 +34,10 @@ it.effect("Groq lowers its own options for custom catalog identities and endpoin
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-client": "test" },
|
||||
body: { custom: "value" },
|
||||
providerOptions: {
|
||||
reasoningEffort: "default",
|
||||
parallelToolCalls: true,
|
||||
serviceTier: "flex",
|
||||
user: "test-user",
|
||||
},
|
||||
reasoningEffort: "default",
|
||||
parallelToolCalls: true,
|
||||
serviceTier: "flex",
|
||||
user: "test-user",
|
||||
}),
|
||||
{ provider: "custom-groq" },
|
||||
)
|
||||
|
||||
@@ -87,7 +87,7 @@ it.effect("Meta package selectors preserve overrides and Chat token policy on cu
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-client": "test" },
|
||||
body: { custom: "value" },
|
||||
providerOptions: { reasoningEffort: "future-effort" },
|
||||
reasoningEffort: "future-effort",
|
||||
})
|
||||
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
const settings = {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
}
|
||||
const selected = provider.configure(settings).model("test-model")
|
||||
expect(selected.provider).toBe(provider.id)
|
||||
@@ -143,7 +143,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
tools: [
|
||||
ToolDefinition.make({ name: "lookup", description: "Look up data", inputSchema: { type: "object" } }),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
store: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
]),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
providerOptions: { store: true },
|
||||
store: true,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("native OpenAI-compatible providers", () => {
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-application": "opencode" },
|
||||
body: { service_tier: "priority" },
|
||||
providerOptions: { reasoningEffort: "high" },
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
|
||||
expect(selected.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
|
||||
|
||||
it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = {
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
code: "server_shutting_down",
|
||||
message: "Server is shutting down. Please retry your request.",
|
||||
param: null,
|
||||
}
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
const event = yield* decode(JSON.stringify(frame))
|
||||
expect(event).toEqual({
|
||||
type: "error",
|
||||
sequence_number: 4,
|
||||
error: { code: frame.code, message: frame.message, param: null },
|
||||
})
|
||||
|
||||
for (const unchanged of [
|
||||
event,
|
||||
{ type: "error" },
|
||||
{
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { code: "server_error", message: "Internal server error" } },
|
||||
},
|
||||
{ type: "response.output_text.delta", item_id: "msg_text", delta: "Hello" },
|
||||
]) {
|
||||
expect(yield* decode(JSON.stringify(unchanged))).toEqual(unchanged)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
"type": "error",
|
||||
"sequence_number": 4,
|
||||
"code": "server_shutting_down",
|
||||
"message": "Server is shutting down. Please retry your request.",
|
||||
"param": null,
|
||||
"diagnostic": "retain-original-frame"
|
||||
}`
|
||||
for (const model of [
|
||||
Meta.configure({ apiKey: "fixture" }).responses("muse-spark-1.3"),
|
||||
configure({ apiKey: "fixture", provider: "gateway", baseURL: "https://responses.example.test/v1" }).model(
|
||||
"example-model",
|
||||
),
|
||||
]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw.replaceAll("\n", "\ndata: ")))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toBe("server_shutting_down: Server is shutting down. Please retry your request.")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -90,7 +90,11 @@ const classifyingChannelDriver = (message: string): WebSocketChannelDriver => {
|
||||
}
|
||||
}
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>, base = baseChannelDriver) => {
|
||||
const continuationDriver = (
|
||||
request: Readonly<Record<string, unknown>>,
|
||||
base = baseChannelDriver,
|
||||
continuation?: OpenResponsesContinuation.Shape,
|
||||
) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenResponsesContinuation.driver({
|
||||
id: "openai-responses",
|
||||
@@ -98,6 +102,7 @@ const continuationDriver = (request: Readonly<Record<string, unknown>>, base = b
|
||||
request,
|
||||
message,
|
||||
base: base(message),
|
||||
continuation,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -581,52 +586,54 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a streamed tool call with only the new tool output", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
|
||||
}
|
||||
const first = continuationDriver(firstRequest)
|
||||
const firstCreate = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "fc_1",
|
||||
status: "completed",
|
||||
call_id: "call_1",
|
||||
name: "weather",
|
||||
arguments: '{ "city": "Paris" }',
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
firstCreate,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const second = continuationDriver({
|
||||
...firstRequest,
|
||||
input: [
|
||||
...firstRequest.input,
|
||||
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
|
||||
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
|
||||
],
|
||||
})
|
||||
|
||||
const create = yield* second.create(saved)
|
||||
const create = yield* second.create(saved)
|
||||
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
expect(create.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("continues a tool call from authoritative completed response output", () =>
|
||||
@@ -680,45 +687,47 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
it.effect("continues a promoted steer after assistant output with response-only text metadata", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
Effect.forEach([undefined, []], (output) =>
|
||||
Effect.gen(function* () {
|
||||
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
|
||||
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
|
||||
const create = yield* first.create(undefined)
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "message",
|
||||
id: "msg_1",
|
||||
status: "completed",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Hello", annotations: [], logprobs: [] }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
const saved = checkpoint(
|
||||
yield* first.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1", output } }),
|
||||
),
|
||||
)
|
||||
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
|
||||
const next = continuationDriver({
|
||||
type: "response.create",
|
||||
model: "gpt-5.2",
|
||||
store: false,
|
||||
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
|
||||
})
|
||||
|
||||
const continued = yield* next.create(saved)
|
||||
const continued = yield* next.create(saved)
|
||||
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
expect(continued.mode).toBe("incremental")
|
||||
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
|
||||
previous_response_id: "resp_1",
|
||||
input: [steer],
|
||||
})
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("continues streamed reasoning when completion re-encrypts the same item", () =>
|
||||
@@ -917,6 +926,58 @@ describe("OpenAI Responses route", () => {
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "InvalidRequest", classification: "context-overflow" } },
|
||||
})
|
||||
|
||||
// A retryable failure stays one: the runner retries it, and the transport has already dropped the
|
||||
// checkpoint, so that retry is a full send. xAI reports every rejection this way.
|
||||
const internal = ProviderShared.encodeJson({
|
||||
type: "error",
|
||||
error: { type: "api_error", message: "gRPC error: Response with id=resp_1 not found" },
|
||||
})
|
||||
expect(yield* second.observe(yield* second.create(saved), internal)).toMatchObject({
|
||||
type: "provider-failure",
|
||||
error: { reason: { _tag: "ProviderInternal" } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("shapes the incremental send with the route continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstRequest = {
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
instructions: "You are terse.",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
|
||||
}
|
||||
const secondRequest = {
|
||||
...firstRequest,
|
||||
input: [...firstRequest.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
}
|
||||
const saved = checkpoint(
|
||||
yield* continuationDriver(firstRequest).observe(
|
||||
yield* continuationDriver(firstRequest).create(undefined),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
|
||||
),
|
||||
)
|
||||
|
||||
const trimmed = yield* continuationDriver(
|
||||
secondRequest,
|
||||
baseChannelDriver,
|
||||
({ instructions: _, ...rest }) => rest,
|
||||
).create(saved)
|
||||
expect(trimmed.mode).toBe("incremental")
|
||||
expect(JSON.parse(trimmed.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// Declining the continuation sends the step in full and never sends a previous_response_id.
|
||||
const declined = yield* continuationDriver(secondRequest, baseChannelDriver, () => undefined).create(saved)
|
||||
expect(declined.mode).toBe("full")
|
||||
expect(JSON.parse(declined.message)).toEqual(secondRequest)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { LLM, LLMEvent, Message } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { XAIResponses } from "../../src/protocols/xai-responses.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import {
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelDriver,
|
||||
} from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -13,6 +20,35 @@ import { sseEvents } from "../lib/sse.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.6")
|
||||
|
||||
/** Runs a request through the WebSocket transport and hands back its channel driver; the HTTP fallback answers. */
|
||||
const channelDriver = (request: ReturnType<typeof LLM.request>) =>
|
||||
Effect.gen(function* () {
|
||||
let driver: WebSocketChannelDriver | undefined
|
||||
yield* LLMClient.generate(request, {
|
||||
webSocket: {
|
||||
execute: (exchange) =>
|
||||
Effect.sync(() => {
|
||||
driver = exchange.driver
|
||||
return { frames: exchange.fallback(), complete: Effect.void }
|
||||
}),
|
||||
},
|
||||
}).pipe(Effect.provide(fixedResponse(sseEvents({ type: "response.completed", response: { id: "http" } }))))
|
||||
if (!driver) throw new Error("Expected a WebSocket channel driver")
|
||||
return driver
|
||||
})
|
||||
|
||||
const completed = (driver: WebSocketChannelDriver, id: string) =>
|
||||
Effect.gen(function* () {
|
||||
const create = yield* driver.create(undefined)
|
||||
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.created", response: { id } }))
|
||||
const observation = yield* driver.observe(
|
||||
create,
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id } }),
|
||||
)
|
||||
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected a checkpoint")
|
||||
return observation.checkpoint
|
||||
})
|
||||
|
||||
describe("xAI Responses route", () => {
|
||||
it.effect("composes the Open Responses baseline with xAI extensions", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -162,6 +198,78 @@ describe("xAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies xAI's untyped WebSocket error envelope", () =>
|
||||
Effect.gen(function* () {
|
||||
// xAI answers a rejected response.create with an error envelope that carries no event type.
|
||||
const envelope = ProviderShared.encodeJson({
|
||||
error: {
|
||||
message:
|
||||
'Request validation error: {"code":"400","error":"Argument not supported: instructions and previous_response_id together"}',
|
||||
type: "api_error",
|
||||
},
|
||||
})
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(envelope), close: Effect.void }),
|
||||
})
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), { webSocket }).pipe(
|
||||
Effect.provide(
|
||||
LLMClient.layer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason._tag).toBe("ProviderInternal")
|
||||
expect(error.message).toContain("Argument not supported: instructions and previous_response_id together")
|
||||
expect(error.reason.body).toBe(envelope)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues stored responses without instructions and sends unstored steps in full", () =>
|
||||
Effect.gen(function* () {
|
||||
const step = (store: boolean, ...prompts: string[]) =>
|
||||
LLM.request({
|
||||
model,
|
||||
system: "You are terse.",
|
||||
messages: prompts.map((prompt) => Message.user(prompt)),
|
||||
providerOptions: { store },
|
||||
})
|
||||
const send = (store: boolean, checkpoint: ChannelCheckpoint) =>
|
||||
channelDriver(step(store, "First", "Second")).pipe(Effect.flatMap((driver) => driver.create(checkpoint)))
|
||||
|
||||
const stored = yield* send(true, yield* completed(yield* channelDriver(step(true, "First")), "resp_1"))
|
||||
expect(stored.mode).toBe("incremental")
|
||||
expect(JSON.parse(stored.message)).toEqual({
|
||||
type: "response.create",
|
||||
model: "grok-4.6",
|
||||
store: true,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
previous_response_id: "resp_1",
|
||||
input: [{ role: "user", content: [{ type: "input_text", text: "Second" }] }],
|
||||
})
|
||||
|
||||
// The connection cache only serves stored responses, so the default store: false never chains.
|
||||
const unstored = yield* send(false, yield* completed(yield* channelDriver(step(false, "First")), "resp_1"))
|
||||
expect(unstored.mode).toBe("full")
|
||||
expect(JSON.parse(unstored.message)).toMatchObject({
|
||||
instructions: "You are terse.",
|
||||
store: false,
|
||||
input: [
|
||||
{ role: "user", content: [{ type: "input_text", text: "First" }] },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Second" }] },
|
||||
],
|
||||
})
|
||||
expect(JSON.parse(unstored.message).previous_response_id).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses xAI hosted tool items", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "x_search_call", id: "x_search_1", status: "completed", action: { query: "news" } }
|
||||
|
||||
@@ -237,13 +237,16 @@ describe("LLMClient tools", () => {
|
||||
LLMEvent.toolError({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
message: "Unknown tool: missing",
|
||||
message: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
|
||||
providerMetadata,
|
||||
}),
|
||||
LLMEvent.toolResult({
|
||||
id: "call_2",
|
||||
name: "missing",
|
||||
result: { type: "error", value: "Unknown tool: missing" },
|
||||
result: {
|
||||
type: "error",
|
||||
value: 'No tool named "missing" is currently available. Please use a tool from the available tool list.',
|
||||
},
|
||||
providerMetadata,
|
||||
}),
|
||||
])
|
||||
@@ -712,12 +715,17 @@ describe("LLMClient tools", () => {
|
||||
|
||||
const toolError = events.find(LLMEvent.is.toolError)
|
||||
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
|
||||
expect(toolError?.message).toContain("Unknown tool")
|
||||
expect(toolError?.message).toBe(
|
||||
'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
|
||||
)
|
||||
expect(events.find(LLMEvent.is.toolResult)).toMatchObject({
|
||||
type: "tool-result",
|
||||
id: "call_1",
|
||||
name: "missing_tool",
|
||||
result: { type: "error", value: "Unknown tool: missing_tool" },
|
||||
result: {
|
||||
type: "error",
|
||||
value: 'No tool named "missing_tool" is currently available. Please use a tool from the available tool list.',
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -19,11 +19,13 @@ story("cancelling a version mismatch permits reconnecting again", async ({ mount
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toBeVisible()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
@@ -43,6 +45,17 @@ story("adding a server keeps all SSH challenges in the original connection dialo
|
||||
await expect(dialog).toHaveCount(0)
|
||||
})
|
||||
|
||||
story("adding an incompatible server advances to a dedicated update step", async ({ mount, page }) => {
|
||||
await mount("app-dialog-ssh--incompatible-host")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("textbox", { name: "Host or SSH command" }).fill("ssh devbox")
|
||||
await dialog.getByRole("button", { name: "Add server", exact: true }).click()
|
||||
await expect(dialog.getByRole("status")).toContainText("Server update required")
|
||||
await expect(dialog.getByRole("textbox")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("alert")).toHaveCount(0)
|
||||
await expect(dialog.getByRole("button", { name: "Update and reconnect", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("updating an incompatible connection continues authentication in the same dialog", async ({ mount, page }) => {
|
||||
const component = await mount("app-dialog-ssh--incompatible-session")
|
||||
await component.getByRole("button", { name: "Reconnect", exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture, pageMessages } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("loads home and the directory picker without newer browser APIs", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages,
|
||||
fileList: () => [],
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
// Safari 16.6 has neither API. Remove them before the web entry runs.
|
||||
Reflect.deleteProperty(Map, "groupBy")
|
||||
Reflect.deleteProperty(Promise, "withResolvers")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator('[data-component="home-session-row"]').filter({ hasText: fixture.expected.targetTitle })
|
||||
await expect(row).toBeVisible()
|
||||
|
||||
await page.getByRole("button", { name: "Add project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("button", { name: "Select folder", exact: true })).toBeEnabled()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(row).toBeVisible()
|
||||
})
|
||||
@@ -59,8 +59,9 @@ for (const shared of [true, false]) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
await page.keyboard.press("ControlOrMeta+;")
|
||||
const dialog = page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(dialog.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
const toggle = dialog.getByRole("switch")
|
||||
await expect(toggle).not.toBeChecked()
|
||||
@@ -92,7 +93,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const state = { fail: true, status: surface === "popover" ? "failed" : "disabled" }
|
||||
const requests: { path: string; directory: string }[] = []
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { showStatus: true } }))
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ keybinds: { "mcp.toggle": "ctrl+;" } }))
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -113,6 +114,10 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
requests.push({ path: url.pathname, directory: target })
|
||||
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
|
||||
state.status = "disabled"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/figma-desktop/connect") {
|
||||
state.status = state.fail ? "failed" : "connected"
|
||||
// Connection failures are reported by the refreshed status, not the HTTP response.
|
||||
@@ -137,12 +142,19 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.getByRole("textbox", { name: "Prompt", exact: true })).toBeEditable()
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
const panel =
|
||||
surface === "popover" ? page.getByRole("tabpanel") : page.getByRole("dialog", { name: "MCPs", exact: true })
|
||||
if (surface === "popover") {
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
const panel = page.getByRole("dialog", { name: surface === "popover" ? "MCP" : "MCPs", exact: true })
|
||||
const toggle = panel.getByRole("switch")
|
||||
await expect(panel.getByText("figma-desktop", { exact: true })).toBeVisible()
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await expect(toggle).toBeChecked()
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
}
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
requests.length = 0
|
||||
@@ -152,7 +164,7 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
.getByRole("listitem", { includeHidden: true })
|
||||
.filter({ has: page.getByText("Request failed", { exact: true }) })
|
||||
await expect(toast.getByText(`figma-desktop: ${error}`, { exact: true })).toBeVisible()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeChecked({ checked: surface === "popover" })
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
|
||||
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
|
||||
@@ -167,9 +179,18 @@ for (const surface of ["popover", "dialog"] as const) {
|
||||
await toast.getByRole("button", { name: "Dismiss", exact: true }).click()
|
||||
await expect(toast).toBeHidden()
|
||||
state.fail = false
|
||||
if (surface === "popover") await page.getByRole("button", { name: "Status", exact: true }).click()
|
||||
if (surface === "dialog") await page.keyboard.press("ControlOrMeta+;")
|
||||
if (surface === "popover") {
|
||||
await expect(page.getByRole("dialog", { name: "Session details", exact: true })).toBeHidden()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
}
|
||||
if (surface === "dialog") await page.keyboard.press("Control+;")
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (surface === "popover") {
|
||||
await panel.getByText("figma-desktop", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
}
|
||||
await panel.locator('[data-slot="switch-control"]').click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
|
||||
@@ -104,18 +104,12 @@ for (const position of ["top", "bottom"] as const) {
|
||||
await expect(composer).toBeHidden()
|
||||
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
const status = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
await expect(status.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await status.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(status).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
await more.click()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
const details = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(details.getByText(fixture.project.name, { exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "No changes", exact: true })).toBeVisible()
|
||||
await expect(details.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await details.getByRole("button", { name: "Close", exact: true }).click()
|
||||
await expect(details).toBeHidden()
|
||||
await expect(more).toBeFocused()
|
||||
|
||||
+5
-4
@@ -2,20 +2,21 @@ import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("status drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
test("summary drawer dismisses and reopens after button, backdrop, Escape, and drag", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mockStressTimeline(page)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const more = page
|
||||
.locator('[data-slot="session-mobile-view-navigation"]')
|
||||
.getByRole("button", { name: "More options", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Status", exact: true })
|
||||
const drawer = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const overlay = page.locator('[data-slot="mobile-drawer-overlay"]')
|
||||
|
||||
for (const dismissal of ["button", "backdrop", "escape", "drag", "button"] as const) {
|
||||
await more.click()
|
||||
await page.getByRole("menuitem", { name: "Status", exact: true }).click()
|
||||
await expect(drawer.getByRole("tab", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("menuitem", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await page.getByRole("menuitem", { name: "Session details", exact: true }).click()
|
||||
await expect(drawer.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
// Corvu starts opening after paint; the transition flag is also absent
|
||||
// before that callback. Wait for the open position before dismissing.
|
||||
await expect
|
||||
@@ -0,0 +1,473 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer, currentSession } from "../utils/mock-server"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
const directory = "/workspace/summary-project"
|
||||
const workspace = "/workspace/existing-worktree"
|
||||
const createdWorkspace = "/workspace/created-worktree"
|
||||
const draftID = "draft_summary"
|
||||
const secondDraftID = "draft_summary_other"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const draftPath = `/new-session?draftId=${draftID}`
|
||||
|
||||
for (const rtl of [false, true]) {
|
||||
test(`new session summary shows project extensions and follows workspace selection in ${rtl ? "rtl" : "ltr"}`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const mock = await openDraft(page, "main", { direction: rtl ? "rtl" : "ltr" })
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", rtl ? "rtl" : "ltr")
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const button = await trigger.boundingBox()
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
if (!button || !view) return false
|
||||
const gap = rtl ? button.x - view.x : view.x + view.width - button.x - button.width
|
||||
return Math.abs(gap - 12) <= 1 && Math.abs(button.y + button.height / 2 - view.y - 24) <= 1
|
||||
})
|
||||
.toBe(true)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "summary-project", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveAttribute("aria-expanded", "true")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const view = await page.locator('[data-component="new-session"]').boundingBox()
|
||||
const project = await summary.locator('[data-section="project"]').boundingBox()
|
||||
const server = await summary.locator('[data-section="server"]').boundingBox()
|
||||
if (!view || !project || !server) return
|
||||
return {
|
||||
top: project.y - view.y - 48,
|
||||
cards: server.y - project.y - project.height,
|
||||
}
|
||||
})
|
||||
.toEqual({ top: 6, cards: 8 })
|
||||
await testInfo.attach(`new-session-summary-${rtl ? "rtl" : "ltr"}`, {
|
||||
body: await page.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
for (const [name, item] of [
|
||||
["MCP", "summary-mcp"],
|
||||
["Plugins", "project-plugin"],
|
||||
["Skills", "summary-skill"],
|
||||
["LSP", "typescript"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(item, { exact: true })).toBeVisible()
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
const worktreeMenu = page.getByRole("menu", { name: "Local repository", exact: true })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const menu = await worktreeMenu.boundingBox()
|
||||
const panel = await summary.locator('[data-component="session-summary-panel"]').boundingBox()
|
||||
if (!menu || !panel) return false
|
||||
return rtl ? menu.x >= panel.x + panel.width : menu.x + menu.width <= panel.x
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "existing-worktree", exact: true })).toBeHidden()
|
||||
await worktreeMenu.getByRole("menuitem", { name: "Worktree", exact: true }).press(rtl ? "ArrowLeft" : "ArrowRight")
|
||||
await expect(page.getByRole("menu", { name: "Worktree", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(summary.getByRole("button", { name: "existing-worktree", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const mcp = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = mcp.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await mcp.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory: workspace, enabled: true }])
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("workspace-plugin", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("non-Git folders show their status without offering worktree actions", async ({ page }) => {
|
||||
await openDraft(page, "main", { git: false })
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByText("No Git", { exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Local repository", exact: true })).toHaveCount(0)
|
||||
await expect(summary.getByRole("button", { name: "New worktree", exact: true })).toHaveCount(0)
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "MCP", exact: true }).getByRole("switch", { name: "summary-mcp", exact: true }),
|
||||
).toBeEnabled()
|
||||
})
|
||||
|
||||
test("new worktree MCP choices persist per draft and apply before the first prompt", async ({ page }, testInfo) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
await page.locator('[data-component="composer-editor"]').fill("Use my selected MCPs")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.locator('[data-slot="mcp-preview-hint"]')).toHaveText("Applies when the worktree is created")
|
||||
await expect(menu).toHaveCSS("opacity", "1")
|
||||
await testInfo.attach("new-worktree-mcp-preview", { body: await page.screenshot(), contentType: "image/png" })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs") ?? "[]").find(
|
||||
(tab: { draftID?: string }) => tab.draftID === "draft_summary",
|
||||
)?.mcp?.states,
|
||||
),
|
||||
)
|
||||
.toEqual({ "summary-mcp": false })
|
||||
|
||||
await page.goto(`/new-session?draftId=${secondDraftID}`)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.goto(draftPath)
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
expect(mock.calls).toEqual([])
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Use my selected MCPs")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "worktree", directory },
|
||||
{ type: "mcp", directory: createdWorkspace, enabled: false },
|
||||
{ type: "session", directory: createdWorkspace },
|
||||
{ type: "prompt", directory: createdWorkspace },
|
||||
])
|
||||
expect(mock.prompts[0].body.text).toBe("Use my selected MCPs")
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
})
|
||||
|
||||
test("the first prompt waits for a live MCP toggle", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for the MCP update")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
try {
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
expect(mock.calls).toEqual([{ type: "mcp", directory, enabled: false }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls).toEqual([
|
||||
{ type: "mcp", directory, enabled: false },
|
||||
{ type: "session", directory },
|
||||
{ type: "prompt", directory },
|
||||
])
|
||||
})
|
||||
|
||||
test("changing worktrees does not wait for another directory's pending MCP update", async ({ page }) => {
|
||||
const mock = await openDraft(page)
|
||||
const release = Promise.withResolvers<void>()
|
||||
mock.state.hold = release.promise
|
||||
mock.state.holdDirectory = directory
|
||||
await page.locator('[data-component="composer-editor"]').fill("Run in the selected worktree")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
try {
|
||||
await expect(menu.getByRole("switch", { name: "summary-mcp", exact: true })).toBeEnabled()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect.poll(() => mock.calls.length).toBe(1)
|
||||
await page.keyboard.press("Escape")
|
||||
await summary.getByRole("button", { name: "Local repository", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Worktree", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "existing-worktree", exact: true }).click()
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toBeEnabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.find((call) => call.type === "session")?.directory).toBe(workspace)
|
||||
expect(mock.status.get(directory)).toBe("connected")
|
||||
} finally {
|
||||
release.resolve()
|
||||
}
|
||||
})
|
||||
|
||||
test("failed MCP preparation restores the draft and reuses the created worktree", async ({ page }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.state.fail = true
|
||||
await page.locator('[data-component="composer-editor"]').fill("Keep this prompt on failure")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
await page.getByRole("dialog", { name: "MCP", exact: true }).getByText("summary-mcp", { exact: true }).click()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(page.getByText("Request failed", { exact: true })).toBeVisible()
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Keep this prompt on failure")
|
||||
await expect(page.getByRole("button", { name: "created-worktree", exact: true })).toBeVisible()
|
||||
expect(mock.prompts).toEqual([])
|
||||
expect(mock.calls.map((call) => call.type)).toEqual(["worktree", "mcp"])
|
||||
mock.state.fail = false
|
||||
await expect(page.locator('[data-action="composer-submit"]')).toBeEnabled()
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(mock.status.get(createdWorkspace)).toBe("disabled")
|
||||
expect(mock.prompts[0].body.text).toBe("Keep this prompt on failure")
|
||||
})
|
||||
|
||||
test("new worktree sign-in completes before the draft can send", async ({ page, context }) => {
|
||||
const mock = await openDraft(page, "create")
|
||||
mock.status.set(createdWorkspace, "needs_auth")
|
||||
const attempts: string[] = []
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
return route.fulfill({
|
||||
json: { location: { directory: createdWorkspace }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: createdWorkspace },
|
||||
data: {
|
||||
id: "summary-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.locator('[data-component="composer-editor"]').fill("Wait for my sign-in")
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = menu.getByRole("switch", { name: "summary-mcp", exact: true })
|
||||
await expect(toggle).toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await menu.getByText("summary-mcp", { exact: true }).click()
|
||||
await expect(toggle).toBeChecked()
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Escape")
|
||||
const popup = page.waitForEvent("popup")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await expect(page).toHaveURL(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText("Wait for my sign-in")
|
||||
expect(attempts).toEqual([createdWorkspace])
|
||||
expect(mock.prompts).toEqual([])
|
||||
mock.status.set(createdWorkspace, "connected")
|
||||
await page.locator('[data-action="composer-submit"]').click()
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.calls.filter((call) => call.type === "worktree")).toHaveLength(1)
|
||||
expect(attempts).toHaveLength(1)
|
||||
})
|
||||
|
||||
async function openDraft(page: Page, worktree = "main", options: { git?: boolean; direction?: "ltr" | "rtl" } = {}) {
|
||||
const project = {
|
||||
id: "proj_new_summary",
|
||||
worktree: directory,
|
||||
name: "summary-project",
|
||||
vcs: options.git === false ? undefined : "git",
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [workspace],
|
||||
}
|
||||
const sessions: ReturnType<typeof currentSession>[] = []
|
||||
const status = new Map<string, string>([
|
||||
[directory, "connected"],
|
||||
[workspace, "disabled"],
|
||||
[createdWorkspace, "connected"],
|
||||
])
|
||||
const calls: { type: string; directory: string; enabled?: boolean }[] = []
|
||||
const prompts: { sessionID: string; body: Record<string, unknown> }[] = []
|
||||
const state: { fail: boolean; hold?: Promise<void>; holdDirectory?: string } = { fail: false }
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
sessions,
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { "summary-model": { id: "summary-model", name: "Summary Model", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "summary-model" },
|
||||
},
|
||||
pageMessages: () => ({ items: [] }),
|
||||
onPrompt(input) {
|
||||
const session = sessions.find((session) => session.id === input.sessionID)
|
||||
if (!session?.location.directory) throw new Error("Prompt arrived before session creation")
|
||||
calls.push({ type: "prompt", directory: session.location.directory })
|
||||
prompts.push(input)
|
||||
},
|
||||
})
|
||||
if (options.git === false) {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/vcs",
|
||||
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
|
||||
)
|
||||
}
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const target = url.searchParams.get("location[directory]") ?? directory
|
||||
if (route.request().method() === "POST") {
|
||||
const enabled = url.pathname.endsWith("/connect")
|
||||
calls.push({ type: "mcp", directory: target, enabled })
|
||||
if (!state.holdDirectory || state.holdDirectory === target) await state.hold
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "MCP fixture failed" } })
|
||||
status.set(target, enabled ? "connected" : "disabled")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{
|
||||
name: "summary-mcp",
|
||||
integrationID: "summary-oauth",
|
||||
status: { status: status.get(target) ?? "connected" },
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/location",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory,
|
||||
project: { id: project.id, directory, canonical: directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
(route) => {
|
||||
const target = new URL(route.request().url()).searchParams.get("location[directory]") ?? directory
|
||||
const id = target === directory ? "project-plugin" : "workspace-plugin"
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: target },
|
||||
data: [{ id, source: { type: "package", target: id }, features: {}, state: { status: "active" } }],
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/skill",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
location: { directory: new URL(route.request().url()).searchParams.get("location[directory]") ?? directory },
|
||||
data: [
|
||||
{
|
||||
id: "summary-skill",
|
||||
name: "summary-skill",
|
||||
location: "/skills/summary/SKILL.md",
|
||||
content: "Summary skill",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp: { typescript: { command: ["typescript-language-server"] } } } }],
|
||||
}),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
calls.push({ type: "worktree", directory })
|
||||
project.sandboxes.push(createdWorkspace)
|
||||
return route.fulfill({ json: { directory: createdWorkspace } })
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session",
|
||||
(route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
const body: { id: string; location: { directory: string } } = route.request().postDataJSON()
|
||||
calls.push({ type: "session", directory: body.location.directory })
|
||||
const session = currentSession(
|
||||
{ ...body, projectID: project.id, title: "Created summary session" },
|
||||
body.location.directory,
|
||||
)
|
||||
sessions.push(session)
|
||||
return route.fulfill({ json: { data: session } })
|
||||
},
|
||||
)
|
||||
await page.addInitScript(
|
||||
({ directory, server, draftID, secondDraftID, worktree }) => {
|
||||
if (!localStorage.getItem("opencode.global.dat:server"))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
if (!localStorage.getItem("opencode.window.browser.dat:tabs"))
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([
|
||||
{ type: "draft", draftID, server, directory, worktree },
|
||||
{ type: "draft", draftID: secondDraftID, server, directory, worktree },
|
||||
]),
|
||||
)
|
||||
},
|
||||
{ directory, server, draftID, secondDraftID, worktree },
|
||||
)
|
||||
if (options.direction) await openWithDirection(page, draftPath, options.direction)
|
||||
if (!options.direction) await page.goto(draftPath)
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(page.locator('[data-action="composer-model"]')).toContainText("Summary Model")
|
||||
if (options.git === false) await expect(page.getByText("No Git", { exact: true })).toBeVisible()
|
||||
if (options.git !== false) {
|
||||
await expect(
|
||||
page.getByRole("button", { name: worktree === "create" ? "New worktree" : "Local", exact: true }),
|
||||
).toBeVisible()
|
||||
}
|
||||
return { calls, prompts, status, state }
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { ProjectUpdateInput } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/project-autosave"
|
||||
const project = {
|
||||
id: "proj_autosave",
|
||||
canonical: directory,
|
||||
name: "Autosave project",
|
||||
icon: { color: "orange" },
|
||||
commands: { start: "echo setup" },
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
for (const field of [
|
||||
{
|
||||
name: "color",
|
||||
label: "",
|
||||
initial: "orange",
|
||||
next: "blue",
|
||||
patches: [{ icon: { color: "blue", override: "" } }, { icon: { color: "orange", override: "" } }],
|
||||
},
|
||||
{
|
||||
name: "name",
|
||||
label: "Project name",
|
||||
initial: project.name,
|
||||
next: "Renamed project",
|
||||
patches: [{ name: "Renamed project" }, { name: project.name }],
|
||||
},
|
||||
{
|
||||
name: "startup script",
|
||||
label: "Worktree startup script",
|
||||
initial: project.commands.start,
|
||||
next: "bun install",
|
||||
patches: [{ commands: { start: "bun install" } }, { commands: { start: project.commands.start } }],
|
||||
},
|
||||
]) {
|
||||
test(`keeps the final project ${field.name} when reverting during an autosave`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [{ type: "http", http: { url: server } }],
|
||||
projects: {
|
||||
local: [{ worktree: directory, expanded: true }],
|
||||
[server]: [{ worktree: directory, expanded: true }],
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{ directory, server },
|
||||
)
|
||||
const pending = Promise.withResolvers<void>()
|
||||
const patches: unknown[] = []
|
||||
await page.route(`**/api/project/${project.id}`, async (route) => {
|
||||
const patch: Pick<ProjectUpdateInput, "name" | "icon" | "commands"> = route.request().postDataJSON()
|
||||
patches.push(patch)
|
||||
if (patches.length === 1) await pending.promise
|
||||
await route.fulfill({ json: { ...project, ...patch } })
|
||||
})
|
||||
await page.goto("/settings")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: project.name, exact: true }).click()
|
||||
const edit = async (value: string) => {
|
||||
if (field.name === "color") {
|
||||
await settings.getByRole("button", { name: `Select ${value} color`, exact: true }).click()
|
||||
return
|
||||
}
|
||||
const input = settings.getByRole("textbox", { name: field.label, exact: true })
|
||||
await input.fill(value)
|
||||
await input.blur()
|
||||
}
|
||||
|
||||
const first = page.waitForRequest(
|
||||
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === `/api/project/${project.id}`,
|
||||
)
|
||||
await edit(field.next)
|
||||
await first
|
||||
await edit(field.initial)
|
||||
expect(patches).toEqual([field.patches[0]])
|
||||
pending.resolve()
|
||||
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
|
||||
expect(patches).toEqual(field.patches)
|
||||
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: project.name, exact: true }).click()
|
||||
if (field.name === "color") {
|
||||
await expect(settings.getByRole("button", { name: "Select orange color", exact: true })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
)
|
||||
return
|
||||
}
|
||||
await expect(settings.getByRole("textbox", { name: field.label, exact: true })).toHaveValue(field.initial)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { ConfigEntry } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/configured-lsp"
|
||||
const entries: ConfigEntry[] = [
|
||||
{
|
||||
type: "document",
|
||||
path: "/config/opencode.json",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"], extensions: [".ts", ".tsx"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "document",
|
||||
path: `${directory}/opencode.jsonc`,
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { disabled: true },
|
||||
rust: { command: ["rust-analyzer"], extensions: [".rs"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_configured_lsp",
|
||||
canonical: directory,
|
||||
name: "Configured LSP project",
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Configured LSP project", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Extensions", exact: true }).click()
|
||||
})
|
||||
|
||||
test("shows inherited and project-configured LSP entries with config-only status", async ({ page }) => {
|
||||
const ready = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
async (route) => {
|
||||
await ready.promise
|
||||
await route.fulfill({ json: entries })
|
||||
},
|
||||
)
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/config")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
expect(new URL((await requested).url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(panel.getByText("Loading", { exact: true })).toBeVisible()
|
||||
ready.resolve()
|
||||
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("rust", { exact: true })).toBeVisible()
|
||||
const typescript = panel.locator(".project-settings-extension-row").filter({ hasText: "typescript" })
|
||||
await expect(typescript).toContainText("Disabled in config")
|
||||
await expect(typescript).toContainText(".ts, .tsx")
|
||||
await expect(panel.locator(".project-settings-extension-row").filter({ hasText: "rust" })).toContainText(
|
||||
"Enabled in config",
|
||||
)
|
||||
await expect(panel.getByRole("switch")).toHaveCount(0)
|
||||
await expect(panel.getByText("Setup required", { exact: true })).toHaveCount(0)
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(panel.getByText("rust", { exact: true })).toBeInViewport()
|
||||
await expect
|
||||
.poll(() => settings.evaluate((element) => element.scrollWidth - element.clientWidth))
|
||||
.toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
for (const lsp of [true, false]) {
|
||||
test(`handles boolean lsp=${lsp} without inventing detected servers`, async ({ page }) => {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: [{ type: "document", info: { lsp } }],
|
||||
}),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(
|
||||
panel.getByText(lsp ? "No language servers configured" : "Language servers disabled", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(panel.locator(".project-settings-extension-row")).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("keeps configuration load failures inside the tab and allows retry", async ({ page }) => {
|
||||
const state = { fail: true }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/config",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: state.fail ? 404 : 200,
|
||||
json: state.fail ? {} : entries,
|
||||
}),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "LSPs", exact: true }).click()
|
||||
const panel = settings.getByRole("tabpanel", { name: "LSPs", exact: true })
|
||||
await expect(panel.getByText("Could not load language server configuration", { exact: true })).toBeVisible()
|
||||
state.fail = false
|
||||
await panel.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(panel.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await settings.getByRole("tab", { name: "Skills", exact: true }).click()
|
||||
await expect(settings.getByRole("tabpanel", { name: "Skills", exact: true })).toBeVisible()
|
||||
})
|
||||
@@ -20,6 +20,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
// one toggle sweeps every connected server, not just the focused one.
|
||||
await mockServers(page, permissionRequests, permissionResponses, {
|
||||
pending: { [serverA]: [pendingPermission("permission-pending-a", sessionA.id)] },
|
||||
preferencesUnavailable: true,
|
||||
})
|
||||
await configureServers(page)
|
||||
|
||||
@@ -34,8 +35,10 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "328px")
|
||||
await expect(settings.getByRole("complementary")).toHaveCSS("width", "328px")
|
||||
await expect(sessionHeading).toBeHidden()
|
||||
await expect(settings.getByText("Servers", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveCount(0)
|
||||
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
const input = autoAccept.getByRole("switch")
|
||||
await expect(autoAccept).toBeVisible()
|
||||
@@ -63,9 +66,21 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
},
|
||||
])
|
||||
|
||||
await settings.getByRole("tab", { name: "127.0.0.1:4097", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab")).toHaveText([
|
||||
"127.0.0.1:4097",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
])
|
||||
await settings.getByRole("tab", { name: "Models" }).click()
|
||||
await expect(settings.getByRole("switch", { name: "Server B Model" })).toBeEnabled()
|
||||
await expect(settings.getByRole("switch", { name: "Server A Model" })).toHaveCount(0)
|
||||
await settings.getByRole("button", { name: "Back to settings" }).click()
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
await expect(settings).toBeHidden()
|
||||
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
@@ -73,7 +88,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toContainText(sessionB.title)
|
||||
await page.keyboard.press("Control+]")
|
||||
await expect(page).toHaveURL("/settings")
|
||||
await expect(settings.getByRole("tab", { name: "Models", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(settings.getByRole("tab", { name: "Preferences", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(page.locator('[data-titlebar-tab][data-active="true"]')).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(page).toHaveURL(`/server/${base64Encode(serverB)}/session/${sessionB.id}`)
|
||||
@@ -311,6 +326,7 @@ type MockServerOptions = {
|
||||
listFailures?: Record<string, number>
|
||||
// Records /api/session/:id GETs so tests can assert session resyncs.
|
||||
sessionGets?: string[]
|
||||
preferencesUnavailable?: boolean
|
||||
}
|
||||
|
||||
async function mockServers(
|
||||
@@ -402,6 +418,11 @@ async function mockServers(
|
||||
directory,
|
||||
project: { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory },
|
||||
})
|
||||
if (url.pathname === "/api/config/preferences") return json(route, {}, options.preferencesUnavailable ? 404 : 200)
|
||||
if (url.pathname === "/api/config/shell")
|
||||
return json(route, options.preferencesUnavailable ? {} : [], options.preferencesUnavailable ? 404 : 200)
|
||||
if (url.pathname === "/api/websearch/provider")
|
||||
return json(route, { location: { directory }, data: [] }, options.preferencesUnavailable ? 404 : 200)
|
||||
if (url.pathname === "/api/worktree") return json(route, [{ directory }])
|
||||
if (url.pathname === "/api/vcs")
|
||||
return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } })
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { expect, test, type Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
@@ -13,6 +14,7 @@ test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("opens and searches project files inline", async ({ page }) => {
|
||||
const searches: { query: string; dirs?: string; limit?: number }[] = []
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -58,6 +60,7 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
searches.push(input)
|
||||
return input.query === "nested" ? ["src/nested.ts"] : []
|
||||
},
|
||||
events: () => events.splice(0),
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
@@ -126,6 +129,35 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
await expect(sidebar).toHaveCount(0)
|
||||
|
||||
const missingReadPattern = "**/api/fs/read/README.md*"
|
||||
const missingRead = (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
_tag: "FileNotFoundError",
|
||||
path: "README.md",
|
||||
message: "File not found: README.md",
|
||||
}),
|
||||
})
|
||||
await page.route(missingReadPattern, missingRead)
|
||||
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
|
||||
events.push(filesystemEvent("README.md", "unlink"))
|
||||
await missingResponse
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
|
||||
|
||||
const missingTab = panel.getByRole("tab", { name: "File not found: README.md", selected: true })
|
||||
await expect(missingTab).toBeVisible()
|
||||
await expect(missingTab.locator("[data-file-not-found]")).toHaveCSS("text-decoration-line", "line-through")
|
||||
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
|
||||
|
||||
await page.unroute(missingReadPattern, missingRead)
|
||||
events.push(filesystemEvent("README.md", "add"))
|
||||
const restoredTab = panel.getByRole("tab", { name: "README.md", selected: true })
|
||||
await expect(restoredTab).toBeVisible()
|
||||
await expect(restoredTab.locator("[data-file-not-found]")).toHaveCount(0)
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0)
|
||||
await expect(sidebar).toBeVisible()
|
||||
@@ -155,6 +187,16 @@ test("opens and searches project files inline", async ({ page }) => {
|
||||
await expect(panel.getByRole("tab", { name: "nested.ts", selected: true })).toBeVisible()
|
||||
})
|
||||
|
||||
function filesystemEvent(file: string, event: "add" | "change" | "unlink"): OpenCodeEvent {
|
||||
return {
|
||||
id: `evt_${file}_${event}`,
|
||||
created: 1,
|
||||
type: "filesystem.changed",
|
||||
location: { directory },
|
||||
data: { file, event },
|
||||
}
|
||||
}
|
||||
|
||||
function fileNode(path: string) {
|
||||
return {
|
||||
name: path,
|
||||
|
||||
@@ -27,8 +27,12 @@ test("server dialog keeps focus above fullscreen settings", async ({ page }) =>
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
await settings.getByRole("tab", { name: "Servers" }).click()
|
||||
await settings.getByRole("button", { name: "Add server" }).click()
|
||||
const add = settings.getByRole("button", { name: "Add server" })
|
||||
const group = settings.locator('[data-component="settings-nav-group-header"]').filter({ hasText: "Servers" })
|
||||
await expect(add).toHaveCSS("opacity", "0")
|
||||
await group.hover()
|
||||
await expect(add).toHaveCSS("opacity", "1")
|
||||
await add.click()
|
||||
|
||||
const editor = page.getByRole("dialog", { name: "Add server" })
|
||||
await expect(editor.getByPlaceholder("http://localhost:4096")).toBeFocused()
|
||||
|
||||
@@ -4,7 +4,7 @@ import { installStressSessionTabs, stressSessionHref } from "../performance/time
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`session header groups controls and exposes server status in ${direction}`, async ({ page }) => {
|
||||
test(`session header groups controls and exposes session details in ${direction}`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
@@ -31,7 +31,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(review).toBeVisible()
|
||||
await expect(details).toBeVisible()
|
||||
const status = page.locator('[data-slot="titlebar-v2"]').getByRole("button", { name: "Status" })
|
||||
await expect(status).toBeVisible()
|
||||
await expect(status).toHaveCount(0)
|
||||
const titleBounds = await header.getByRole("heading").boundingBox()
|
||||
expect(titleBounds).not.toBeNull()
|
||||
for (const editing of [false, true]) {
|
||||
@@ -122,12 +122,11 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
.toBe(true)
|
||||
await expect(page.getByRole("menuitem", { name: "Server status", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await status.click()
|
||||
const mcp = page.getByRole("tab", { name: "MCP", exact: true })
|
||||
const plugins = page.getByRole("tab", { name: "Plugins", exact: true })
|
||||
await expect(mcp).toHaveAttribute("aria-selected", "true")
|
||||
await plugins.click()
|
||||
await expect(plugins).toHaveAttribute("aria-selected", "true")
|
||||
await details.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
await expect(mcp).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Plugins", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(mcp).toBeHidden()
|
||||
})
|
||||
|
||||
@@ -198,14 +198,15 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(settings).toBeFocused()
|
||||
await expect(page.getByRole("tooltip")).toBeHidden()
|
||||
await page.keyboard.press("Enter")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("heading", { name: copy["dialog.project.edit.title"], exact: true })).toBeVisible()
|
||||
await expect(dialog.getByRole("textbox", { name: copy["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
project.name,
|
||||
)
|
||||
const settingsScreen = page.getByTestId("settings-screen")
|
||||
await expect(settingsScreen.getByRole("heading", { name: project.name, exact: true })).toBeVisible()
|
||||
await expect(
|
||||
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
|
||||
).toHaveValue(project.name)
|
||||
await expect(menu).toBeHidden()
|
||||
await dialog.getByRole("button", { name: copy["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
|
||||
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
|
||||
await expect(settingsScreen).toBeHidden()
|
||||
await expect(header.getByRole("heading")).toHaveText(fixture.expected.targetTitle)
|
||||
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
@@ -272,12 +273,13 @@ for (const state of ["closed", "unopened"] as const) {
|
||||
await expect(menu.getByRole("menuitem", { name: fixture.project.name, exact: true })).toBeEnabled()
|
||||
await expect(menu.getByRole("menuitem", { name: directory, exact: true })).toBeDisabled()
|
||||
await menu.getByRole("menuitem", { name: "Edit project", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox", { name: en["dialog.project.edit.name"], exact: true })).toHaveValue(
|
||||
fixture.project.name,
|
||||
)
|
||||
await dialog.getByRole("button", { name: en["common.cancel"], exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
const settingsScreen = page.getByTestId("settings-screen")
|
||||
await expect(
|
||||
settingsScreen.getByRole("textbox", { name: en["project.settings.name.title"], exact: true }),
|
||||
).toHaveValue(fixture.project.name)
|
||||
await settingsScreen.getByRole("button", { name: en["settings.backToProjects"], exact: true }).click()
|
||||
await settingsScreen.getByRole("button", { name: en["settings.backToApp"], exact: true }).click()
|
||||
await expect(settingsScreen).toBeHidden()
|
||||
}
|
||||
await trigger.click()
|
||||
await menu.getByRole("menuitem", { name: fixture.project.name, exact: true }).click()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { sessionID, setupTimeline, userMessage } from "../performance/timeline-stability/fixture"
|
||||
|
||||
test("keeps a submitted prompt in place while its optimistic rows are measured", async ({ page }) => {
|
||||
await setupTimeline(page, { messages: [userMessage()], seedHistory: true })
|
||||
const release = Promise.withResolvers<void>()
|
||||
await page.route(`**/api/session/${sessionID}/prompt`, async (route) => {
|
||||
if (route.request().method() !== "POST") return route.fallback()
|
||||
await release.promise
|
||||
return route.fallback()
|
||||
})
|
||||
|
||||
const editor = page.locator('[data-component="composer"]').getByRole("textbox")
|
||||
await expect(editor).toBeEditable()
|
||||
await editor.fill("Observe optimistic prompt spacing.")
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
const observation = await page.evaluateHandle(() => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
let frame = 0
|
||||
const sample = () => {
|
||||
const prompt = [...document.querySelectorAll<HTMLElement>('[data-timeline-row="UserMessage"]')].find((row) =>
|
||||
row.textContent?.includes("Observe optimistic prompt spacing."),
|
||||
)
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
}
|
||||
frame = requestAnimationFrame(sample)
|
||||
return {
|
||||
stop: () => {
|
||||
cancelAnimationFrame(frame)
|
||||
return frames
|
||||
},
|
||||
}
|
||||
})
|
||||
const requested = page.waitForRequest(
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/prompt`,
|
||||
)
|
||||
try {
|
||||
await editor.press("Enter")
|
||||
await requested
|
||||
const prompt = page
|
||||
.locator('[data-timeline-row="UserMessage"]')
|
||||
.filter({ hasText: "Observe optimistic prompt spacing." })
|
||||
await expect(prompt).toBeInViewport()
|
||||
await expect(page.locator('[data-component="session-working"]')).toBeVisible()
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.locator("[data-timeline-virtual-content]").evaluate((element) => {
|
||||
const root = element.parentElement!
|
||||
return root.scrollHeight - root.clientHeight - root.scrollTop
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
const frames = await observation.evaluate((value) => value.stop())
|
||||
expect(frames.some((frame) => frame.working && frame.prompt === undefined)).toBe(false)
|
||||
const positions = frames.flatMap((frame) => (frame.prompt === undefined ? [] : [frame.prompt]))
|
||||
expect(positions.length).toBeGreaterThan(0)
|
||||
expect(new Set(positions).size).toBe(1)
|
||||
} finally {
|
||||
release.resolve()
|
||||
await observation.dispose()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,178 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`summary slides the conversation into spare space and back in ${direction}`, async ({ page }, testInfo) => {
|
||||
await page.clock.install({ time: new Date("2026-09-10T12:00:00Z") })
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await mockStressTimeline(page)
|
||||
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const row = page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
)
|
||||
const content = page.locator("[data-timeline-virtual-content]")
|
||||
const composer = page.locator('[data-component="session-composer-dock"] > div')
|
||||
const panel = page.locator('[data-slot="session-chat-panel"]')
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(row).toBeInViewport()
|
||||
await expect(row).toHaveCSS("width", "1000px")
|
||||
const before = await row.boundingBox()
|
||||
const dock = await composer.boundingBox()
|
||||
expect(before).not.toBeNull()
|
||||
expect(dock).not.toBeNull()
|
||||
await content.evaluate((element) => {
|
||||
element.setAttribute("data-summary-motion", "")
|
||||
for (const type of ["transitionrun", "transitionend"]) {
|
||||
element.addEventListener(type, (event) => {
|
||||
if (event.target !== element || (event as TransitionEvent).propertyName !== "translate") return
|
||||
element.setAttribute("data-summary-motion", `${element.getAttribute("data-summary-motion")}${type},`)
|
||||
})
|
||||
}
|
||||
})
|
||||
await testInfo.attach(`summary-${direction}-centered`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await trigger.click()
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const message = await row.boundingBox()
|
||||
const details = await summary.boundingBox()
|
||||
const chat = await panel.boundingBox()
|
||||
if (!message || !details || !chat) return false
|
||||
return (
|
||||
message.x >= chat.x &&
|
||||
message.x + message.width <= chat.x + chat.width &&
|
||||
(direction === "ltr" ? message.x + message.width < details.x : message.x > details.x + details.width)
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(row).toHaveCSS("width", "1000px")
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const message = await row.boundingBox()
|
||||
const input = await composer.boundingBox()
|
||||
if (!message || !input || !before || !dock) return Infinity
|
||||
return Math.abs(message.x - before.x - (input.x - dock.x))
|
||||
})
|
||||
.toBeLessThan(1)
|
||||
await expect
|
||||
.poll(() =>
|
||||
content.evaluate((element) => element.parentElement!.scrollWidth - element.parentElement!.clientWidth),
|
||||
)
|
||||
.toBe(0)
|
||||
await testInfo.attach(`summary-${direction}-shifted`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
await page.clock.pauseAt(new Date("2026-09-10T12:01:00Z"))
|
||||
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
|
||||
for (const width of [1520, 1280, 1600]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
|
||||
await page.clock.runFor(100)
|
||||
await expect(content).toHaveCSS("translate", shifted)
|
||||
await expect(composer).toHaveCSS("translate", shifted)
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "")
|
||||
}
|
||||
await page.clock.resume()
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect(content).not.toHaveCSS("translate", shifted)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", shifted)
|
||||
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
|
||||
await expect(trigger).toBeFocused()
|
||||
|
||||
await trigger.click()
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
// Cross the actual chat-panel breakpoint, including any surrounding shell width.
|
||||
const shell = 1440 - (await panel.boundingBox())!.width
|
||||
await page.setViewportSize({ width: 1320 + shell, height: 900 })
|
||||
await expect.poll(async () => (await panel.boundingBox())!.width).toBe(1320)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const message = (await row.boundingBox())!
|
||||
const details = (await summary.boundingBox())!
|
||||
return direction === "ltr" ? details.x - message.x - message.width : message.x - details.x - details.width
|
||||
})
|
||||
.toBeGreaterThan(0)
|
||||
await page.setViewportSize({ width: 1319 + shell, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", "none")
|
||||
await expect(row).toHaveCSS("width", "1000px")
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", "0px")
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(content).toHaveCSS("transition-duration", "0s")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(content).toHaveCSS("translate", "none")
|
||||
await expect.poll(async () => Math.abs((await row.boundingBox())!.x - before!.x)).toBeLessThan(1)
|
||||
})
|
||||
}
|
||||
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
test(`summary bounds long service lists in ${theme}`, async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 800, height: 600 })
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((theme) => {
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", theme)
|
||||
localStorage.setItem("opencode.global.dat:language", JSON.stringify({ locale: theme === "dark" ? "he" : "en" }))
|
||||
}, theme)
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
new URL(route.request().url()).pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: Array.from({ length: 30 }, (_, index) => ({
|
||||
name: `server-${String(index).padStart(2, "0")}-בדיקה-${"long-name-".repeat(6)}`,
|
||||
status: { status: "connected" },
|
||||
})),
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", theme)
|
||||
await page.getByRole("button", { name: theme === "dark" ? "פרטי ההפעלה" : "Session details", exact: true }).click()
|
||||
const summary = page.locator('[data-component="session-summary-panel"]')
|
||||
await summary.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await expect(menu.getByRole("switch")).toHaveCount(30)
|
||||
await expect.poll(() => menu.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true)
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await menu.boundingBox()
|
||||
return (
|
||||
!!bounds &&
|
||||
bounds.x >= 15 &&
|
||||
bounds.y >= 15 &&
|
||||
bounds.x + bounds.width <= 785 &&
|
||||
bounds.y + bounds.height <= 585
|
||||
)
|
||||
})
|
||||
.toBe(true)
|
||||
await testInfo.attach(`summary-${theme}-long-list`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await menu.getByRole("switch", { name: /^server-29-/ }).focus()
|
||||
await expect(menu.getByRole("switch", { name: /^server-29-/ })).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeFocused()
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test("every MCP row hit area toggles exactly once and keeps the submenu open", async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { enabled: true }
|
||||
const writes: string[] = []
|
||||
await page.route("**/api/mcp**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
const directory = url.searchParams.get("location[directory]")
|
||||
if (route.request().method() === "POST") {
|
||||
expect(directory).toBe(fixture.directory)
|
||||
writes.push(url.pathname)
|
||||
state.enabled = url.pathname.endsWith("/connect")
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [
|
||||
{ name: "figma", status: { status: state.enabled ? "connected" : "disabled" } },
|
||||
{ name: "linear", status: { status: "needs_auth" }, integrationID: "linear-oauth" },
|
||||
{ name: "playwright", status: { status: "failed", error: "Connection refused" } },
|
||||
{ name: "waiting", status: { status: "pending" } },
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "figma", exact: true })
|
||||
const row = submenu
|
||||
.locator('[data-component="switch"]')
|
||||
.filter({ has: page.getByRole("switch", { name: "figma", exact: true }) })
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toBeChecked()
|
||||
await expect(submenu.getByRole("switch", { name: "playwright", exact: true })).toHaveAccessibleDescription("Failed")
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toBeDisabled()
|
||||
await expect(submenu.getByRole("switch", { name: "waiting", exact: true })).toHaveAccessibleDescription("Connecting…")
|
||||
await expect(submenu.getByRole("switch", { name: "linear", exact: true })).toHaveAccessibleDescription(
|
||||
"Sign in required",
|
||||
)
|
||||
await testInfo.attach("summary-mcp-states", { body: await page.screenshot(), contentType: "image/png" })
|
||||
|
||||
for (const [index, target] of ["label", "dot", "padding", "control", "keyboard"].entries()) {
|
||||
const enabled = index % 2 !== 0
|
||||
await expect(toggle).toBeEnabled()
|
||||
if (target === "label") await row.getByText("figma", { exact: true }).click()
|
||||
if (target === "dot") await row.locator(".session-service-dot").click()
|
||||
if (target === "padding") await row.click({ position: { x: 3, y: 3 } })
|
||||
if (target === "control") await row.locator('[data-slot="switch-control"]').click()
|
||||
if (target === "keyboard") await toggle.press("Space")
|
||||
await expect(toggle).toBeChecked({ checked: enabled })
|
||||
await expect(toggle).toBeEnabled()
|
||||
await expect(submenu).toBeVisible()
|
||||
if (target === "keyboard") await expect(toggle).toBeFocused()
|
||||
expect(writes).toHaveLength(index + 1)
|
||||
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
|
||||
}
|
||||
})
|
||||
|
||||
test("MCP authentication starts before a slow resource catalog finishes", async ({ page, context }) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { status: "disabled" }
|
||||
const attempts: string[] = []
|
||||
const resources = Promise.withResolvers<void>()
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
await page.route("**/api/mcp**", async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
const url = new URL(route.request().url())
|
||||
if (url.pathname.endsWith("/connect")) {
|
||||
state.status = "needs_auth"
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
if (url.pathname === "/api/mcp/resource" && state.status === "needs_auth") await resources.promise
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data:
|
||||
url.pathname === "/api/mcp/resource"
|
||||
? { resources: [], templates: [] }
|
||||
: [{ name: "linear", integrationID: "linear-oauth", status: { status: state.status } }],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/integration/**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (route.request().method() === "POST") {
|
||||
attempts.push(route.request().url())
|
||||
return route.fulfill({
|
||||
json: { location: { directory: fixture.directory }, data: { url: "https://auth.example.test/authorize" } },
|
||||
})
|
||||
}
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: {
|
||||
id: "linear-oauth",
|
||||
methods: [{ id: "oauth", type: "oauth" }],
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await page.getByRole("button", { name: "MCP", exact: true }).click()
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
const toggle = submenu.getByRole("switch", { name: "linear", exact: true })
|
||||
await expect(toggle).toBeEnabled()
|
||||
const refresh = page.waitForRequest(
|
||||
(request) =>
|
||||
state.status === "needs_auth" &&
|
||||
new URL(request.url()).pathname === "/api/mcp/resource" &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
try {
|
||||
const popup = page.waitForEvent("popup")
|
||||
await submenu.getByText("linear", { exact: true }).click()
|
||||
await expect(await popup).toHaveURL("https://auth.example.test/authorize")
|
||||
await refresh
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(toggle).toHaveAccessibleDescription("Sign in required")
|
||||
} finally {
|
||||
resources.resolve()
|
||||
}
|
||||
await expect(toggle).toBeEnabled()
|
||||
expect(attempts).toHaveLength(1)
|
||||
expect(new URL(attempts[0]).searchParams.get("location[directory]")).toBe(fixture.directory)
|
||||
})
|
||||
|
||||
test("multiple desktop connections show the session's server name", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.route("http://secondary.test/**", (route) => route.fulfill({ json: { healthy: true, version: "2.0.0" } }))
|
||||
await page.addInitScript(
|
||||
({ directory, server }) => {
|
||||
const current = { type: "http", http: { url: server }, displayName: "Design server" }
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [current, { type: "http", http: { url: "http://secondary.test" }, displayName: "Other server" }],
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
hidden: {},
|
||||
lastProject: {},
|
||||
recentlyClosed: {},
|
||||
}),
|
||||
)
|
||||
},
|
||||
{
|
||||
directory: fixture.directory,
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(summary.getByRole("button", { name: "Design server", exact: true })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
)
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { mockStressTimeline, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
const services = [
|
||||
{ name: "MCP", path: "/api/mcp", empty: "No MCP servers configured yet", item: "summary-mcp" },
|
||||
{ name: "Plugins", path: "/api/plugin", empty: "No plugins configured yet", item: "summary-plugin" },
|
||||
{ name: "Skills", path: "/api/skill", empty: "No skills configured yet", item: "summary-skill" },
|
||||
{ name: "LSP", path: "/api/config", empty: "No LSP servers explicitly configured", item: "summary-lsp" },
|
||||
] as const
|
||||
|
||||
for (const service of services) {
|
||||
for (const empty of [false, true]) {
|
||||
test(`${service.name} keeps ${empty ? "its empty state" : "cached items"} visible while reopening and refreshing`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
const state = { hold: false }
|
||||
const response = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === service.path,
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
if (state.hold) await response.promise
|
||||
if (service.name === "LSP")
|
||||
return route.fulfill({
|
||||
json: empty ? [] : [{ type: "document", info: { lsp: { "summary-lsp": { command: ["summary-lsp"] } } } }],
|
||||
})
|
||||
const items =
|
||||
service.name === "MCP"
|
||||
? [{ name: service.item, status: { status: "connected" } }]
|
||||
: service.name === "Plugins"
|
||||
? [
|
||||
{
|
||||
id: service.item,
|
||||
source: { type: "package", target: service.item },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: [{ id: service.item, name: service.item, location: "/skills/summary/SKILL.md", content: "Summary" }]
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: empty ? [] : items } })
|
||||
},
|
||||
)
|
||||
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const trigger = summary.getByRole("button", { name: service.name, exact: true })
|
||||
await trigger.click()
|
||||
const menu = page.getByRole("dialog", { name: service.name, exact: true })
|
||||
const content = menu.getByText(empty ? service.empty : service.item, { exact: true })
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
if (empty) {
|
||||
const message = menu.locator(".session-service-empty")
|
||||
await expect(message).toHaveCSS("padding", "8px 12px")
|
||||
await expect(message).toHaveCSS("gap", "8px")
|
||||
await expect(message).toHaveCSS("font-size", "11px")
|
||||
await expect(message).toHaveCSS("line-height", "16px")
|
||||
await expect(message.locator("strong")).toHaveCSS("font-weight", "530")
|
||||
await expect(message.locator("p")).toHaveCSS("font-weight", "440")
|
||||
await testInfo.attach(`${service.name}-empty`, { body: await menu.screenshot(), contentType: "image/png" })
|
||||
}
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(menu).toBeHidden()
|
||||
state.hold = true
|
||||
const refresh = page.waitForRequest(
|
||||
(request) => request.method() === "GET" && new URL(request.url()).pathname === service.path,
|
||||
)
|
||||
try {
|
||||
await trigger.click()
|
||||
await refresh
|
||||
await expect(menu).toHaveAttribute("aria-busy", "true")
|
||||
await expect(content).toBeVisible()
|
||||
await expect(menu.getByRole("status")).toHaveCount(0)
|
||||
await expect(menu).toHaveCSS("width", empty ? "200px" : "280px")
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(menu).toHaveAttribute("aria-busy", "false")
|
||||
await expect(content).toBeVisible()
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test("prefetching plugins does not suspend the summary or report an empty catalog", async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
const response = Promise.withResolvers<void>()
|
||||
const state = { requested: false }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/plugin",
|
||||
async (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
state.requested = true
|
||||
await response.promise
|
||||
return route.fulfill({ json: { location: { directory: fixture.directory }, data: [] } })
|
||||
},
|
||||
)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
try {
|
||||
await expect.poll(() => state.requested).toBe(true)
|
||||
await expect(summary.getByRole("button", { name: fixture.project.name, exact: true })).toBeVisible()
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const menu = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(menu.getByRole("status")).toContainText("Loading")
|
||||
await expect(menu.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
await expect(summary).toBeVisible()
|
||||
} finally {
|
||||
response.resolve()
|
||||
}
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Plugins", exact: true }).getByText("No plugins configured yet", { exact: true }),
|
||||
).toBeVisible()
|
||||
})
|
||||
@@ -0,0 +1,296 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import {
|
||||
installStressSessionTabs,
|
||||
mockStressTimeline,
|
||||
stressSessionHref,
|
||||
} from "../performance/timeline/timeline-test-helpers"
|
||||
import { openWithDirection } from "../utils/direction"
|
||||
|
||||
for (const custom of [false, true]) {
|
||||
test(`summary tooltip and ${custom ? "custom" : "default"} shortcut follow the active session`, async ({ page }) => {
|
||||
await mockStressTimeline(page)
|
||||
await installStressSessionTabs(page)
|
||||
if (custom) {
|
||||
await page.addInitScript(() => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({ ...settings, keybinds: { ...settings.keybinds, "session.summary.toggle": "f8" } }),
|
||||
)
|
||||
})
|
||||
}
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await trigger.hover()
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
await expect(tooltip).toBeVisible()
|
||||
await expect(tooltip).toContainText("Summary")
|
||||
const mac = await page.evaluate(() => /(Mac|iPod|iPhone|iPad)/.test(navigator.platform))
|
||||
const shortcut = custom ? "F8" : mac ? "Meta+Shift+Y" : "Control+Shift+Y"
|
||||
await expect(tooltip.locator('[data-slot="keybind-v2-label"]')).toHaveText(
|
||||
custom ? ["F8"] : mac ? ["⇧", "⌘", "Y"] : ["Ctrl", "Shift", "Y"],
|
||||
)
|
||||
for (const id of [fixture.sourceID, fixture.targetID, fixture.sourceID]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(id)}"]`).click()
|
||||
await expect(
|
||||
page.locator(
|
||||
`[data-timeline-row="UserMessage"][data-message-id="${id === fixture.sourceID ? fixture.expected.sourceMessageIDs.at(-1) : fixture.expected.targetMessageIDs.at(-1)}"]`,
|
||||
),
|
||||
).toBeInViewport()
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(summary.getByRole("button", { name: "Server", exact: true })).toBeVisible()
|
||||
await expect.poll(() => summary.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
await expect(tooltip).toBeHidden()
|
||||
await page.keyboard.press(shortcut)
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for (const layout of ["horizontal", "vertical"] as const) {
|
||||
test(`summary persists both disclosures across sessions with ${layout} tabs`, async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
await page.addInitScript((layout) => {
|
||||
const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}")
|
||||
localStorage.setItem(
|
||||
"settings.v3",
|
||||
JSON.stringify({
|
||||
...settings,
|
||||
appearance: { ...settings.appearance, tabLayout: layout },
|
||||
general: { ...settings.general, showStatus: true },
|
||||
}),
|
||||
)
|
||||
}, layout)
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
await expect(trigger).toBeEnabled()
|
||||
await expect(page.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await trigger.click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const project = summary.getByRole("button", { name: fixture.project.name, exact: true })
|
||||
const server = summary.getByRole("button", { name: "Server", exact: true })
|
||||
await expect(project).toHaveAttribute("aria-expanded", "true")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "true")
|
||||
for (const heading of [project, server]) {
|
||||
await expect(heading).toHaveCSS("column-gap", "8px")
|
||||
await expect(heading.locator(".session-summary-heading-label")).toHaveCSS("column-gap", "4px")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("width", "16")
|
||||
await expect(heading.locator(".session-summary-disclosure")).toHaveAttribute("height", "16")
|
||||
}
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await testInfo.attach(`summary-${layout}`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await project.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(summary.getByRole("button", { name: "No changes", exact: true })).toHaveCount(0)
|
||||
await expect(server).toHaveAttribute("aria-expanded", "true")
|
||||
await server.click()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
await expect(trigger).toBeFocused()
|
||||
await trigger.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "false")
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("settings.v3") ?? "{}").sessionSummary))
|
||||
.toEqual({ projectExpanded: false, serverExpanded: false })
|
||||
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await trigger.click()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(server).toHaveAttribute("aria-expanded", "false")
|
||||
await server.click()
|
||||
await expect(summary.getByRole("button", { name: "MCP", exact: true })).toBeVisible()
|
||||
await expect(project).toHaveAttribute("aria-expanded", "false")
|
||||
await page.keyboard.press("Escape")
|
||||
await page.keyboard.press("Control+,")
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeVisible()
|
||||
await expect(settings.getByText("Server status", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test(`service submenus open on click and stay aligned with the view in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
await openWithDirection(page, stressSessionHref(fixture.targetID), direction)
|
||||
await expect(page.getByRole("button", { name: "Session details", exact: true })).toBeEnabled()
|
||||
await expect(page.locator("html")).toHaveAttribute("dir", direction)
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "en")
|
||||
const warnings: string[] = []
|
||||
page.on("console", (event) => {
|
||||
if (event.text().includes("computations created outside")) warnings.push(event.text())
|
||||
})
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
const mcp = summary.getByRole("button", { name: "MCP", exact: true })
|
||||
const submenu = page.getByRole("dialog", { name: "MCP", exact: true })
|
||||
await mcp.hover()
|
||||
await expect(submenu).toHaveCount(0)
|
||||
await mcp.click()
|
||||
await expect(submenu.getByText("No MCP servers configured yet", { exact: true })).toBeVisible()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const row = await mcp.boundingBox()
|
||||
const menu = await submenu.boundingBox()
|
||||
if (!row || !menu) return false
|
||||
return direction === "ltr" ? menu.x + menu.width <= row.x : menu.x >= row.x + row.width
|
||||
})
|
||||
.toBe(true)
|
||||
await testInfo.attach(`summary-submenu-${direction}`, { body: await page.screenshot(), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(submenu).toBeHidden()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect(mcp).toBeFocused()
|
||||
await mcp.press("Enter")
|
||||
await expect(submenu.getByText("Add servers in opencode.json")).toBeVisible()
|
||||
await mcp.click()
|
||||
await expect(submenu).toBeHidden()
|
||||
|
||||
for (const [name, text] of [
|
||||
["Plugins", "No plugins configured yet"],
|
||||
["Skills", "No skills configured yet"],
|
||||
["LSP", "No LSP servers explicitly configured"],
|
||||
]) {
|
||||
await summary.getByRole("button", { name, exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name, exact: true }).getByText(text, { exact: true })).toBeVisible()
|
||||
await expect(submenu).toBeHidden()
|
||||
}
|
||||
await summary.getByRole("button", { name: "Server", exact: true }).click()
|
||||
await expect(page.getByRole("dialog", { name: "LSP", exact: true })).toBeHidden()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(summary).toBeHidden()
|
||||
|
||||
for (const reviewOpen of [false, true]) {
|
||||
if (reviewOpen) await page.getByRole("button", { name: "Toggle review", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
await expect(summary).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const header = await page.locator("[data-session-title]").boundingBox()
|
||||
const panel = await summary.boundingBox()
|
||||
if (!header || !panel) return Infinity
|
||||
return direction === "ltr"
|
||||
? Math.abs(header.x + header.width - panel.x - panel.width - 12)
|
||||
: Math.abs(panel.x - header.x - 12)
|
||||
})
|
||||
.toBeLessThanOrEqual(1)
|
||||
await page.keyboard.press("Escape")
|
||||
}
|
||||
expect(warnings).toEqual([])
|
||||
})
|
||||
}
|
||||
|
||||
test("catalog submenus show project plugins and skills, refresh on reopen, and distinguish errors from empty", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await mockStressTimeline(page)
|
||||
const state = { fail: true, extra: false }
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/plugin**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
requests.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
if (state.fail) return route.fulfill({ status: 500, json: { message: "Unavailable" } })
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "builtin", source: { type: "builtin" }, features: {}, state: { status: "active" } },
|
||||
{
|
||||
id: "supermemory",
|
||||
source: { type: "package", target: "opencode-supermemory" },
|
||||
features: { server: true },
|
||||
state: { status: "active" },
|
||||
},
|
||||
{
|
||||
id: "broken-plugin",
|
||||
source: { type: "local", path: "/broken.ts" },
|
||||
features: { server: true },
|
||||
state: { status: "failed", error: "Plugin failed to activate" },
|
||||
},
|
||||
...(state.extra
|
||||
? [
|
||||
{
|
||||
id: "daytona",
|
||||
source: { type: "package", target: "opencode-daytona" },
|
||||
features: {},
|
||||
state: { status: "active" },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/skill**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: {
|
||||
location: { directory: fixture.directory },
|
||||
data: [
|
||||
{ id: "find-skills", name: "find-skills", location: "/skills/find/SKILL.md", content: "Find skills" },
|
||||
{
|
||||
id: "review-animations",
|
||||
name: "review-animations",
|
||||
location: "/skills/review/SKILL.md",
|
||||
content: "Review animations",
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.route("**/api/config**", (route) => {
|
||||
if (route.request().method() === "OPTIONS") return route.fallback()
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{
|
||||
type: "document",
|
||||
info: {
|
||||
lsp: {
|
||||
typescript: { command: ["typescript-language-server", "--stdio"] },
|
||||
rust: { command: ["rust-analyzer"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
{ type: "document", info: { lsp: { rust: { disabled: true } } } },
|
||||
],
|
||||
})
|
||||
})
|
||||
await page.goto(stressSessionHref(fixture.targetID))
|
||||
await page.getByRole("button", { name: "Session details", exact: true }).click()
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
const plugins = page.getByRole("dialog", { name: "Plugins", exact: true })
|
||||
await expect(plugins.getByRole("alert")).toContainText("Request failed")
|
||||
await expect(plugins.getByText("No plugins configured yet", { exact: true })).toHaveCount(0)
|
||||
state.fail = false
|
||||
await plugins.getByRole("button", { name: "Retry", exact: true }).click()
|
||||
await expect(plugins.getByText("supermemory", { exact: true })).toBeVisible()
|
||||
await expect(plugins.getByText("builtin", { exact: true })).toHaveCount(0)
|
||||
await expect(plugins.getByTitle("Plugin failed to activate")).toContainText("Failed")
|
||||
expect(requests.every((directory) => directory === fixture.directory)).toBe(true)
|
||||
await testInfo.attach("summary-plugins", { body: await page.screenshot(), contentType: "image/png" })
|
||||
await page.keyboard.press("Escape")
|
||||
state.extra = true
|
||||
await summary.getByRole("button", { name: "Plugins", exact: true }).click()
|
||||
await expect(plugins.getByText("daytona", { exact: true })).toBeVisible()
|
||||
await summary.getByRole("button", { name: "Skills", exact: true }).click()
|
||||
const skills = page.getByRole("dialog", { name: "Skills", exact: true })
|
||||
await expect(skills.getByText("find-skills", { exact: true })).toBeVisible()
|
||||
await expect(skills.getByText("review-animations", { exact: true })).toBeVisible()
|
||||
await expect(plugins).toBeHidden()
|
||||
await summary.getByRole("button", { name: "LSP", exact: true }).click()
|
||||
const lsp = page.getByRole("dialog", { name: "LSP", exact: true })
|
||||
await expect(lsp.getByText("Configured LSPs", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("typescript", { exact: true })).toBeVisible()
|
||||
await expect(lsp.getByText("rust", { exact: true })).toHaveCount(0)
|
||||
await expect(lsp.locator(".session-service-dot")).toHaveCount(0)
|
||||
await testInfo.attach("summary-configured-lsp", { body: await page.screenshot(), contentType: "image/png" })
|
||||
})
|
||||
@@ -122,7 +122,15 @@ test("renders compaction progress, summary, and outcome in order", async ({ page
|
||||
)
|
||||
await expect(compaction.getByRole("heading", { name: "Checkpoint" })).toBeVisible()
|
||||
await expect(compaction).toContainText("Streamed implementation details.")
|
||||
await expect(compaction.getByRole("status").getByLabel("Compacting", { exact: true })).toBeVisible()
|
||||
const running = compaction.getByRole("status").getByLabel("Compacting", { exact: true })
|
||||
await expect(running).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const summary = await compaction.locator('[data-component="text-part"]').boundingBox()
|
||||
const status = await running.boundingBox()
|
||||
return !!summary && !!status && status.y >= summary.y + summary.height
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(compaction.getByText("Session compacted", { exact: true })).toHaveCount(0)
|
||||
|
||||
await timeline.send(
|
||||
|
||||
@@ -115,7 +115,7 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
})
|
||||
const group = page.locator('[data-component="collapsed-tool-group"]')
|
||||
await group.getByRole("button", { name: "Used 2 Shell, Patch", exact: true }).click()
|
||||
await expect(group.getByText("2 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts"])
|
||||
await timeline.send(
|
||||
partUpdated(
|
||||
toolPart(
|
||||
@@ -134,7 +134,6 @@ test("combines follow-up patches into one three-file stack inside Used", async (
|
||||
"true",
|
||||
)
|
||||
await expect(group.locator('[data-component="apply-patch-tool"]')).toHaveCount(1)
|
||||
await expect(group.getByText("3 files", { exact: true })).toBeVisible()
|
||||
await expect(group.locator('[data-slot="apply-patch-filename"]')).toHaveText(["a.ts", "b.ts", "c.ts"])
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { fixture } from "../performance/timeline/session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "../performance/timeline/timeline-test-helpers"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 }, serviceWorkers: "block" })
|
||||
|
||||
test("keeps five loaded workspace tabs visible and reactive through repeated switches", async ({ page }, info) => {
|
||||
const sessions = Array.from({ length: 5 }, (_, index) => ({
|
||||
...fixture.sessions[0]!,
|
||||
id: `ses_workspace_cycle_${index}`,
|
||||
directory: `${fixture.directory}/worktree-${index}`,
|
||||
title: `Workspace session ${index}`,
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
...fixture,
|
||||
sessions,
|
||||
pageMessages: (id) => ({
|
||||
items: [
|
||||
{ id: `msg_user_${id}`, type: "user", text: `Prompt for ${id}`, time: { created: 1 } },
|
||||
{
|
||||
id: `msg_assistant_${id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "claude-opus-4-6", providerID: "opencode" },
|
||||
time: { created: 2, completed: 3 },
|
||||
content: [{ type: "text", text: `Answer for ${id}` }],
|
||||
},
|
||||
] satisfies SessionMessageInfo[],
|
||||
}),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.route("**/api/location?*", (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
directory: new URL(route.request().url()).searchParams.get("location[directory]"),
|
||||
project: { id: fixture.project.id, directory: fixture.directory, canonical: fixture.directory },
|
||||
},
|
||||
}),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: sessions.map((session) => session.id) })
|
||||
await page.goto(stressSessionHref(sessions[0]!.id))
|
||||
await expect(page.getByText(`Answer for ${sessions[0]!.id}`, { exact: true })).toBeVisible()
|
||||
|
||||
for (const session of [...sessions.slice(1), ...sessions, ...sessions.toReversed()]) {
|
||||
await page.locator(`[data-titlebar-tab-link][href="${stressSessionHref(session.id)}"]`).click()
|
||||
await expect(page.locator(`[data-timeline-part-id="msg_assistant_${session.id}:text:0"]`)).toBeVisible()
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
}
|
||||
const active = sessions[0]!
|
||||
events.push({
|
||||
id: "evt_workspace_cycle_update",
|
||||
created: 4,
|
||||
type: "session.text.ended",
|
||||
location: { directory: active.directory },
|
||||
durable: { aggregateID: active.id, seq: 0, version: 1 },
|
||||
data: {
|
||||
sessionID: active.id,
|
||||
assistantMessageID: `msg_assistant_${active.id}`,
|
||||
ordinal: 0,
|
||||
text: "Still receiving updates",
|
||||
},
|
||||
})
|
||||
await expect(page.getByText("Still receiving updates", { exact: true })).toBeVisible()
|
||||
await page.screenshot({ path: info.outputPath("workspace-tabs.png") })
|
||||
})
|
||||
@@ -13,11 +13,20 @@ test.beforeEach(async ({ page }) => {
|
||||
id: "proj_settings_demo",
|
||||
canonical: directory,
|
||||
name: "Settings demo",
|
||||
icon: {
|
||||
color: "orange",
|
||||
override:
|
||||
"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16'%3E%3Crect width='16' height='16' fill='red'/%3E%3C/svg%3E",
|
||||
},
|
||||
commands: { start: "echo setup" },
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes,
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
preferences: { shell: "zsh", websearch: { provider: "exa" } },
|
||||
shells: [{ path: "/bin/zsh", name: "zsh", acceptable: true }],
|
||||
websearchProviders: [{ id: "exa", name: "Exa" }],
|
||||
sessions: sandboxes.map((directory, index) => ({
|
||||
id: `ses_settings_${index + 1}`,
|
||||
title: `Workspace ${index + 1} session`,
|
||||
@@ -56,6 +65,131 @@ test("settings has its own route and returns through app history", async ({ page
|
||||
await expect(home).toHaveAttribute("aria-pressed", "true")
|
||||
})
|
||||
|
||||
test("single-server settings expose scoped pages without a server picker", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Servers", exact: true })).toHaveCount(0)
|
||||
|
||||
for (const name of ["Projects", "Worktrees", "Providers", "Models", "Extensions"]) {
|
||||
await settings.getByRole("tab", { name, exact: true }).click()
|
||||
await expect(settings.locator('[data-action="settings-server-select"]')).toHaveCount(0)
|
||||
}
|
||||
|
||||
await settings.getByRole("tab", { name: "Server", exact: true }).click()
|
||||
await expect(settings.getByRole("button", { name: "Add server", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Third-party search", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("zsh", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Exa", { exact: true })).toBeVisible()
|
||||
|
||||
await settings.getByText("Exa", { exact: true }).click()
|
||||
const updated = page.waitForRequest(
|
||||
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/config/preferences",
|
||||
)
|
||||
await page.getByRole("option", { name: "Any", exact: true }).click()
|
||||
expect((await updated).postDataJSON()).toEqual({ websearch: { provider: "random" } })
|
||||
})
|
||||
|
||||
test("server details tolerate unavailable preference endpoints", async ({ page }) => {
|
||||
await page.route(
|
||||
(url) =>
|
||||
url.pathname === "/api/config/preferences" ||
|
||||
url.pathname === "/api/config/shell" ||
|
||||
url.pathname === "/api/websearch/provider",
|
||||
(route) => route.fulfill({ status: 404, json: {} }),
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Server", exact: true }).click()
|
||||
|
||||
const connection = settings.locator('[data-component="settings-server-connection"]')
|
||||
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
|
||||
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
|
||||
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
|
||||
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
|
||||
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
|
||||
await expect(page.getByText("Server request failed", { exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("project settings open as a nested autosaving view", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
|
||||
|
||||
await expect(settings.getByRole("button", { name: "Back to projects", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Settings demo", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Extensions", exact: true })).toBeVisible()
|
||||
await expect(settings.getByRole("tab", { name: "Scripts", exact: true })).toHaveCount(0)
|
||||
|
||||
const name = settings.getByRole("textbox", { name: "Project name", exact: true })
|
||||
const saved = page.waitForRequest(
|
||||
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
|
||||
)
|
||||
await name.fill("Renamed project")
|
||||
await name.blur()
|
||||
expect((await saved).postDataJSON()).toEqual({ name: "Renamed project" })
|
||||
await expect(settings.getByRole("tab", { name: "Renamed project", exact: true })).toBeVisible()
|
||||
|
||||
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
|
||||
const scriptSaved = page.waitForRequest(
|
||||
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/project/proj_settings_demo",
|
||||
)
|
||||
await startup.fill("bun install")
|
||||
await startup.blur()
|
||||
expect((await scriptSaved).postDataJSON()).toEqual({ commands: { start: "bun install" } })
|
||||
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByRole("heading", { name: "Worktrees", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("clearing project fields sends explicit removal values", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Settings demo", exact: true }).click()
|
||||
const startup = settings.getByRole("textbox", { name: "Worktree startup script", exact: true })
|
||||
await expect(startup).toHaveValue("echo setup")
|
||||
|
||||
const scriptSaved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
|
||||
)
|
||||
await startup.clear()
|
||||
await startup.blur()
|
||||
const scriptResponse = await scriptSaved
|
||||
expect(scriptResponse.ok()).toBe(true)
|
||||
expect(scriptResponse.request().postDataJSON()).toEqual({ commands: { start: "" } })
|
||||
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
|
||||
|
||||
const icon = settings.getByRole("button", { name: "Project icon", exact: true })
|
||||
await expect(icon.locator("img")).toHaveCount(1)
|
||||
const iconSaved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
|
||||
)
|
||||
await icon.hover()
|
||||
await icon.click()
|
||||
const iconResponse = await iconSaved
|
||||
expect(iconResponse.ok()).toBe(true)
|
||||
expect(iconResponse.request().postDataJSON()).toEqual({ icon: { color: "orange", override: "" } })
|
||||
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
|
||||
|
||||
const color = settings.getByRole("button", { name: "Select orange color", exact: true })
|
||||
await expect(color).toHaveAttribute("aria-pressed", "true")
|
||||
const colorSaved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "PATCH" && new URL(response.url()).pathname === "/api/project/proj_settings_demo",
|
||||
)
|
||||
await color.click()
|
||||
const colorResponse = await colorSaved
|
||||
expect(colorResponse.ok()).toBe(true)
|
||||
expect(colorResponse.request().postDataJSON()).toEqual({ icon: { color: "", override: "" } })
|
||||
await expect(settings.locator('[aria-busy="true"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("new session shortcut leaves settings and opens a new session screen", async ({ page }) => {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings).toBeFocused()
|
||||
|
||||
@@ -38,7 +38,7 @@ for (const viewport of [
|
||||
test("every settings page leaves room below its final content", async ({ page }) => {
|
||||
await page.setViewportSize(viewport)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const panel = settings.locator(":scope > .settings > .settings-panel:visible")
|
||||
const panel = settings.locator(".settings-content > .settings-panel:visible")
|
||||
if (viewport.bottom) {
|
||||
const toggle = settings.locator('[data-action="settings-mobile-titlebar-bottom"]')
|
||||
await toggle.locator('[data-slot="switch-control"]').click()
|
||||
@@ -50,12 +50,12 @@ for (const viewport of [
|
||||
"Appearance",
|
||||
"Notifications",
|
||||
"Shortcuts",
|
||||
"Servers",
|
||||
"Projects",
|
||||
"Worktrees",
|
||||
"Providers",
|
||||
"Models",
|
||||
"Extensions",
|
||||
"Server",
|
||||
"Experimental",
|
||||
"About",
|
||||
]) {
|
||||
|
||||
@@ -69,11 +69,8 @@ for (const colorScheme of ["light", "dark"] as const) {
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await panel.getByText("rebase", { exact: true }).hover()
|
||||
await panel.getByText("rebase", { exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await expect(dialog.getByRole("textbox")).toHaveValue("rebase")
|
||||
await expect(dialog.getByRole("textbox")).toBeFocused()
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click()
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("rebase")
|
||||
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
|
||||
await expect(panel.getByText("rebase", { exact: true })).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1280, height: 260 })
|
||||
|
||||
@@ -0,0 +1,471 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import en from "../../src/runtime/i18n/en"
|
||||
import { clientSettings } from "../../src/settings/search-catalog"
|
||||
import { createMockServerHandler, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
|
||||
const directory = "/projects/opencode"
|
||||
const config = {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_search",
|
||||
canonical: directory,
|
||||
name: "OpenCode",
|
||||
icon: { color: "orange" },
|
||||
vcs: "git",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
}
|
||||
|
||||
function projectList(count: number) {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
...config.project,
|
||||
id: `proj_search_${index}`,
|
||||
canonical: `${directory}-${index}`,
|
||||
name: `OpenCode ${String(index).padStart(2, "0")}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function ui(page: Page) {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
return {
|
||||
settings,
|
||||
search: settings.getByRole("combobox", { name: "Search", exact: true }),
|
||||
results: settings.getByRole("listbox", { name: "Settings results", exact: true }),
|
||||
viewport: settings.locator(".settings-search-scroll > .scroll-view__viewport"),
|
||||
}
|
||||
}
|
||||
|
||||
async function findShortcut(page: Page) {
|
||||
// Browser emulation can report a different OS than the machine running Playwright.
|
||||
const key = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+f" : "Control+f"))
|
||||
await page.keyboard.press(key)
|
||||
}
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, config)
|
||||
await page.route("https://api.github.com/**", (route) => route.fulfill({ json: [] }))
|
||||
await page.goto("/")
|
||||
if ((page.viewportSize()?.width ?? 1280) < 800) await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
const view = ui(page)
|
||||
await expect(view.settings).toBeFocused()
|
||||
// Readiness includes the server-backed project inventory, not just the settings shell.
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(1)
|
||||
await view.search.clear()
|
||||
})
|
||||
|
||||
test("pointer selection, keyboard navigation, and local find shortcut", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("font")
|
||||
const code = view.results.getByRole("option", { name: "Code Font, Appearance", exact: true })
|
||||
const terminal = view.results.getByRole("option", { name: "Terminal Font, Appearance", exact: true })
|
||||
const font = view.results.getByRole("option", { name: "UI Font, Appearance", exact: true })
|
||||
await expect(view.results.getByRole("option")).toHaveText([
|
||||
"Code FontAppearance",
|
||||
"Terminal FontAppearance",
|
||||
"UI FontAppearance",
|
||||
])
|
||||
await code.click()
|
||||
await expect(code).toBeFocused()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Code Font", exact: true })).toBeInViewport()
|
||||
await code.press("ArrowDown")
|
||||
await expect(terminal).toBeFocused()
|
||||
await expect(terminal).toHaveAttribute("aria-selected", "true")
|
||||
await terminal.press("Enter")
|
||||
await expect(terminal).toBeFocused()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
|
||||
await terminal.press("End")
|
||||
await expect(font).toBeFocused()
|
||||
await font.press("Home")
|
||||
await expect(code).toBeFocused()
|
||||
await terminal.focus()
|
||||
await expect(terminal).toHaveAttribute("aria-selected", "true")
|
||||
await terminal.press("Enter")
|
||||
await expect(view.settings.locator('[data-search-target="row"]')).toContainText("Terminal Font")
|
||||
await findShortcut(page)
|
||||
await expect(view.search).toBeFocused()
|
||||
expect(await view.search.evaluate((input: HTMLInputElement) => [input.selectionStart, input.selectionEnd])).toEqual([
|
||||
0, 4,
|
||||
])
|
||||
await view.settings.getByRole("button", { name: "Back to app", exact: true }).click()
|
||||
await expect(view.settings).toBeHidden()
|
||||
await findShortcut(page)
|
||||
await expect(page.getByRole("textbox", { name: /Search sessions/ })).toBeFocused()
|
||||
})
|
||||
|
||||
test("page priority, compact icons, section context, and the minimal empty state", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("work")
|
||||
await expect(view.results.getByRole("option")).toHaveText(["Worktrees", "Default environmentPreferences / General"])
|
||||
const pageResult = view.results.getByRole("option", { name: /^Worktrees,/ })
|
||||
await expect(pageResult).toHaveCSS("height", "28px")
|
||||
await expect(pageResult.locator("svg")).toHaveCount(1)
|
||||
await expect(view.settings.getByText("App settings", { exact: true })).toHaveCount(0)
|
||||
await view.search.fill("Agent")
|
||||
await expect(view.results.getByRole("option", { name: "Agent, Desktop notifications", exact: true })).toHaveText(
|
||||
"AgentDesktop notifications",
|
||||
)
|
||||
await expect(view.results.getByRole("option", { name: "Agent, Sound effects", exact: true })).toHaveText(
|
||||
"AgentSound effects",
|
||||
)
|
||||
await view.search.fill("zzzzzzzzzz")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("status")).toHaveText('No results for "zzzzzzzzzz"')
|
||||
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await view.search.press("Escape")
|
||||
await expect(view.search).toHaveValue("")
|
||||
await expect(view.settings.getByRole("tab", { name: "Preferences", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("empty queries keep the closing quote beside the ellipsis while typing and resizing", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
const query = "zzzz 🧑🏽💻 ".repeat(40)
|
||||
await view.search.fill(query)
|
||||
const status = view.settings.getByRole("status")
|
||||
const quoted = status.locator("bdi")
|
||||
await expect(status).toHaveAccessibleName(`No results for "${query}"`)
|
||||
await expect(quoted).toHaveText(/^".+…"$/)
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const text = await quoted.textContent()
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await expect(quoted).toHaveText(/^".+…"$/)
|
||||
await expect(quoted).not.toHaveText(text!)
|
||||
await expect
|
||||
.poll(() =>
|
||||
quoted.evaluate((element) => element.getBoundingClientRect().width <= element.parentElement!.clientWidth),
|
||||
)
|
||||
.toBe(true)
|
||||
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
|
||||
|
||||
await view.search.fill("zzzzzzzzzz")
|
||||
await expect(status).toHaveText('No results for "zzzzzzzzzz"')
|
||||
await view.search.pressSequentially("x")
|
||||
await expect(status).toHaveText('No results for "zzzzzzzzzzx"')
|
||||
})
|
||||
|
||||
test("Models and Shortcuts autofocus their filters on normal navigation", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of [
|
||||
{ tab: "Models", search: "Search models" },
|
||||
{ tab: "Shortcuts", search: "Search shortcuts" },
|
||||
{ tab: "Models", search: "Search models" },
|
||||
]) {
|
||||
await view.settings.getByRole("tab", { name: entry.tab, exact: true }).click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: entry.search, exact: true })).toBeFocused()
|
||||
}
|
||||
await view.search.fill("shortcuts")
|
||||
const result = view.results.getByRole("option", { name: "Keyboard shortcuts", exact: true })
|
||||
await result.click()
|
||||
await expect(result).toBeFocused()
|
||||
})
|
||||
|
||||
for (const count of [7, 8]) {
|
||||
test(`Projects search uses the full list threshold with ${count} projects`, async ({ page }) => {
|
||||
await page.route("**/api/project", (route) => route.fulfill({ json: projectList(count) }))
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(count)
|
||||
await view.search.clear()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
const projects = view.settings.getByRole("button", { name: /^OpenCode / })
|
||||
await expect(projects).toHaveCount(count)
|
||||
if (count === 7) {
|
||||
await expect(search).toHaveCount(0)
|
||||
return
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill(" CODE 06 ")
|
||||
await expect(projects).toHaveCount(1)
|
||||
await expect(projects).toHaveAccessibleName("OpenCode 06")
|
||||
await expect(search).toBeVisible()
|
||||
await search.fill("missing-project")
|
||||
await expect(projects).toHaveCount(0)
|
||||
await expect(view.settings.getByText("No projects found", { exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await expect(projects).toHaveCount(count)
|
||||
await view.settings.getByRole("tab", { name: "Models", exact: true }).click()
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("OpenCode 06")
|
||||
await projects.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 06", exact: true })).toBeVisible()
|
||||
})
|
||||
}
|
||||
|
||||
test("Projects search focuses when the qualifying inventory arrives after opening", async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
await page.route("**/api/project", async (route) => {
|
||||
await inventory.promise
|
||||
await route.fulfill({ json: projectList(8) })
|
||||
})
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
|
||||
await page.reload()
|
||||
await requested
|
||||
const view = ui(page)
|
||||
const search = view.settings.getByRole("searchbox", { name: "Search projects", exact: true })
|
||||
try {
|
||||
await view.settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await expect(view.settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
await expect(search).toHaveCount(0)
|
||||
} finally {
|
||||
inventory.resolve()
|
||||
}
|
||||
await expect(search).toBeFocused()
|
||||
await expect(view.settings.getByRole("button", { name: /^OpenCode / })).toHaveCount(8)
|
||||
})
|
||||
|
||||
test("all indexed client controls resolve to visible production controls", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
for (const entry of clientSettings.filter((entry) => entry.target && !entry.available)) {
|
||||
await view.search.fill(en[entry.label as keyof typeof en])
|
||||
const result = view.results.locator(`[data-setting-target="${entry.target}"]`)
|
||||
await expect(result).toHaveCount(1)
|
||||
await result.click()
|
||||
await expect(view.settings.locator(`[data-action="${entry.target}"]`)).toBeInViewport()
|
||||
}
|
||||
})
|
||||
|
||||
test("qualified project results preserve query and selection on return", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("project name")
|
||||
await expect(view.results.locator('[data-setting-target="settings-project-name"]')).toHaveCount(0)
|
||||
await view.search.fill("OpenCode")
|
||||
const project = view.results.getByRole("option")
|
||||
await expect(project).toHaveText("OOpenCode")
|
||||
await expect(project.locator('[data-component="project-avatar-v2"]')).toHaveCSS("width", "16px")
|
||||
await expect(view.settings.locator(".settings-search-group")).toHaveCount(0)
|
||||
await view.search.fill("OpenCode name")
|
||||
const name = view.results.getByRole("option")
|
||||
await expect(name).toHaveText("Project nameGeneral")
|
||||
await name.click()
|
||||
await expect(view.search).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("button", { name: "Back to settings", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode", exact: true })).toHaveCSS("line-height", "20px")
|
||||
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.search).toHaveValue("OpenCode name")
|
||||
await expect(name).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("returning from a project restores a scrolled result list", async ({ page }) => {
|
||||
await page.route("**/api/project", (route) =>
|
||||
route.fulfill({
|
||||
json: projectList(30),
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("OpenCode")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(30)
|
||||
const scrollbar = view.settings.locator('.settings-search-scroll > .scroll-view__thumb[data-orientation="vertical"]')
|
||||
await view.viewport.hover()
|
||||
await expect(scrollbar).toHaveAttribute("data-visible", "true")
|
||||
const bounds = (await scrollbar.boundingBox())!
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2)
|
||||
await page.mouse.down()
|
||||
await expect(scrollbar).toHaveAttribute("data-dragging", "true")
|
||||
await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height / 2 + 80, { steps: 4 })
|
||||
await page.mouse.up()
|
||||
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
|
||||
const project = view.results.getByRole("option", { name: /^OpenCode 25,/ })
|
||||
await project.scrollIntoViewIfNeeded()
|
||||
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBeGreaterThan(0)
|
||||
const scroll = await view.viewport.evaluate((list) => list.scrollTop)
|
||||
await project.click()
|
||||
await expect(view.settings.getByRole("heading", { name: "OpenCode 25", exact: true })).toBeVisible()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(project).toHaveAttribute("aria-selected", "true")
|
||||
await expect.poll(() => view.viewport.evaluate((list) => list.scrollTop)).toBe(scroll)
|
||||
await view.search.fill("about")
|
||||
await expect(view.results.getByRole("option", { name: "About", exact: true })).toBeVisible()
|
||||
await expect(scrollbar).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("IME confirmation does not activate a search result", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.search.fill("font")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(3)
|
||||
await view.search.dispatchEvent("keydown", { key: "Enter", isComposing: true, bubbles: true, cancelable: true })
|
||||
await expect(view.settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await view.search.press("Enter")
|
||||
await expect(view.settings.getByRole("heading", { name: "Appearance", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("header highlight runs once per search activation and finishes cleanly", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await view.settings.evaluate((root) => {
|
||||
root.setAttribute("data-search-flashes", "0")
|
||||
root.addEventListener("animationstart", (event) => {
|
||||
if (!(event instanceof AnimationEvent) || event.animationName !== "settings-search-reveal") return
|
||||
root.setAttribute("data-search-flashes", String(Number(root.getAttribute("data-search-flashes")) + 1))
|
||||
})
|
||||
})
|
||||
await view.search.fill("skills")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("tab", { name: "Skills", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
|
||||
await view.settings.evaluate(async (root) => {
|
||||
await Promise.all(
|
||||
root
|
||||
.getAnimations({ subtree: true })
|
||||
.filter(
|
||||
(animation) => animation instanceof CSSAnimation && animation.animationName === "settings-search-reveal",
|
||||
)
|
||||
.map((animation) => animation.finished),
|
||||
)
|
||||
})
|
||||
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
|
||||
for (const tab of ["MCPs", "Plugins", "Skills"]) {
|
||||
await view.settings.getByRole("tab", { name: tab, exact: true }).click()
|
||||
await expect(view.settings.getByRole("tab", { name: tab, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await expect(view.settings.locator("[data-search-target]")).toHaveCount(0)
|
||||
}
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "1")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "2")
|
||||
await view.search.fill("about")
|
||||
await view.results.getByRole("option", { name: "About", exact: true }).click()
|
||||
await expect(view.settings.getByText("Released under the MIT License", { exact: true })).toBeVisible()
|
||||
await expect(view.settings).toHaveAttribute("data-search-flashes", "3")
|
||||
})
|
||||
|
||||
test("multi-server results navigate to the named server and hide search in nested views", async ({ page }) => {
|
||||
const server = "http://127.0.0.1:4097"
|
||||
const remote = createMockServerHandler({
|
||||
...config,
|
||||
directory: "/remote/opencode",
|
||||
project: { ...config.project, canonical: "/remote/opencode" },
|
||||
})
|
||||
page.on("close", () => void remote.dispose())
|
||||
await installSseTransport(page, { server })
|
||||
await page.route(`${server}/api/**`, async (route) => {
|
||||
if (route.request().method() === "OPTIONS")
|
||||
return route.fulfill({
|
||||
status: 204,
|
||||
headers: { "access-control-allow-origin": "*", "access-control-allow-headers": "*" },
|
||||
})
|
||||
const response = await remote.handler(
|
||||
new Request(route.request().url(), { method: route.request().method(), headers: route.request().headers() }),
|
||||
)
|
||||
await route.fulfill({
|
||||
status: response.status,
|
||||
headers: { ...Object.fromEntries(response.headers), "access-control-allow-origin": "*" },
|
||||
body: Buffer.from(await response.arrayBuffer()),
|
||||
})
|
||||
})
|
||||
await page.addInitScript(
|
||||
(server) =>
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", displayName: "Build server", http: { url: server } }] }),
|
||||
),
|
||||
server,
|
||||
)
|
||||
await page.reload()
|
||||
const view = ui(page)
|
||||
await view.search.fill("MCPs")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(2)
|
||||
await view.results.getByRole("option", { name: "MCPs, Build server, Extensions", exact: true }).click()
|
||||
await expect(view.search).toHaveCount(0)
|
||||
await expect(view.settings.getByRole("tab", { name: "Build server", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("tab", { name: "MCPs", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await expect(view.search).toHaveValue("MCPs")
|
||||
await view.search.fill("Build server models")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
|
||||
await view.settings.getByRole("button", { name: "Back to settings", exact: true }).click()
|
||||
await view.search.fill("Build server OpenCode name")
|
||||
await expect(view.results.getByRole("option")).toHaveCount(1)
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.settings.getByRole("button", { name: "Build server", exact: true })).toBeVisible()
|
||||
await expect(view.settings.getByRole("textbox", { name: "Project name", exact: true })).toHaveValue("OpenCode")
|
||||
})
|
||||
|
||||
for (const direction of ["ltr", "rtl"] as const) {
|
||||
test.describe(`search layout ${direction}`, () => {
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
colorScheme: direction === "rtl" ? "dark" : "light",
|
||||
contextOptions: { reducedMotion: "reduce" },
|
||||
})
|
||||
test("search input fades track typing, caret scrolling, resizing, and clearing", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await page.setViewportSize({ width: 1280, height: 900 })
|
||||
await page.evaluate((direction) => {
|
||||
document.documentElement.dir = direction
|
||||
}, direction)
|
||||
await view.search.fill(direction === "rtl" ? "غيرموجود ".repeat(30) : "zzzz ".repeat(30))
|
||||
await view.search.press("End")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(view.search).not.toHaveCSS("mask-image", "none")
|
||||
|
||||
await view.search.press("Home")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
|
||||
await view.search.evaluate((input: HTMLInputElement, direction) => {
|
||||
input.scrollLeft = ((input.scrollWidth - input.clientWidth) / 2) * (direction === "rtl" ? -1 : 1)
|
||||
}, direction)
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "true")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "true")
|
||||
|
||||
await view.search.fill("z".repeat(50))
|
||||
await expect(view.search).not.toHaveCSS("mask-image", "none")
|
||||
await page.setViewportSize({ width: 600, height: 844 })
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(view.search).toHaveCSS("mask-image", "none")
|
||||
|
||||
await view.search.fill("zzzz ".repeat(30))
|
||||
await expect(view.search).not.toHaveCSS("mask-image", "none")
|
||||
await view.settings.getByRole("button", { name: "Clear", exact: true }).click()
|
||||
await expect(view.search).toHaveValue("")
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.search).toHaveAttribute("data-overflow-start", "false")
|
||||
await expect(view.search).toHaveAttribute("data-overflow-end", "false")
|
||||
await expect(view.search).toHaveCSS("mask-image", "none")
|
||||
})
|
||||
|
||||
test("keeps input/content stable and hides the active descendant when results collapse", async ({ page }) => {
|
||||
const view = ui(page)
|
||||
await page.evaluate((direction) => {
|
||||
document.documentElement.dir = direction
|
||||
}, direction)
|
||||
const input = await view.search.boundingBox()
|
||||
const content = await view.settings.locator(".settings-content").boundingBox()
|
||||
await view.search.fill("e")
|
||||
await expect.poll(() => view.search.boundingBox()).toEqual(input)
|
||||
await expect.poll(() => view.settings.locator(".settings-content").boundingBox()).toEqual(content)
|
||||
await view.search.fill("terminal font")
|
||||
await view.results.getByRole("option").click()
|
||||
await expect(view.results).toBeHidden()
|
||||
await expect(view.search).toHaveAttribute("aria-expanded", "false")
|
||||
await expect(view.search).not.toHaveAttribute("aria-activedescendant", /.+/)
|
||||
await expect(view.settings.getByRole("textbox", { name: "Terminal Font", exact: true })).toBeInViewport()
|
||||
await findShortcut(page)
|
||||
await expect(view.search).toBeFocused()
|
||||
await expect(view.results).toBeVisible()
|
||||
await expect(view.search).toHaveAttribute("aria-activedescendant", /.+/)
|
||||
expect(await view.settings.evaluate((root) => root.scrollWidth <= root.clientWidth)).toBe(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
for (const mode of ["failed", "stopped", "ready"] as const) {
|
||||
test(`manages a ${mode} configured WSL server from nested settings`, async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory: "/repo",
|
||||
project: {
|
||||
id: "proj_wsl_settings",
|
||||
canonical: "/repo",
|
||||
name: "WSL project",
|
||||
sandboxes: [],
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/e2e/utils/settings-wsl.html?${new URLSearchParams({ server, mode })}`)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await expect(settings.getByRole("tab", { name: "Local Server", exact: true })).toBeEnabled()
|
||||
const ubuntu = settings.getByRole("tab", { name: "Ubuntu", exact: true })
|
||||
await expect(ubuntu).toHaveCount(1)
|
||||
await ubuntu.click()
|
||||
await expect(settings.getByRole("heading", { name: "Ubuntu", exact: true })).toBeVisible()
|
||||
const connection = settings.locator('[data-component="settings-server-connection"]')
|
||||
|
||||
if (mode !== "ready") {
|
||||
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeDisabled()
|
||||
await connection.getByRole("button", { name: "More options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Retry start", exact: true }).click()
|
||||
await expect(page.getByLabel("WSL actions")).toHaveText("start:wsl:Ubuntu")
|
||||
}
|
||||
await expect(settings.getByRole("tab", { name: "Projects", exact: true })).toBeEnabled()
|
||||
await connection.getByRole("button", { name: "Update OpenCode", exact: true }).click()
|
||||
await expect(page.getByLabel("WSL actions")).toContainText("update:Ubuntu")
|
||||
await expect(connection.getByRole("button", { name: "Update OpenCode", exact: true })).toHaveCount(0)
|
||||
|
||||
await connection.getByRole("button", { name: "More options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
|
||||
await expect(page.getByLabel("WSL actions")).toContainText("remove:wsl:Ubuntu")
|
||||
await expect(settings.getByRole("tab", { name: "Ubuntu", exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByRole("tab", { name: "Server", exact: true })).toBeEnabled()
|
||||
})
|
||||
}
|
||||
@@ -188,16 +188,8 @@ test("vertical tabs show project details, resize, and navigate", async ({ page }
|
||||
sidebar.getByRole("button", { name: "Home", exact: true }).getByText("Home", { exact: true }),
|
||||
).toBeVisible()
|
||||
await expect(sidebar.getByRole("button", { name: "New session" })).toBeVisible()
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toBeVisible()
|
||||
const status = sidebar.getByRole("button", { name: "Status", exact: true })
|
||||
await expect(status).toBeVisible()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const bounds = await sidebar.boundingBox()
|
||||
const button = await status.boundingBox()
|
||||
return !!bounds && !!button && button.x >= bounds.x && button.x - bounds.x <= 12
|
||||
})
|
||||
.toBe(true)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(sidebar.getByRole("button", { name: "Status", exact: true })).toHaveCount(0)
|
||||
await expect(page.locator('[data-slot="titlebar-v2"]')).toBeHidden()
|
||||
await expect
|
||||
.poll(async () => {
|
||||
@@ -301,7 +293,7 @@ for (const count of [0, 26]) {
|
||||
}
|
||||
|
||||
for (const direction of ["ltr", "rtl"]) {
|
||||
test(`vertical tabs keep Status pinned without Settings in ${direction}`, async ({ page }, testInfo) => {
|
||||
test(`vertical tabs scroll without the retired Status footer in ${direction}`, async ({ page }, testInfo) => {
|
||||
await mockServer(page)
|
||||
await page.addInitScript(
|
||||
({ server, sessionA, sessionB, directory }) => {
|
||||
@@ -334,38 +326,23 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
|
||||
const tabB = sidebar.locator(`[data-titlebar-tab-link][href="${hrefB}"]`)
|
||||
await expect(sidebar.locator("[data-titlebar-tab-slot]")).toHaveCount(26)
|
||||
await expect(status).toHaveText("Status")
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(settings).toHaveCount(0)
|
||||
await expect(status.locator('[data-slot="status-indicator"]')).toBeVisible()
|
||||
await page.evaluate((direction) => document.documentElement.setAttribute("dir", direction), direction)
|
||||
|
||||
for (const width of [1280, 800]) {
|
||||
await page.setViewportSize({ width, height: 360 })
|
||||
await expect(sidebar).toHaveCSS("padding-inline-start", "10px")
|
||||
await expect(sidebar).toHaveCSS("padding-bottom", "10px")
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCSS("margin-top", "8px")
|
||||
await expect(status).toBeInViewport({ ratio: 1 })
|
||||
await expect(status).toHaveCSS("height", "28px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
sidebar.locator('[data-slot="vertical-tabs-footer"]').evaluate((element) => {
|
||||
const content = Math.max(
|
||||
0,
|
||||
...Array.from(element.children, (child) => child.getBoundingClientRect().height),
|
||||
)
|
||||
return element.getBoundingClientRect().height - content
|
||||
}),
|
||||
)
|
||||
.toBe(0)
|
||||
await expect(sidebar.locator('[data-slot="vertical-tabs-footer"]')).toHaveCount(0)
|
||||
await expect(scroll).toHaveCSS("mask-image", /linear-gradient/)
|
||||
await scroll.evaluate((element) => element.scrollTo(0, 0))
|
||||
await expect(scroll).toHaveJSProperty("scrollTop", 0)
|
||||
const pinnedStatus = await status.boundingBox()
|
||||
await scroll.hover()
|
||||
await page.mouse.wheel(0, 200)
|
||||
await expect.poll(() => scroll.evaluate((element) => element.scrollTop)).toBeGreaterThan(0)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await testInfo.attach(`vertical-tabs-status-${width}`, {
|
||||
await expect(status).toHaveCount(0)
|
||||
await testInfo.attach(`vertical-tabs-scroll-${width}`, {
|
||||
body: await sidebar.screenshot(),
|
||||
contentType: "image/png",
|
||||
})
|
||||
@@ -379,14 +356,9 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
return !!tab && !!viewport && tab.y + tab.height <= viewport.y + viewport.height - 16
|
||||
})
|
||||
.toBe(true)
|
||||
await expect.poll(() => status.boundingBox()).toEqual(pinnedStatus)
|
||||
await expect(status).toHaveCount(0)
|
||||
await expect(settings).toHaveCount(0)
|
||||
}
|
||||
|
||||
await status.click()
|
||||
await expect(status).toHaveAttribute("aria-expanded", "true")
|
||||
await status.press("Escape")
|
||||
await expect(status).toHaveAttribute("aria-expanded", "false")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -501,7 +473,7 @@ test("dedicated experimental settings control vertical tab details", async ({ pa
|
||||
await settings.locator('[data-action="settings-show-project-name"] [data-slot="switch-control"]').click()
|
||||
await expect(projectNameSwitch).toBeChecked()
|
||||
await expect(projectNames).toHaveText(["tab-project"])
|
||||
await expect(settings.getByRole("tablist")).toHaveCSS("width", "240px")
|
||||
await expect(settings.getByRole("complementary")).toHaveCSS("width", "240px")
|
||||
|
||||
await page.setViewportSize({ width: 920, height: 720 })
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCSS("width", "260px")
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
const directory = "/repo/workspaces-prefetch"
|
||||
const sandboxes = [`${directory}/first`, `${directory}/second`]
|
||||
const project = {
|
||||
id: "proj_workspaces_prefetch",
|
||||
canonical: directory,
|
||||
name: "Prefetch project",
|
||||
sandboxes,
|
||||
time: { created: 1, updated: 1 },
|
||||
}
|
||||
const other = { ...project, id: "proj_other", name: "Other project", canonical: "/repo/other", sandboxes: [] }
|
||||
|
||||
test.use({ viewport: { width: 1280, height: 900 } })
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project,
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [
|
||||
{
|
||||
id: "ses_workspaces_cached",
|
||||
title: "Cached worktree session",
|
||||
projectID: project.id,
|
||||
directory: sandboxes[0],
|
||||
time: { created: 1, updated: 1 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ projects: { local: [{ worktree: directory, expanded: true }] } }),
|
||||
)
|
||||
}, directory)
|
||||
await page.goto("/")
|
||||
await expect(page.getByText("Cached worktree session", { exact: true })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click()
|
||||
await expect(page.getByTestId("settings-screen").getByRole("tab", { name: "Preferences" })).toBeVisible()
|
||||
})
|
||||
|
||||
for (const interaction of ["hover", "focus"] as const) {
|
||||
test(`project Worktrees ${interaction} prefetches only its inventory and reuses the request`, async ({ page }) => {
|
||||
const inventory = Promise.withResolvers<void>()
|
||||
const calls: string[] = []
|
||||
const sessions: string[] = []
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/project",
|
||||
(route) => route.fulfill({ json: [project, other] }),
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url())
|
||||
if (url.pathname === "/api/session" && url.searchParams.has("directory"))
|
||||
sessions.push(url.searchParams.get("directory")!)
|
||||
})
|
||||
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: project.name, exact: true }).click()
|
||||
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
|
||||
await expect(worktrees).toBeEnabled()
|
||||
const requested = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/project")
|
||||
await worktrees[interaction]()
|
||||
await requested
|
||||
await expect(worktrees).toHaveAttribute("aria-selected", "false")
|
||||
await expect.poll(() => calls).toEqual([directory])
|
||||
expect(sessions).toEqual([])
|
||||
|
||||
if (interaction === "hover") {
|
||||
const finished = page.waitForEvent(
|
||||
"requestfinished",
|
||||
(request) => new URL(request.url()).pathname === "/api/worktree",
|
||||
)
|
||||
inventory.resolve()
|
||||
await finished
|
||||
}
|
||||
await worktrees.click()
|
||||
await expect(worktrees).toHaveAttribute("aria-selected", "true")
|
||||
inventory.resolve()
|
||||
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
|
||||
expect(calls).toEqual([directory])
|
||||
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
|
||||
})
|
||||
}
|
||||
|
||||
for (const nested of [false, true]) {
|
||||
test(`${nested ? "nested" : "root"} server Worktrees hover only prefetches metadata`, async ({ page }) => {
|
||||
if (nested) {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.addInitScript((server) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
list: [
|
||||
{ type: "http", displayName: "Settings server", http: { url: server } },
|
||||
{ type: "http", displayName: "Other server", http: { url: "http://127.0.0.1:4097" } },
|
||||
],
|
||||
}),
|
||||
)
|
||||
}, server)
|
||||
await page.reload()
|
||||
await page.getByTestId("settings-screen").getByRole("tab", { name: "Settings server", exact: true }).click()
|
||||
}
|
||||
const calls = { projects: 0, worktrees: [] as string[] }
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/project",
|
||||
async (route) => {
|
||||
calls.projects += 1
|
||||
await route.fulfill({ json: [project, other] })
|
||||
},
|
||||
)
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? ""
|
||||
calls.worktrees.push(requested)
|
||||
if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] })
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const worktrees = settings.getByRole("tab", { name: "Worktrees", exact: true })
|
||||
await expect(worktrees).toBeEnabled()
|
||||
const fetched = page.waitForEvent(
|
||||
"requestfinished",
|
||||
(request) => new URL(request.url()).pathname === "/api/project",
|
||||
)
|
||||
await worktrees.hover()
|
||||
await fetched
|
||||
await worktrees.focus()
|
||||
await expect(worktrees).toHaveAttribute("aria-selected", "false")
|
||||
expect(calls).toEqual({ projects: 1, worktrees: [] })
|
||||
|
||||
await worktrees.click()
|
||||
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
|
||||
expect(calls.projects).toBe(1)
|
||||
expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted())
|
||||
})
|
||||
}
|
||||
|
||||
test("cached sessions render while directory sessions load without treating unknown rows as empty", async ({
|
||||
page,
|
||||
}) => {
|
||||
const ready = Promise.withResolvers<void>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/session" && url.searchParams.has("directory"),
|
||||
async (route) => {
|
||||
await ready.promise
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const requested = page.waitForRequest((request) => {
|
||||
const url = new URL(request.url())
|
||||
return url.pathname === "/api/session" && url.searchParams.has("directory")
|
||||
})
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await requested
|
||||
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
|
||||
const empty = settings
|
||||
.locator(".settings-workspaces-row")
|
||||
.filter({ has: page.getByLabel(sandboxes[1], { exact: true }) })
|
||||
await expect(empty).toContainText("Loading messages")
|
||||
await settings.getByRole("button", { name: "More options", exact: true }).click()
|
||||
await expect(page.getByRole("menuitem", { name: "Delete worktrees without sessions", exact: true })).toHaveCount(0)
|
||||
await page.keyboard.press("Escape")
|
||||
ready.resolve()
|
||||
await expect(empty).toContainText("0 sessions")
|
||||
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("project deletion updates the cached server-wide inventory", async ({ page }) => {
|
||||
const removed = new Set<string>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
(route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
removed.add(route.request().postDataJSON().directory)
|
||||
return route.fulfill({ status: 204 })
|
||||
}
|
||||
return route.fulfill({
|
||||
json: [
|
||||
{ directory },
|
||||
...sandboxes
|
||||
.filter((directory) => !removed.has(directory))
|
||||
.map((directory) => ({ directory, strategy: "git" })),
|
||||
],
|
||||
})
|
||||
},
|
||||
)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: project.name, exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Delete “second”?", exact: true }).click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "Delete “second”?", exact: true })
|
||||
.getByRole("button", { name: "Delete worktree", exact: true })
|
||||
.click()
|
||||
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
|
||||
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
await expect(settings.getByText("1 worktree", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByLabel(sandboxes[1], { exact: true })).toHaveCount(0)
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { MemoryRouter, createMemoryHistory } from "@solidjs/router"
|
||||
import { render } from "solid-js/web"
|
||||
import { AppBaseProviders, AppInterface } from "../../src/app"
|
||||
import { useLanguage, type Direction } from "../../src/runtime/i18n/language"
|
||||
import { PlatformProvider } from "../../src/runtime/platform/platform"
|
||||
import { createWebPlatform } from "../../src/runtime/platform/web"
|
||||
import { ServerConnection } from "../../src/runtime/server/registry"
|
||||
|
||||
export function mount(input: { server: string; route: string; direction: Direction }) {
|
||||
const root = document.getElementById("root")
|
||||
if (!root) throw new Error("Missing fixture root")
|
||||
const history = createMemoryHistory()
|
||||
history.set({ value: input.route, replace: true, scroll: false })
|
||||
const server: ServerConnection.Http = { type: "http", http: { url: input.server } }
|
||||
|
||||
function DirectedApp() {
|
||||
const language = useLanguage()
|
||||
language.setDirection(input.direction)
|
||||
return (
|
||||
<AppInterface
|
||||
servers={[server]}
|
||||
defaultServer={ServerConnection.key(server)}
|
||||
canonicalLocalServer={ServerConnection.key(server)}
|
||||
router={(props) => <MemoryRouter {...props} history={history} />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render(
|
||||
() => (
|
||||
<PlatformProvider value={createWebPlatform("test").platform}>
|
||||
<AppBaseProviders locale="en">
|
||||
<DirectedApp />
|
||||
</AppBaseProviders>
|
||||
</PlatformProvider>
|
||||
),
|
||||
root,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, interactive-widget=resizes-content, viewport-fit=cover"
|
||||
/>
|
||||
</head>
|
||||
<body
|
||||
class="antialiased overscroll-none font-(family-name:--font-family-text) text-[13px] font-[440] overflow-hidden bg-v2-background-bg-deep"
|
||||
>
|
||||
<div id="root" class="flex flex-col h-dvh bg-v2-background-bg-deep p-px"></div>
|
||||
<script type="module">
|
||||
import { mount } from "./app-direction.fixture.tsx"
|
||||
const query = new URLSearchParams(location.search)
|
||||
mount({ server: query.get("server"), route: query.get("route"), direction: query.get("direction") })
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
|
||||
export async function openWithDirection(page: Page, route: string, direction: "ltr" | "rtl") {
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
await page.goto(`/e2e/utils/app-direction.html?${new URLSearchParams({ server, route, direction })}`)
|
||||
}
|
||||
@@ -70,7 +70,23 @@ const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("mcp", "/api/mcp", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("mcpResource", "/api/mcp/resource", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("projectList", "/api/project", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.patch("projectUpdate", "/api/project/:projectID", {
|
||||
params: { projectID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", {
|
||||
payload: JsonPayload,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("configShells", "/api/config/shell", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
|
||||
success: Json,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user