mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-10 19:49:48 -04:00
Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 613a8d1beb | |||
| 708557880b | |||
| 0f5d4ae648 | |||
| ce72020750 | |||
| c43d606f8e | |||
| 1007630347 | |||
| 9e8274d2da | |||
| 74aa735e6a | |||
| 8030a6c187 | |||
| e5aa5161f2 | |||
| abb1ee6278 | |||
| c4003579bb | |||
| 0d9c534184 | |||
| 5773d43cbf | |||
| e0e9414cbd | |||
| 44edb639c2 | |||
| fbd52ca2f4 | |||
| 8015ff7ca5 | |||
| ec9584177f | |||
| 061efc6cf2 | |||
| fe374aea46 | |||
| 46edc98f10 | |||
| fdeb2748e1 | |||
| 59e6967b8f |
@@ -23,7 +23,7 @@ runs:
|
||||
fi
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version-file: ${{ !steps.bun-url.outputs.url && 'package.json' || '' }}
|
||||
bun-download-url: ${{ steps.bun-url.outputs.url }}
|
||||
@@ -34,7 +34,7 @@ runs:
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
|
||||
@@ -19,7 +19,7 @@ runs:
|
||||
steps:
|
||||
- name: Create app token
|
||||
id: apptoken
|
||||
uses: actions/create-github-app-token@v2
|
||||
uses: actions/create-github-app-token@fee1f7d63c2ff003460e3d139729b119787bc349 # v2.2.2
|
||||
with:
|
||||
app-id: ${{ inputs.opencode-app-id }}
|
||||
private-key: ${{ inputs.opencode-app-secret }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ jobs:
|
||||
contents: read
|
||||
issues: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Close inactive PRs
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close non-compliant issues and PRs after 2 hours
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const { data: items } = await github.rest.issues.listForRepo({
|
||||
|
||||
@@ -21,18 +21,18 @@ jobs:
|
||||
REGISTRY: ghcr.io/${{ github.repository_owner }}
|
||||
TAG: "24.04"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history to access commits
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
- name: Run opencode
|
||||
if: steps.commits.outputs.has_commits == 'true'
|
||||
uses: sst/opencode/github@latest
|
||||
uses: sst/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
with:
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -125,7 +125,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -20,10 +20,10 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
|
||||
- name: Evaluate flake outputs (all systems)
|
||||
run: |
|
||||
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Nix
|
||||
uses: nixbuild/nix-quick-install-action@v34
|
||||
uses: nixbuild/nix-quick-install-action@2c9db80fb984ceb1bcaa77cdda3fdf8cfba92035 # v34
|
||||
|
||||
- name: Compute node_modules hash
|
||||
id: hash
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
echo "Computed hash for ${SYSTEM}: $HASH"
|
||||
|
||||
- name: Upload hash
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: hash-${{ matrix.system }}
|
||||
path: hash.txt
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -102,7 +102,7 @@ jobs:
|
||||
git pull --rebase --autostash origin "$GITHUB_REF_NAME"
|
||||
|
||||
- name: Download hash artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
path: hashes
|
||||
pattern: hash-*
|
||||
|
||||
@@ -9,6 +9,6 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Send nicely-formatted embed to Discord
|
||||
uses: SethCohen/github-releases-to-discord@v1
|
||||
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
|
||||
with:
|
||||
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
@@ -21,12 +21,12 @@ jobs:
|
||||
issues: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@latest
|
||||
uses: anomalyco/opencode/github@2c14fc5586fe0b88e5c04732d2e846769cc35671 # latest
|
||||
env:
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_PERMISSION: '{"bash": "deny"}'
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Add Contributor Label
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
with:
|
||||
script: |
|
||||
const isPR = !!context.payload.pull_request;
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check PR standards
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check PR template compliance
|
||||
uses: actions/github-script@v7
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
publish:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
publish:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
if: github.repository == 'anomalyco/opencode'
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
with:
|
||||
fetch-tags: true
|
||||
|
||||
@@ -95,14 +95,14 @@ jobs:
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: |
|
||||
packages/opencode/dist/opencode-darwin*
|
||||
packages/opencode/dist/opencode-linux*
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist/opencode-windows*
|
||||
@@ -123,9 +123,9 @@ jobs:
|
||||
AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE: ${{ secrets.AZURE_TRUSTED_SIGNING_CERTIFICATE_PROFILE }}
|
||||
AZURE_TRUSTED_SIGNING_ENDPOINT: ${{ secrets.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
@@ -138,13 +138,13 @@ jobs:
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Azure login
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: azure/artifact-signing-action@v1
|
||||
- uses: azure/artifact-signing-action@b443cf8ea4124818d2ea9f043cba29fc3ec47b16 # v1.2.0
|
||||
with:
|
||||
endpoint: ${{ env.AZURE_TRUSTED_SIGNING_ENDPOINT }}
|
||||
signing-account-name: ${{ env.AZURE_TRUSTED_SIGNING_ACCOUNT_NAME }}
|
||||
@@ -201,7 +201,7 @@ jobs:
|
||||
--clobber `
|
||||
--repo "${{ needs.version.outputs.repo }}"
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: |
|
||||
@@ -249,9 +249,9 @@ jobs:
|
||||
platform_flag: --linux
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: apple-actions/import-codesign-certs@v2
|
||||
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
|
||||
if: runner.os == 'macOS'
|
||||
with:
|
||||
keychain: build
|
||||
@@ -268,19 +268,19 @@ jobs:
|
||||
|
||||
- name: Azure login
|
||||
if: runner.os == 'Windows'
|
||||
uses: azure/login@v2
|
||||
uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2.3.0
|
||||
with:
|
||||
client-id: ${{ env.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ env.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ env.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
- name: Cache apt packages
|
||||
if: contains(matrix.settings.host, 'ubuntu')
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ~/apt-cache
|
||||
key: ${{ runner.os }}-${{ matrix.settings.target }}-apt-electron-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||
@@ -388,12 +388,12 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: opencode-desktop-${{ matrix.settings.target }}
|
||||
path: packages/desktop/dist/*
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
name: latest-yml-${{ matrix.settings.target }}
|
||||
@@ -408,44 +408,44 @@ jobs:
|
||||
if: always() && !failure() && !cancelled()
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-cli
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-cli-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
with:
|
||||
name: opencode-cli-signed-windows
|
||||
path: packages/opencode/dist
|
||||
|
||||
- uses: actions/download-artifact@v4
|
||||
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
|
||||
if: needs.version.outputs.release
|
||||
with:
|
||||
pattern: latest-yml-*
|
||||
@@ -459,7 +459,7 @@ jobs:
|
||||
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
|
||||
|
||||
- name: Cache apt packages (AUR)
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: /var/cache/apt/archives
|
||||
key: ${{ runner.os }}-apt-aur-${{ hashFiles('.github/workflows/publish.yml') }}
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
release:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
name: Release Zed Extension
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -37,12 +37,12 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
git config --global user.name "opencode"
|
||||
|
||||
- name: Cache Turbo
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: node_modules/.cache/turbo
|
||||
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Publish unit reports
|
||||
if: always()
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
uses: mikepenz/action-junit-report@bccf2e31636835cf0874589931c4116687171386 # v6.4.0
|
||||
with:
|
||||
report_paths: packages/*/.artifacts/unit/junit.xml
|
||||
check_name: "unit results (${{ matrix.settings.name }})"
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
|
||||
- name: Upload unit artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: unit-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
include-hidden-files: true
|
||||
@@ -111,12 +111,12 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
@@ -131,7 +131,7 @@ jobs:
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
id: playwright-cache
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ github.workspace }}/.playwright-browsers
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
|
||||
@@ -155,7 +155,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
|
||||
if-no-files-found: ignore
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
@@ -43,6 +43,7 @@ type SessionView = {
|
||||
reviewOpen?: string[]
|
||||
pendingMessage?: string
|
||||
pendingMessageAt?: number
|
||||
todoCollapsed?: boolean
|
||||
}
|
||||
|
||||
type TabHandoff = {
|
||||
@@ -759,6 +760,18 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
setScroll(tab: string, pos: SessionScroll) {
|
||||
scroll.setScroll(key(), tab, pos)
|
||||
},
|
||||
todoCollapsed: {
|
||||
get: () => s().todoCollapsed ?? false,
|
||||
set(collapsed: boolean) {
|
||||
const session = key()
|
||||
const current = store.sessionView[session]
|
||||
if (!current) {
|
||||
setStore("sessionView", session, { scroll: {}, todoCollapsed: collapsed })
|
||||
} else {
|
||||
setStore("sessionView", session, "todoCollapsed", collapsed)
|
||||
}
|
||||
},
|
||||
},
|
||||
terminal: {
|
||||
opened: terminalOpened,
|
||||
open() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Show, createEffect, createMemo, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
@@ -46,10 +47,12 @@ export function SessionComposerRegion(props: {
|
||||
setPromptDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const layout = useLayout()
|
||||
const prompt = usePrompt()
|
||||
const language = useLanguage()
|
||||
const route = useSessionKey()
|
||||
const sync = useSync()
|
||||
const view = layout.view(route.sessionKey)
|
||||
|
||||
const handoffPrompt = createMemo(() => getSessionHandoff(route.sessionKey())?.prompt)
|
||||
const info = createMemo(() => (route.params.id ? sync.session.get(route.params.id) : undefined))
|
||||
@@ -207,6 +210,8 @@ export function SessionComposerRegion(props: {
|
||||
<SessionTodoDock
|
||||
sessionID={route.params.id}
|
||||
todos={props.state.todos()}
|
||||
collapsed={view.todoCollapsed.get()}
|
||||
onToggle={() => view.todoCollapsed.set(!view.todoCollapsed.get())}
|
||||
collapseLabel={language.t("session.todo.collapse")}
|
||||
expandLabel={language.t("session.todo.expand")}
|
||||
dockProgress={value()}
|
||||
|
||||
@@ -42,18 +42,17 @@ function dot(status: Todo["status"]) {
|
||||
export function SessionTodoDock(props: {
|
||||
sessionID?: string
|
||||
todos: Todo[]
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
collapseLabel: string
|
||||
expandLabel: string
|
||||
dockProgress: number
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({
|
||||
collapsed: false,
|
||||
height: 320,
|
||||
})
|
||||
|
||||
const toggle = () => setStore("collapsed", (value) => !value)
|
||||
|
||||
const total = createMemo(() => props.todos.length)
|
||||
const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length)
|
||||
const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() }))
|
||||
@@ -72,7 +71,7 @@ export function SessionTodoDock(props: {
|
||||
)
|
||||
|
||||
const preview = createMemo(() => active()?.content ?? "")
|
||||
const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const collapse = useSpring(() => (props.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 })
|
||||
const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress)))
|
||||
const shut = createMemo(() => 1 - dock())
|
||||
const value = createMemo(() => Math.max(0, Math.min(1, collapse())))
|
||||
@@ -107,11 +106,11 @@ export function SessionTodoDock(props: {
|
||||
class="pl-3 pr-2 py-2 flex items-center gap-2 overflow-visible"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={toggle}
|
||||
onClick={props.onToggle}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== "Enter" && event.key !== " ") return
|
||||
event.preventDefault()
|
||||
toggle()
|
||||
props.onToggle()
|
||||
}}
|
||||
>
|
||||
<span
|
||||
@@ -148,7 +147,7 @@ export function SessionTodoDock(props: {
|
||||
>
|
||||
<TextReveal
|
||||
class="text-14-regular text-text-base cursor-default"
|
||||
text={store.collapsed ? preview() : undefined}
|
||||
text={props.collapsed ? preview() : undefined}
|
||||
duration={600}
|
||||
travel={25}
|
||||
edge={17}
|
||||
@@ -161,7 +160,7 @@ export function SessionTodoDock(props: {
|
||||
<div class="ml-auto">
|
||||
<IconButton
|
||||
data-action="session-todo-toggle-button"
|
||||
data-collapsed={store.collapsed ? "true" : "false"}
|
||||
data-collapsed={props.collapsed ? "true" : "false"}
|
||||
icon="chevron-down"
|
||||
size="normal"
|
||||
variant="ghost"
|
||||
@@ -172,16 +171,16 @@ export function SessionTodoDock(props: {
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
toggle()
|
||||
props.onToggle()
|
||||
}}
|
||||
aria-label={store.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
aria-label={props.collapsed ? props.expandLabel : props.collapseLabel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-slot="session-todo-list"
|
||||
aria-hidden={store.collapsed || off()}
|
||||
aria-hidden={props.collapsed || off()}
|
||||
classList={{
|
||||
"pointer-events-none": hide() > 0.1,
|
||||
}}
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
import { Effect, Option, Schema, SchemaAST } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
/**
|
||||
* Annotation key for providing a hand-crafted Zod schema that the walker
|
||||
* should use instead of re-deriving from the AST. Attach it via
|
||||
* `Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })`.
|
||||
*/
|
||||
export const ZodOverride: unique symbol = Symbol.for("effect-zod/override")
|
||||
|
||||
// AST nodes are immutable and frequently shared across schemas (e.g. a single
|
||||
// Schema.Class embedded in multiple parents). Memoizing by node identity
|
||||
// avoids rebuilding equivalent Zod subtrees and keeps derived children stable
|
||||
// by reference across callers.
|
||||
const walkCache = new WeakMap<SchemaAST.AST, z.ZodTypeAny>()
|
||||
|
||||
// Shared empty ParseOptions for the rare callers that need one — avoids
|
||||
// allocating a fresh object per parse inside refinements and transforms.
|
||||
const EMPTY_PARSE_OPTIONS = {} as SchemaAST.ParseOptions
|
||||
|
||||
export function zod<S extends Schema.Top>(schema: S): z.ZodType<Schema.Schema.Type<S>> {
|
||||
return walk(schema.ast) as z.ZodType<Schema.Schema.Type<S>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a Zod value from an Effect Schema (or a Schema-backed export with a
|
||||
* `.zod` static) and narrow the result to `z.ZodObject<any>` so `.shape`,
|
||||
* `.omit`, `.extend`, and friends are accessible.
|
||||
*
|
||||
* The `zod()` walker returns `z.ZodType<T>` because not every AST node decodes
|
||||
* to an object; this helper keeps the "I started from a `Schema.Struct`" cast
|
||||
* in one place instead of sprinkling `as unknown as z.ZodObject<any>` across
|
||||
* call sites.
|
||||
*
|
||||
* The return is intentionally loose — carrying Schema field types through the
|
||||
* mapped `.omit()` / `.extend()` surface triggers brand-intersection
|
||||
* explosions for branded primitives (`string & Brand<"SessionID">` extends
|
||||
* `object` via the brand and gets walked into the prototype by `DeepPartial`,
|
||||
* mapped-schema helpers, and zod's inference through `z.ZodType<T | undefined>`
|
||||
* wrappers also can't reconstruct `T` cleanly. Consumers that care about the
|
||||
* post-`.omit()` shape should cast `c.req.valid(...)` to the expected type.
|
||||
*/
|
||||
export function zodObject<S extends Schema.Top>(schema: S): z.ZodObject<any> {
|
||||
const derived: z.ZodTypeAny = "zod" in schema && isZodType(schema.zod) ? schema.zod : walk(schema.ast)
|
||||
return derived as unknown as z.ZodObject<any>
|
||||
}
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodTypeAny {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
/**
|
||||
* Emit a JSON Schema for a tool/route parameter schema — derives the zod form
|
||||
* via the walker so Effect Schema inputs flow through the same zod-openapi
|
||||
* pipeline the LLM/SDK layer already depends on. `io: "input"` mirrors what
|
||||
* `session/prompt.ts` has always passed to `ai`'s `jsonSchema()` helper.
|
||||
*/
|
||||
export function toJsonSchema<S extends Schema.Top>(schema: S) {
|
||||
return z.toJSONSchema(zod(schema), { io: "input" })
|
||||
}
|
||||
|
||||
function walk(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const cached = walkCache.get(ast)
|
||||
if (cached) return cached
|
||||
const result = walkUncached(ast)
|
||||
walkCache.set(ast, result)
|
||||
return result
|
||||
}
|
||||
|
||||
function walkUncached(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const override = (ast.annotations as any)?.[ZodOverride] as z.ZodTypeAny | undefined
|
||||
// `description` annotations layer on top of an override so callers can
|
||||
// reuse a shared override schema (e.g. `SessionID`) and still add a
|
||||
// per-field description on the outer wrapper.
|
||||
const base = override ?? bodyWithChecks(ast)
|
||||
const desc = SchemaAST.resolveDescription(ast)
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
const described = desc ? base.describe(desc) : base
|
||||
return ref ? described.meta({ ref }) : described
|
||||
}
|
||||
|
||||
function bodyWithChecks(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
// Schema.Class wraps its fields in a Declaration AST plus an encoding that
|
||||
// constructs the class instance. For the Zod derivation we want the plain
|
||||
// field shape (the decoded/consumer view), not the class instance — so
|
||||
// Declarations fall through to body(), not encoded(). User-level
|
||||
// Schema.decodeTo / Schema.transform attach encoding to non-Declaration
|
||||
// nodes, where we do apply the transform.
|
||||
//
|
||||
// Schema.withDecodingDefault also attaches encoding, but we want `.default(v)`
|
||||
// on the inner Zod rather than a transform wrapper — so optional ASTs whose
|
||||
// encoding resolves a default from Option.none() route through body()/opt().
|
||||
const hasEncoding = ast.encoding?.length && (ast._tag !== "Declaration" || ast.typeParameters.length === 0)
|
||||
const hasTransform = hasEncoding && !(SchemaAST.isOptional(ast) && extractDefault(ast) !== undefined)
|
||||
const base = hasTransform ? encoded(ast) : body(ast)
|
||||
return ast.checks?.length ? applyChecks(base, ast.checks, ast) : base
|
||||
}
|
||||
|
||||
// Walk the encoded side and apply each link's decode to produce the decoded
|
||||
// shape. A node `Target` produced by `from.decodeTo(Target)` carries
|
||||
// `Target.encoding = [Link(from, transformation)]`. Chained decodeTo calls
|
||||
// nest the encoding via `Link.to` so walking it recursively threads all
|
||||
// prior transforms — typical encoding.length is 1.
|
||||
function encoded(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const encoding = ast.encoding!
|
||||
return encoding.reduce<z.ZodTypeAny>(
|
||||
(acc, link) => acc.transform((v) => decode(link.transformation, v)),
|
||||
walk(encoding[0].to),
|
||||
)
|
||||
}
|
||||
|
||||
// Transformations built via pure `SchemaGetter.transform(fn)` (the common
|
||||
// decodeTo case) resolve synchronously, so running with no services is safe.
|
||||
// Effectful / middleware-based transforms will surface as Effect defects.
|
||||
function decode(transformation: SchemaAST.Link["transformation"], value: unknown): unknown {
|
||||
const exit = Effect.runSyncExit(
|
||||
(transformation.decode as any).run(Option.some(value), EMPTY_PARSE_OPTIONS) as Effect.Effect<
|
||||
Option.Option<unknown>
|
||||
>,
|
||||
)
|
||||
if (exit._tag === "Failure") throw new Error(`effect-zod: transform failed: ${String(exit.cause)}`)
|
||||
return Option.getOrElse(exit.value, () => value)
|
||||
}
|
||||
|
||||
// Flatten FilterGroups and any nested variants into a linear list of Filters.
|
||||
// Well-known filters (Schema.isInt, isGreaterThan, isPattern, …) are
|
||||
// translated into native Zod methods so their JSON Schema output includes
|
||||
// the corresponding constraint (type: integer, exclusiveMinimum, pattern, …).
|
||||
// Anything else falls back to a single .superRefine layer — runtime-only,
|
||||
// emits no JSON Schema constraint.
|
||||
function applyChecks(out: z.ZodTypeAny, checks: SchemaAST.Checks, ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
const filters: SchemaAST.Filter<unknown>[] = []
|
||||
const collect = (c: SchemaAST.Check<unknown>) => {
|
||||
if (c._tag === "FilterGroup") c.checks.forEach(collect)
|
||||
else filters.push(c)
|
||||
}
|
||||
checks.forEach(collect)
|
||||
|
||||
const unhandled: SchemaAST.Filter<unknown>[] = []
|
||||
const translated = filters.reduce<z.ZodTypeAny>((acc, filter) => {
|
||||
const next = translateFilter(acc, filter)
|
||||
if (next) return next
|
||||
unhandled.push(filter)
|
||||
return acc
|
||||
}, out)
|
||||
|
||||
if (unhandled.length === 0) return translated
|
||||
|
||||
return translated.superRefine((value, ctx) => {
|
||||
for (const filter of unhandled) {
|
||||
const issue = filter.run(value, ast, EMPTY_PARSE_OPTIONS)
|
||||
if (!issue) continue
|
||||
const message = issueMessage(issue) ?? (filter.annotations as any)?.message ?? "Validation failed"
|
||||
ctx.addIssue({ code: "custom", message })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Translate a well-known Effect Schema filter into a native Zod method call on
|
||||
// `out`. Dispatch is keyed on `filter.annotations.meta._tag`, which every
|
||||
// built-in check factory (isInt, isGreaterThan, isPattern, …) attaches at
|
||||
// construction time. Returns `undefined` for unrecognised filters so the
|
||||
// caller can fall back to the generic .superRefine path.
|
||||
function translateFilter(out: z.ZodTypeAny, filter: SchemaAST.Filter<unknown>): z.ZodTypeAny | undefined {
|
||||
const meta = (filter.annotations as { meta?: Record<string, unknown> } | undefined)?.meta
|
||||
if (!meta || typeof meta._tag !== "string") return undefined
|
||||
switch (meta._tag) {
|
||||
case "isInt":
|
||||
return call(out, "int")
|
||||
case "isFinite":
|
||||
return call(out, "finite")
|
||||
case "isGreaterThan":
|
||||
return call(out, "gt", meta.exclusiveMinimum)
|
||||
case "isGreaterThanOrEqualTo":
|
||||
return call(out, "gte", meta.minimum)
|
||||
case "isLessThan":
|
||||
return call(out, "lt", meta.exclusiveMaximum)
|
||||
case "isLessThanOrEqualTo":
|
||||
return call(out, "lte", meta.maximum)
|
||||
case "isBetween": {
|
||||
const lo = meta.exclusiveMinimum ? call(out, "gt", meta.minimum) : call(out, "gte", meta.minimum)
|
||||
if (!lo) return undefined
|
||||
return meta.exclusiveMaximum ? call(lo, "lt", meta.maximum) : call(lo, "lte", meta.maximum)
|
||||
}
|
||||
case "isMultipleOf":
|
||||
return call(out, "multipleOf", meta.divisor)
|
||||
case "isMinLength":
|
||||
return call(out, "min", meta.minLength)
|
||||
case "isMaxLength":
|
||||
return call(out, "max", meta.maxLength)
|
||||
case "isLengthBetween": {
|
||||
const lo = call(out, "min", meta.minimum)
|
||||
if (!lo) return undefined
|
||||
return call(lo, "max", meta.maximum)
|
||||
}
|
||||
case "isPattern":
|
||||
return call(out, "regex", meta.regExp)
|
||||
case "isStartsWith":
|
||||
return call(out, "startsWith", meta.startsWith)
|
||||
case "isEndsWith":
|
||||
return call(out, "endsWith", meta.endsWith)
|
||||
case "isIncludes":
|
||||
return call(out, "includes", meta.includes)
|
||||
case "isUUID":
|
||||
return call(out, "uuid")
|
||||
case "isULID":
|
||||
return call(out, "ulid")
|
||||
case "isBase64":
|
||||
return call(out, "base64")
|
||||
case "isBase64Url":
|
||||
return call(out, "base64url")
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Invoke a named Zod method on `target` if it exists, otherwise return
|
||||
// undefined so the caller can fall back. Using this helper instead of a
|
||||
// typed cast keeps `translateFilter` free of per-case narrowing noise.
|
||||
function call(target: z.ZodTypeAny, method: string, ...args: unknown[]): z.ZodTypeAny | undefined {
|
||||
const fn = (target as unknown as Record<string, ((...a: unknown[]) => z.ZodTypeAny) | undefined>)[method]
|
||||
return typeof fn === "function" ? fn.apply(target, args) : undefined
|
||||
}
|
||||
|
||||
function issueMessage(issue: any): string | undefined {
|
||||
if (typeof issue?.annotations?.message === "string") return issue.annotations.message
|
||||
if (typeof issue?.message === "string") return issue.message
|
||||
return undefined
|
||||
}
|
||||
|
||||
function body(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (SchemaAST.isOptional(ast)) return opt(ast)
|
||||
|
||||
switch (ast._tag) {
|
||||
case "String":
|
||||
return z.string()
|
||||
case "Number":
|
||||
return z.number()
|
||||
case "Boolean":
|
||||
return z.boolean()
|
||||
case "Null":
|
||||
return z.null()
|
||||
case "Undefined":
|
||||
return z.undefined()
|
||||
case "Any":
|
||||
case "Unknown":
|
||||
return z.unknown()
|
||||
case "Never":
|
||||
return z.never()
|
||||
case "Literal":
|
||||
return z.literal(ast.literal)
|
||||
case "Union":
|
||||
return union(ast)
|
||||
case "Objects":
|
||||
return object(ast)
|
||||
case "Arrays":
|
||||
return array(ast)
|
||||
case "Declaration":
|
||||
return decl(ast)
|
||||
default:
|
||||
return fail(ast)
|
||||
}
|
||||
}
|
||||
|
||||
function opt(ast: SchemaAST.AST): z.ZodTypeAny {
|
||||
if (ast._tag !== "Union") return fail(ast)
|
||||
const items = ast.types.filter((item) => item._tag !== "Undefined")
|
||||
const inner =
|
||||
items.length === 1
|
||||
? walk(items[0])
|
||||
: items.length > 1
|
||||
? z.union(items.map(walk) as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
: z.undefined()
|
||||
// Schema.withDecodingDefault attaches an encoding `Link` whose transformation
|
||||
// decode Getter resolves `Option.none()` to `Option.some(default)`. Invoke
|
||||
// it to extract the default and emit `.default(...)` instead of `.optional()`.
|
||||
const fallback = extractDefault(ast)
|
||||
if (fallback !== undefined) return inner.default(fallback.value)
|
||||
return inner.optional()
|
||||
}
|
||||
|
||||
type DecodeLink = {
|
||||
readonly transformation: {
|
||||
readonly decode: {
|
||||
readonly run: (
|
||||
input: Option.Option<unknown>,
|
||||
options: SchemaAST.ParseOptions,
|
||||
) => Effect.Effect<Option.Option<unknown>, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractDefault(ast: SchemaAST.AST): { value: unknown } | undefined {
|
||||
const encoding = (ast as { encoding?: ReadonlyArray<DecodeLink> }).encoding
|
||||
if (!encoding?.length) return undefined
|
||||
// Walk the chain of encoding Links in order; the first Getter that produces
|
||||
// a value from Option.none wins. withDecodingDefault always puts its
|
||||
// defaulting Link adjacent to the optional Union.
|
||||
for (const link of encoding) {
|
||||
const probe = Effect.runSyncExit(link.transformation.decode.run(Option.none(), {}))
|
||||
if (probe._tag !== "Success") continue
|
||||
if (Option.isSome(probe.value)) return { value: probe.value.value }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function union(ast: SchemaAST.Union): z.ZodTypeAny {
|
||||
// When every member is a string literal, emit z.enum() so that
|
||||
// JSON Schema produces { "enum": [...] } instead of { "anyOf": [{ "const": ... }] }.
|
||||
if (ast.types.length >= 2 && ast.types.every((t) => t._tag === "Literal" && typeof t.literal === "string")) {
|
||||
return z.enum(ast.types.map((t) => (t as SchemaAST.Literal).literal as string) as [string, ...string[]])
|
||||
}
|
||||
|
||||
const items = ast.types.map(walk)
|
||||
if (items.length === 1) return items[0]
|
||||
if (items.length < 2) return fail(ast)
|
||||
|
||||
const discriminator = ast.annotations?.discriminator
|
||||
if (typeof discriminator === "string") {
|
||||
return z.discriminatedUnion(discriminator, items as [z.ZodObject<any>, z.ZodObject<any>, ...z.ZodObject<any>[]])
|
||||
}
|
||||
|
||||
return z.union(items as [z.ZodTypeAny, z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
}
|
||||
|
||||
function object(ast: SchemaAST.Objects): z.ZodTypeAny {
|
||||
// Pure record: { [k: string]: V }
|
||||
if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 1) {
|
||||
const sig = ast.indexSignatures[0]
|
||||
if (sig.parameter._tag !== "String") return fail(ast)
|
||||
return z.record(z.string(), walk(sig.type))
|
||||
}
|
||||
|
||||
// Pure object with known fields and no index signatures.
|
||||
if (ast.indexSignatures.length === 0) {
|
||||
return z.object(Object.fromEntries(ast.propertySignatures.map((sig) => [String(sig.name), walk(sig.type)])))
|
||||
}
|
||||
|
||||
// Struct with a catchall (StructWithRest): known fields + index signature.
|
||||
// Only supports a single string-keyed index signature; multi-signature or
|
||||
// symbol/number keys fall through to fail.
|
||||
if (ast.indexSignatures.length !== 1) return fail(ast)
|
||||
const sig = ast.indexSignatures[0]
|
||||
if (sig.parameter._tag !== "String") return fail(ast)
|
||||
return z
|
||||
.object(Object.fromEntries(ast.propertySignatures.map((p) => [String(p.name), walk(p.type)])))
|
||||
.catchall(walk(sig.type))
|
||||
}
|
||||
|
||||
function array(ast: SchemaAST.Arrays): z.ZodTypeAny {
|
||||
// Pure variadic arrays: { elements: [], rest: [item] }
|
||||
if (ast.elements.length === 0) {
|
||||
if (ast.rest.length !== 1) return fail(ast)
|
||||
return z.array(walk(ast.rest[0]))
|
||||
}
|
||||
// Fixed-length tuples: { elements: [a, b, ...], rest: [] }
|
||||
// Tuples with a variadic tail (...rest) are not yet supported.
|
||||
if (ast.rest.length > 0) return fail(ast)
|
||||
const items = ast.elements.map(walk)
|
||||
return z.tuple(items as [z.ZodTypeAny, ...Array<z.ZodTypeAny>])
|
||||
}
|
||||
|
||||
function decl(ast: SchemaAST.Declaration): z.ZodTypeAny {
|
||||
if (ast.typeParameters.length !== 1) return fail(ast)
|
||||
return walk(ast.typeParameters[0])
|
||||
}
|
||||
|
||||
function fail(ast: SchemaAST.AST): never {
|
||||
const ref = SchemaAST.resolveIdentifier(ast)
|
||||
throw new Error(`unsupported effect schema: ${ref ?? ast._tag}`)
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import { zod, ZodOverride } from "./effect-zod"
|
||||
|
||||
/**
|
||||
* Integer greater than zero.
|
||||
@@ -21,7 +20,6 @@ export const optionalOmitUndefined = <S extends Schema.Top>(schema: S) =>
|
||||
decode: SchemaGetter.passthrough({ strict: false }),
|
||||
encode: SchemaGetter.transformOptional(Option.filter((value) => value !== undefined)),
|
||||
}),
|
||||
Schema.annotate({ [ZodOverride]: zod(schema).optional() }),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import * as Cache from "./utils/cache"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "anthropic-messages"
|
||||
@@ -190,6 +191,7 @@ type AnthropicEvent = Schema.Schema.Type<typeof AnthropicEvent>
|
||||
interface ParserState {
|
||||
readonly tools: ToolStream.State<number>
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
@@ -500,37 +502,45 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
|
||||
if (!block) return [state, NO_EVENTS]
|
||||
|
||||
if ((block.type === "tool_use" || block.type === "server_tool_use") && event.index !== undefined) {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, event.index, {
|
||||
id: block.id ?? String(event.index),
|
||||
name: block.name ?? "",
|
||||
providerExecuted: block.type === "server_tool_use",
|
||||
}),
|
||||
},
|
||||
NO_EVENTS,
|
||||
[...events, LLMEvent.toolInputStart({ id: block.id ?? String(event.index), name: block.name ?? "" })],
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "text" && block.text) {
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: block.text })]]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, block.text) },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
if (block.type === "thinking" && block.thinking) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
text: block.thinking,
|
||||
}),
|
||||
],
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, block.thinking),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const result = serverToolResultEvent(block)
|
||||
return [state, result ? [result] : NO_EVENTS]
|
||||
if (!result) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [{ ...state, lifecycle: Lifecycle.stepStart(state.lifecycle, events) }, [...events, result]]
|
||||
}
|
||||
|
||||
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
|
||||
@@ -540,25 +550,37 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
const delta = event.delta
|
||||
|
||||
if (delta?.type === "text_delta" && delta.text) {
|
||||
return [state, [LLMEvent.textDelta({ id: `text-${event.index ?? 0}`, text: delta.text })]] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) },
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "thinking_delta" && delta.thinking) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
state,
|
||||
[LLMEvent.reasoningDelta({ id: `reasoning-${event.index ?? 0}`, text: delta.thinking })],
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `reasoning-${event.index ?? 0}`, delta.thinking),
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (delta?.type === "signature_delta" && delta.signature) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.reasoningEnd({
|
||||
id: `reasoning-${event.index ?? 0}`,
|
||||
providerMetadata: anthropicMetadata({ signature: delta.signature }),
|
||||
}),
|
||||
],
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.index ?? 0}`,
|
||||
anthropicMetadata({ signature: delta.signature }),
|
||||
),
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
@@ -572,7 +594,10 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
|
||||
"Anthropic Messages tool argument delta is missing its tool call",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
}
|
||||
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
@@ -584,23 +609,30 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
|
||||
) {
|
||||
if (event.index === undefined) return [state, NO_EVENTS] satisfies StepResult
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.index)
|
||||
return [{ ...state, tools: result.tools }, result.event ? [result.event] : NO_EVENTS] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const lifecycle = resultEvents.length
|
||||
? Lifecycle.stepStart(state.lifecycle, events)
|
||||
: Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
|
||||
events,
|
||||
`reasoning-${event.index}`,
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
|
||||
const usage = mergeUsage(state.usage, mapUsage(event.usage))
|
||||
return [
|
||||
{ ...state, usage },
|
||||
[
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: mapFinishReason(event.delta?.stop_reason),
|
||||
usage,
|
||||
providerMetadata: event.delta?.stop_sequence
|
||||
? anthropicMetadata({ stopSequence: event.delta.stop_sequence })
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...state, lifecycle, usage }, events]
|
||||
}
|
||||
|
||||
const onError = (state: ParserState, event: AnthropicEvent): StepResult => [
|
||||
@@ -634,7 +666,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(AnthropicEvent),
|
||||
initial: () => ({ tools: ToolStream.empty<number>() }),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { BedrockAuth, type Credentials as BedrockCredentials } from "./utils/bedrock-auth"
|
||||
import { BedrockCache } from "./utils/bedrock-cache"
|
||||
import { BedrockMedia } from "./utils/bedrock-media"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "bedrock-converse"
|
||||
@@ -420,45 +421,64 @@ interface ParserState {
|
||||
// `metadata` (carries usage). Hold the terminal event in state so `onHalt`
|
||||
// can emit exactly one finish after both chunks have had a chance to arrive.
|
||||
readonly pendingFinish: { readonly reason: FinishReason; readonly usage?: Usage } | undefined
|
||||
readonly hasToolCalls: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: BedrockEvent) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.contentBlockStart?.start?.toolUse) {
|
||||
const index = event.contentBlockStart.contentBlockIndex
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
tools: ToolStream.start(state.tools, index, {
|
||||
id: event.contentBlockStart.start.toolUse.toolUseId,
|
||||
name: event.contentBlockStart.start.toolUse.name,
|
||||
}),
|
||||
},
|
||||
[],
|
||||
[
|
||||
...events,
|
||||
LLMEvent.toolInputStart({
|
||||
id: event.contentBlockStart.start.toolUse.toolUseId,
|
||||
name: event.contentBlockStart.start.toolUse.name,
|
||||
}),
|
||||
],
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.text) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.textDelta({
|
||||
id: `text-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.text,
|
||||
}),
|
||||
],
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.textDelta(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`text-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
event.contentBlockDelta.delta.text,
|
||||
),
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.contentBlockDelta?.delta?.reasoningContent?.text) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
state,
|
||||
[
|
||||
LLMEvent.reasoningDelta({
|
||||
id: `reasoning-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
text: event.contentBlockDelta.delta.reasoningContent.text,
|
||||
}),
|
||||
],
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`reasoning-${event.contentBlockDelta.contentBlockIndex}`,
|
||||
event.contentBlockDelta.delta.reasoningContent.text,
|
||||
),
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
}
|
||||
|
||||
@@ -472,12 +492,33 @@ const step = (state: ParserState, event: BedrockEvent) =>
|
||||
"Bedrock Converse tool delta is missing its tool call",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] as const
|
||||
}
|
||||
|
||||
if (event.contentBlockStop) {
|
||||
const result = yield* ToolStream.finish(ADAPTER, state.tools, event.contentBlockStop.contentBlockIndex)
|
||||
return [{ ...state, tools: result.tools }, result.event ? [result.event] : []] as const
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const lifecycle = resultEvents.length
|
||||
? Lifecycle.stepStart(state.lifecycle, events)
|
||||
: Lifecycle.reasoningEnd(
|
||||
Lifecycle.textEnd(state.lifecycle, events, `text-${event.contentBlockStop.contentBlockIndex}`),
|
||||
events,
|
||||
`reasoning-${event.contentBlockStop.contentBlockIndex}`,
|
||||
)
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
hasToolCalls: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasToolCalls,
|
||||
lifecycle,
|
||||
tools: result.tools,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
}
|
||||
|
||||
if (event.messageStop) {
|
||||
@@ -517,7 +558,15 @@ const framing = BedrockEventStream.framing(ADAPTER)
|
||||
|
||||
const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.pendingFinish
|
||||
? [LLMEvent.requestFinish({ reason: state.pendingFinish.reason, usage: state.pendingFinish.usage })]
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason:
|
||||
state.pendingFinish.reason === "stop" && state.hasToolCalls ? "tool-calls" : state.pendingFinish.reason,
|
||||
usage: state.pendingFinish.usage,
|
||||
})
|
||||
return events
|
||||
})()
|
||||
: []
|
||||
|
||||
// =============================================================================
|
||||
@@ -535,7 +584,12 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: BedrockEvent,
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), pendingFinish: undefined }),
|
||||
initial: () => ({
|
||||
tools: ToolStream.empty<number>(),
|
||||
pendingFinish: undefined,
|
||||
hasToolCalls: false,
|
||||
lifecycle: Lifecycle.initial(),
|
||||
}),
|
||||
step,
|
||||
onHalt,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, ProviderShared } from "./shared"
|
||||
import { GeminiToolSchema } from "./utils/gemini-tool-schema"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
export const DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
@@ -134,10 +135,9 @@ interface ParserState {
|
||||
readonly hasToolCalls: boolean
|
||||
readonly nextToolCallId: number
|
||||
readonly usage?: Usage
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
const mediaData = ProviderShared.mediaBytes
|
||||
|
||||
// =============================================================================
|
||||
@@ -324,7 +324,14 @@ const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean
|
||||
|
||||
const finish = (state: ParserState): ReadonlyArray<LLMEvent> =>
|
||||
state.finishReason || state.usage
|
||||
? [LLMEvent.requestFinish({ reason: mapFinishReason(state.finishReason, state.hasToolCalls), usage: state.usage })]
|
||||
? (() => {
|
||||
const events: LLMEvent[] = []
|
||||
Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: mapFinishReason(state.finishReason, state.hasToolCalls),
|
||||
usage: state.usage,
|
||||
})
|
||||
return events
|
||||
})()
|
||||
: []
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
@@ -341,21 +348,21 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
let hasToolCalls = nextState.hasToolCalls
|
||||
let lifecycle = nextState.lifecycle
|
||||
let nextToolCallId = nextState.nextToolCallId
|
||||
|
||||
for (const part of candidate.content.parts) {
|
||||
if ("text" in part && part.text.length > 0) {
|
||||
events.push(
|
||||
part.thought
|
||||
? LLMEvent.reasoningDelta({ id: "reasoning-0", text: part.text })
|
||||
: LLMEvent.textDelta({ id: "text-0", text: part.text }),
|
||||
)
|
||||
lifecycle = part.thought
|
||||
? Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", part.text)
|
||||
: Lifecycle.textDelta(lifecycle, events, "text-0", part.text)
|
||||
continue
|
||||
}
|
||||
|
||||
if ("functionCall" in part) {
|
||||
const input = part.functionCall.args
|
||||
const id = `tool_${nextToolCallId++}`
|
||||
lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(LLMEvent.toolCall({ id, name: part.functionCall.name, input }))
|
||||
hasToolCalls = true
|
||||
}
|
||||
@@ -365,6 +372,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
{
|
||||
...nextState,
|
||||
hasToolCalls,
|
||||
lifecycle,
|
||||
nextToolCallId,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
@@ -388,7 +396,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: () => ({ hasToolCalls: false, nextToolCallId: 0 }),
|
||||
initial: () => ({ hasToolCalls: false, nextToolCallId: 0, lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
onHalt: finish,
|
||||
},
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../schema"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "openai-chat"
|
||||
@@ -147,6 +148,7 @@ interface ParserState {
|
||||
readonly toolCallEvents: ReadonlyArray<LLMEvent>
|
||||
readonly usage?: Usage
|
||||
readonly finishReason?: FinishReason
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
@@ -321,7 +323,9 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
const toolDeltas = delta?.tool_calls ?? []
|
||||
let tools = state.tools
|
||||
|
||||
if (delta?.content) events.push(LLMEvent.textDelta({ id: "text-0", text: delta.content }))
|
||||
let lifecycle = state.lifecycle
|
||||
|
||||
if (delta?.content) lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content)
|
||||
|
||||
for (const tool of toolDeltas) {
|
||||
const result = ToolStream.appendOrStart(
|
||||
@@ -333,7 +337,8 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
tools = result.tools
|
||||
if (result.event) events.push(result.event)
|
||||
if (result.events.length) lifecycle = Lifecycle.stepStart(lifecycle, events)
|
||||
events.push(...result.events)
|
||||
}
|
||||
|
||||
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
|
||||
@@ -349,15 +354,20 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
|
||||
toolCallEvents: finished?.events ?? state.toolCallEvents,
|
||||
usage,
|
||||
finishReason,
|
||||
lifecycle,
|
||||
},
|
||||
events,
|
||||
] as const
|
||||
})
|
||||
|
||||
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const events: LLMEvent[] = []
|
||||
const hasToolCalls = state.toolCallEvents.length > 0
|
||||
const reason = state.finishReason === "stop" && hasToolCalls ? "tool-calls" : state.finishReason
|
||||
return [...state.toolCallEvents, ...(reason ? [LLMEvent.requestFinish({ reason, usage: state.usage })] : [])]
|
||||
const lifecycle = state.toolCallEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...state.toolCallEvents)
|
||||
if (reason) Lifecycle.finish(lifecycle, events, { reason, usage: state.usage })
|
||||
return events
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -377,7 +387,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIChatEvent),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [] }),
|
||||
initial: () => ({ tools: ToolStream.empty<number>(), toolCallEvents: [], lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
onHalt: finishEvents,
|
||||
},
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "../schema"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
|
||||
import { OpenAIOptions } from "./utils/openai-options"
|
||||
import { Lifecycle } from "./utils/lifecycle"
|
||||
import { ToolStream } from "./utils/tool-stream"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
@@ -165,6 +166,7 @@ type OpenAIResponsesEvent = Schema.Schema.Type<typeof OpenAIResponsesEvent>
|
||||
interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
@@ -385,23 +387,32 @@ const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "re
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
return [state, [LLMEvent.textDelta({ id: event.item_id ?? "text-0", text: event.delta })]]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) },
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
hasFunctionCall: state.hasFunctionCall,
|
||||
tools: ToolStream.start(state.tools, item.id, {
|
||||
id: item.call_id ?? item.id,
|
||||
name: item.name ?? "",
|
||||
input: item.arguments ?? "",
|
||||
providerMetadata: openaiMetadata({ itemId: item.id }),
|
||||
providerMetadata,
|
||||
}),
|
||||
},
|
||||
NO_EVENTS,
|
||||
[...events, LLMEvent.toolInputStart({ id: item.call_id ?? item.id, name: item.name ?? "", providerMetadata })],
|
||||
]
|
||||
}
|
||||
|
||||
@@ -418,10 +429,10 @@ const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallAr
|
||||
"OpenAI Responses tool argument delta is missing its tool call",
|
||||
)
|
||||
if (ToolStream.isError(result)) return yield* result
|
||||
return [
|
||||
{ hasFunctionCall: state.hasFunctionCall, tools: result.tools },
|
||||
result.event ? [result.event] : NO_EVENTS,
|
||||
] satisfies StepResult
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = result.events.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...result.events)
|
||||
return [{ ...state, lifecycle, tools: result.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* (
|
||||
@@ -440,33 +451,46 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
item.arguments === undefined
|
||||
? yield* ToolStream.finish(ADAPTER, tools, item.id)
|
||||
: yield* ToolStream.finishWithInput(ADAPTER, tools, item.id, item.arguments)
|
||||
const events: LLMEvent[] = []
|
||||
const resultEvents = result.events ?? []
|
||||
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle
|
||||
events.push(...resultEvents)
|
||||
return [
|
||||
{ hasFunctionCall: result.event ? true : state.hasFunctionCall, tools: result.tools },
|
||||
result.event ? [result.event] : NO_EVENTS,
|
||||
{
|
||||
...state,
|
||||
lifecycle,
|
||||
hasFunctionCall: resultEvents.some(LLMEvent.is.toolCall) ? true : state.hasFunctionCall,
|
||||
tools: result.tools,
|
||||
},
|
||||
events,
|
||||
] satisfies StepResult
|
||||
}
|
||||
|
||||
if (isHostedToolItem(item)) return [state, hostedToolEvents(item)] satisfies StepResult
|
||||
if (isHostedToolItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(...hostedToolEvents(item))
|
||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||
}
|
||||
|
||||
return [state, NO_EVENTS] satisfies StepResult
|
||||
})
|
||||
|
||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
[
|
||||
LLMEvent.requestFinish({
|
||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||
usage: mapUsage(event.response?.usage),
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
? openaiMetadata({
|
||||
responseId: event.response.id,
|
||||
serviceTier: event.response.service_tier,
|
||||
})
|
||||
: undefined,
|
||||
}),
|
||||
],
|
||||
]
|
||||
const onResponseFinish = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const events: LLMEvent[] = []
|
||||
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
|
||||
reason: mapFinishReason(event, state.hasFunctionCall),
|
||||
usage: mapUsage(event.response?.usage),
|
||||
providerMetadata:
|
||||
event.response?.id || event.response?.service_tier
|
||||
? openaiMetadata({
|
||||
responseId: event.response.id,
|
||||
serviceTier: event.response.service_tier,
|
||||
})
|
||||
: undefined,
|
||||
})
|
||||
return [{ ...state, lifecycle }, events]
|
||||
}
|
||||
|
||||
const onResponseFailed = (state: ParserState, event: OpenAIResponsesEvent): StepResult => [
|
||||
state,
|
||||
@@ -506,7 +530,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIResponsesEvent),
|
||||
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>() }),
|
||||
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
terminal: (event) => TERMINAL_TYPES.has(event.type),
|
||||
},
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { LLMEvent, type FinishReason, type ProviderMetadata, type Usage } from "../../schema"
|
||||
|
||||
export interface State {
|
||||
readonly stepStarted: boolean
|
||||
readonly text: ReadonlySet<string>
|
||||
readonly reasoning: ReadonlySet<string>
|
||||
}
|
||||
|
||||
export const initial = (): State => ({ stepStarted: false, text: new Set(), reasoning: new Set() })
|
||||
|
||||
export const stepStart = (state: State, events: LLMEvent[]): State => {
|
||||
if (state.stepStarted) return state
|
||||
events.push(LLMEvent.stepStart({ index: 0 }))
|
||||
return { ...state, stepStarted: true }
|
||||
}
|
||||
|
||||
export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const stepped = stepStart(state, events)
|
||||
if (stepped.text.has(id)) {
|
||||
events.push(LLMEvent.textDelta({ id, text }))
|
||||
return stepped
|
||||
}
|
||||
events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text }))
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const stepped = stepStart(state, events)
|
||||
if (stepped.reasoning.has(id)) {
|
||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
||||
return stepped
|
||||
}
|
||||
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
|
||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||
}
|
||||
|
||||
export const reasoningEnd = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): State => {
|
||||
if (!state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }))
|
||||
const reasoning = new Set(stepped.reasoning)
|
||||
reasoning.delete(id)
|
||||
return { ...stepped, reasoning }
|
||||
}
|
||||
|
||||
export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => {
|
||||
if (!state.text.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.textEnd({ id, providerMetadata }))
|
||||
const text = new Set(stepped.text)
|
||||
text.delete(id)
|
||||
return { ...stepped, text }
|
||||
}
|
||||
|
||||
const closeOpenBlocks = (state: State, events: LLMEvent[]): State => {
|
||||
for (const id of state.reasoning) events.push(LLMEvent.reasoningEnd({ id }))
|
||||
for (const id of state.text) events.push(LLMEvent.textEnd({ id }))
|
||||
return { ...state, text: new Set(), reasoning: new Set() }
|
||||
}
|
||||
|
||||
export const finish = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
input: {
|
||||
readonly reason: FinishReason
|
||||
readonly usage?: Usage
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
},
|
||||
): State => {
|
||||
const stepped = closeOpenBlocks(stepStart(state, events), events)
|
||||
events.push(
|
||||
LLMEvent.stepFinish({
|
||||
index: 0,
|
||||
reason: input.reason,
|
||||
usage: input.usage,
|
||||
providerMetadata: input.providerMetadata,
|
||||
}),
|
||||
LLMEvent.requestFinish(input),
|
||||
)
|
||||
return { ...stepped, stepStarted: false }
|
||||
}
|
||||
|
||||
export * as Lifecycle from "./lifecycle"
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputDelta } from "../../schema"
|
||||
import { LLMError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema"
|
||||
import { eventError, parseToolInput, type ToolAccumulator } from "../shared"
|
||||
|
||||
type StreamKey = string | number
|
||||
@@ -27,13 +27,13 @@ export type State<K extends StreamKey> = Partial<Record<K, PendingTool>>
|
||||
/**
|
||||
* Result of adding argument text to one pending tool call. It returns both the
|
||||
* next `tools` state and the updated `tool` because parsers often need the
|
||||
* current id/name immediately. `event` is present only when new text arrived;
|
||||
* metadata-only deltas update identity without emitting `tool-input-delta`.
|
||||
* current id/name immediately. `events` contains lifecycle and delta events
|
||||
* produced by the append; metadata-only deltas update identity without output.
|
||||
*/
|
||||
export interface AppendOutcome<K extends StreamKey> {
|
||||
readonly tools: State<K>
|
||||
readonly tool: PendingTool
|
||||
readonly event?: ToolInputDelta
|
||||
readonly events: ReadonlyArray<LLMEvent>
|
||||
}
|
||||
|
||||
/** Create empty accumulator state for one provider stream. */
|
||||
@@ -49,7 +49,14 @@ const withoutTool = <K extends StreamKey>(tools: State<K>, key: K): State<K> =>
|
||||
return next
|
||||
}
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string): ToolInputDelta =>
|
||||
const inputStart = (tool: PendingTool) =>
|
||||
LLMEvent.toolInputStart({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
providerMetadata: tool.providerMetadata,
|
||||
})
|
||||
|
||||
const inputDelta = (tool: PendingTool, text: string) =>
|
||||
LLMEvent.toolInputDelta({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
@@ -76,11 +83,16 @@ const appendTool = <K extends StreamKey>(
|
||||
key: K,
|
||||
tool: PendingTool,
|
||||
text: string,
|
||||
): AppendOutcome<K> => ({
|
||||
tools: withTool(tools, key, tool),
|
||||
tool,
|
||||
event: text.length === 0 ? undefined : inputDelta(tool, text),
|
||||
})
|
||||
): AppendOutcome<K> => {
|
||||
const events: LLMEvent[] = []
|
||||
if (!tools[key]) events.push(inputStart(tool))
|
||||
if (text.length > 0) events.push(inputDelta(tool, text))
|
||||
return {
|
||||
tools: withTool(tools, key, tool),
|
||||
tool,
|
||||
events,
|
||||
}
|
||||
}
|
||||
|
||||
export const isError = <K extends StreamKey>(result: AppendOutcome<K> | LLMError): result is LLMError =>
|
||||
result instanceof LLMError
|
||||
@@ -121,7 +133,8 @@ export const appendOrStart = <K extends StreamKey>(
|
||||
providerExecuted: current?.providerExecuted,
|
||||
providerMetadata: current?.providerMetadata,
|
||||
}
|
||||
if (current && delta.text.length === 0 && current.id === id && current.name === name) return { tools, tool: current }
|
||||
if (current && delta.text.length === 0 && current.id === id && current.name === name)
|
||||
return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, tool, delta.text)
|
||||
}
|
||||
|
||||
@@ -139,7 +152,7 @@ export const appendExisting = <K extends StreamKey>(
|
||||
): AppendOutcome<K> | LLMError => {
|
||||
const current = tools[key]
|
||||
if (!current) return eventError(route, missingToolMessage)
|
||||
if (text.length === 0) return { tools, tool: current }
|
||||
if (text.length === 0) return { tools, tool: current, events: [] }
|
||||
return appendTool(tools, key, { ...current, input: `${current.input}${text}` }, text)
|
||||
}
|
||||
|
||||
@@ -152,7 +165,13 @@ export const finish = <K extends StreamKey>(route: string, tools: State<K>, key:
|
||||
Effect.gen(function* () {
|
||||
const tool = tools[key]
|
||||
if (!tool) return { tools }
|
||||
return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool) }
|
||||
return {
|
||||
tools: withoutTool(tools, key),
|
||||
events: [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
yield* toolCall(route, tool),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -164,7 +183,13 @@ export const finishWithInput = <K extends StreamKey>(route: string, tools: State
|
||||
Effect.gen(function* () {
|
||||
const tool = tools[key]
|
||||
if (!tool) return { tools }
|
||||
return { tools: withoutTool(tools, key), event: yield* toolCall(route, tool, input) }
|
||||
return {
|
||||
tools: withoutTool(tools, key),
|
||||
events: [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
yield* toolCall(route, tool, input),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -179,7 +204,14 @@ export const finishAll = <K extends StreamKey>(route: string, tools: State<K>) =
|
||||
)
|
||||
return {
|
||||
tools: empty<K>(),
|
||||
events: yield* Effect.forEach(pending, (tool) => toolCall(route, tool)),
|
||||
events: yield* Effect.forEach(pending, (tool) =>
|
||||
toolCall(route, tool).pipe(
|
||||
Effect.map((call) => [
|
||||
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
|
||||
call,
|
||||
]),
|
||||
),
|
||||
).pipe(Effect.map((events) => events.flat())),
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -154,8 +154,8 @@ const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
)
|
||||
return
|
||||
}
|
||||
if (event.type === "request-finish") {
|
||||
state.finishReason = event.reason
|
||||
if (event.type === "step-finish" || event.type === "request-finish") {
|
||||
state.finishReason = event.reason === "stop" && state.toolCalls.length > 0 ? "tool-calls" : event.reason
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -146,24 +146,46 @@ describe("Anthropic Messages route", () => {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: undefined,
|
||||
cacheWriteInputTokens: undefined,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } },
|
||||
})
|
||||
|
||||
expect(response.toolCalls).toEqual([
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { anthropic: { input_tokens: 5, output_tokens: 1 } },
|
||||
}),
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -204,30 +204,37 @@ describe("Gemini route", () => {
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
})
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 3,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 7,
|
||||
thoughtsTokenCount: 1,
|
||||
cachedContentTokenCount: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "reasoning-start", id: "reasoning-0" },
|
||||
{ type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "reasoning-end", id: "reasoning-0" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 3,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 1,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 2,
|
||||
totalTokenCount: 7,
|
||||
thoughtsTokenCount: 1,
|
||||
cachedContentTokenCount: 1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
usage,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -252,22 +259,41 @@ describe("Gemini route", () => {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
|
||||
})
|
||||
|
||||
expect(response.toolCalls).toEqual([
|
||||
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
])
|
||||
expect(response.events).toEqual([
|
||||
{ type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
|
||||
}),
|
||||
usage,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -318,8 +344,10 @@ describe("Gemini route", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(length.events).toEqual([{ type: "request-finish", reason: "length" }])
|
||||
expect(filtered.events).toEqual([{ type: "request-finish", reason: "content-filter" }])
|
||||
expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"])
|
||||
expect(length.events.at(-1)).toMatchObject({ type: "request-finish", reason: "length" })
|
||||
expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "request-finish"])
|
||||
expect(filtered.events.at(-1)).toMatchObject({ type: "request-finish", reason: "content-filter" })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -222,31 +222,36 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "text-start", id: "text-0" },
|
||||
{ type: "text-delta", id: "text-0", text: "Hello" },
|
||||
{ type: "text-delta", id: "text-0", text: "!" },
|
||||
{ type: "text-end", id: "text-0" },
|
||||
{ type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 7,
|
||||
prompt_tokens_details: { cached_tokens: 1 },
|
||||
completion_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
usage,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -269,9 +274,20 @@ describe("OpenAI Chat route", () => {
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: undefined,
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage: undefined, providerMetadata: undefined },
|
||||
{ type: "request-finish", reason: "tool-calls", usage: undefined },
|
||||
])
|
||||
}),
|
||||
@@ -293,6 +309,8 @@ describe("OpenAI Chat route", () => {
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup", providerMetadata: undefined },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' },
|
||||
])
|
||||
@@ -352,7 +370,7 @@ describe("OpenAI Chat route", () => {
|
||||
const events = Array.from(
|
||||
yield* LLMClient.stream(request).pipe(Stream.take(1), Stream.runCollect, Effect.provide(fixedResponse(body))),
|
||||
)
|
||||
expect(events.map((event) => event.type)).toEqual(["text-delta"])
|
||||
expect(events.map((event) => event.type)).toEqual(["step-start"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -333,32 +333,43 @@ describe("OpenAI Responses route", () => {
|
||||
},
|
||||
)
|
||||
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.text).toBe("Hello!")
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{ type: "text-start", id: "msg_1" },
|
||||
{ type: "text-delta", id: "msg_1", text: "Hello" },
|
||||
{ type: "text-delta", id: "msg_1", text: "!" },
|
||||
{ type: "text-end", id: "msg_1" },
|
||||
{
|
||||
type: "step-finish",
|
||||
index: 0,
|
||||
reason: "stop",
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage,
|
||||
},
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "stop",
|
||||
providerMetadata: { openai: { responseId: "resp_1", serviceTier: "default" } },
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 2,
|
||||
nonCachedInputTokens: 4,
|
||||
cacheReadInputTokens: 1,
|
||||
reasoningTokens: 0,
|
||||
totalTokens: 7,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
input_tokens: 5,
|
||||
output_tokens: 2,
|
||||
total_tokens: 7,
|
||||
input_tokens_details: { cached_tokens: 1 },
|
||||
output_tokens_details: { reasoning_tokens: 0 },
|
||||
},
|
||||
},
|
||||
}),
|
||||
usage,
|
||||
},
|
||||
])
|
||||
}),
|
||||
@@ -390,8 +401,24 @@ describe("OpenAI Responses route", () => {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const usage = new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
cacheReadInputTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
||||
})
|
||||
|
||||
expect(response.events).toEqual([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "tool-input-start",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-input-delta",
|
||||
id: "call_1",
|
||||
@@ -404,23 +431,26 @@ describe("OpenAI Responses route", () => {
|
||||
name: "lookup",
|
||||
text: ':"weather"}',
|
||||
},
|
||||
{
|
||||
type: "tool-input-end",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerExecuted: undefined,
|
||||
providerMetadata: { openai: { itemId: "item_1" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
|
||||
{
|
||||
type: "request-finish",
|
||||
reason: "tool-calls",
|
||||
usage: new Usage({
|
||||
inputTokens: 5,
|
||||
outputTokens: 1,
|
||||
nonCachedInputTokens: 5,
|
||||
totalTokens: 6,
|
||||
providerMetadata: { openai: { input_tokens: 5, output_tokens: 1 } },
|
||||
}),
|
||||
providerMetadata: undefined,
|
||||
usage,
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -313,7 +313,14 @@ describe("LLMClient tools", () => {
|
||||
),
|
||||
)
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["text-delta", "request-finish"])
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"step-start",
|
||||
"text-start",
|
||||
"text-delta",
|
||||
"text-end",
|
||||
"step-finish",
|
||||
"request-finish",
|
||||
])
|
||||
expect(LLMResponse.text({ events })).toBe("Done.")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -21,11 +21,17 @@ describe("ToolStream", () => {
|
||||
if (ToolStream.isError(second)) return yield* second
|
||||
const finished = yield* ToolStream.finish(ADAPTER, second.tools, 0)
|
||||
|
||||
expect(first.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' })
|
||||
expect(second.event).toEqual({ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' })
|
||||
expect(first.events).toEqual([
|
||||
{ type: "tool-input-start", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: '{"query"' },
|
||||
])
|
||||
expect(second.events).toEqual([{ type: "tool-input-delta", id: "call_1", name: "lookup", text: ':"weather"}' }])
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
events: [
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -50,7 +56,10 @@ describe("ToolStream", () => {
|
||||
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
event: { type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } },
|
||||
events: [
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "final" } },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -73,7 +82,9 @@ describe("ToolStream", () => {
|
||||
expect(finished).toEqual({
|
||||
tools: {},
|
||||
events: [
|
||||
{ type: "tool-input-end", id: "call_1", name: "lookup" },
|
||||
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
|
||||
{ type: "tool-input-end", id: "call_2", name: "web_search" },
|
||||
{
|
||||
type: "tool-call",
|
||||
id: "call_2",
|
||||
|
||||
@@ -1,64 +1,11 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { z } from "zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { zodObject } from "@opencode-ai/core/effect-zod"
|
||||
import { TuiJsonSchema } from "../src/cli/cmd/tui/config/tui-json-schema"
|
||||
import { Schema } from "effect"
|
||||
import { TuiInfo } from "../src/cli/cmd/tui/config/tui-schema"
|
||||
|
||||
type JsonSchema = Record<string, unknown>
|
||||
|
||||
function generate(schema: z.ZodType) {
|
||||
const result = z.toJSONSchema(schema, {
|
||||
io: "input", // Generate input shape (treats optional().default() as not required)
|
||||
/**
|
||||
* We'll use the `default` values of the field as the only value in `examples`.
|
||||
* This will ensure no docs are needed to be read, as the configuration is
|
||||
* self-documenting.
|
||||
*
|
||||
* See https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-validation-00#rfc.section.9.5
|
||||
*/
|
||||
override(ctx) {
|
||||
const schema = ctx.jsonSchema
|
||||
|
||||
// Preserve strictness: set additionalProperties: false for objects
|
||||
if (
|
||||
schema &&
|
||||
typeof schema === "object" &&
|
||||
schema.type === "object" &&
|
||||
schema.additionalProperties === undefined
|
||||
) {
|
||||
schema.additionalProperties = false
|
||||
}
|
||||
|
||||
// Add examples and default descriptions for string fields with defaults
|
||||
if (schema && typeof schema === "object" && "type" in schema && schema.type === "string" && schema?.default) {
|
||||
if (!schema.examples) {
|
||||
schema.examples = [schema.default]
|
||||
}
|
||||
|
||||
schema.description = [schema.description || "", `default: \`${formatDefault(schema.default)}\``]
|
||||
.filter(Boolean)
|
||||
.join("\n\n")
|
||||
.trim()
|
||||
}
|
||||
},
|
||||
}) as Record<string, unknown> & {
|
||||
allowComments?: boolean
|
||||
allowTrailingCommas?: boolean
|
||||
}
|
||||
|
||||
// used for json lsps since config supports jsonc
|
||||
result.allowComments = true
|
||||
result.allowTrailingCommas = true
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function formatDefault(value: unknown) {
|
||||
if (typeof value !== "object" || value === null) return String(value)
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
const MODEL_REF = "https://models.dev/model-schema.json#/$defs/Model"
|
||||
|
||||
function generateEffect(schema: Schema.Top) {
|
||||
const document = Schema.toJsonSchemaDocument(schema)
|
||||
@@ -68,9 +15,11 @@ function generateEffect(schema: Schema.Top) {
|
||||
$defs: document.definitions,
|
||||
})
|
||||
if (!isRecord(normalized)) throw new Error("schema generator produced a non-object schema")
|
||||
normalized.allowComments = true
|
||||
normalized.allowTrailingCommas = true
|
||||
return normalized
|
||||
const restored = restoreModelRefs(normalized)
|
||||
if (!isRecord(restored)) throw new Error("schema generator produced a non-object schema")
|
||||
restored.allowComments = true
|
||||
restored.allowTrailingCommas = true
|
||||
return restored
|
||||
}
|
||||
|
||||
function normalize(value: unknown): unknown {
|
||||
@@ -100,6 +49,17 @@ function normalize(value: unknown): unknown {
|
||||
return schema
|
||||
}
|
||||
|
||||
function restoreModelRefs(value: unknown, key?: string): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => restoreModelRefs(item))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const schema = Object.fromEntries(Object.entries(value).map(([name, item]) => [name, restoreModelRefs(item, name)]))
|
||||
if ((key === "model" || key === "small_model") && schema.type === "string") {
|
||||
return { ...schema, $ref: MODEL_REF }
|
||||
}
|
||||
return schema
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonSchema {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
@@ -108,9 +68,9 @@ const configFile = process.argv[2]
|
||||
const tuiFile = process.argv[3]
|
||||
|
||||
console.log(configFile)
|
||||
await Bun.write(configFile, JSON.stringify(generate(zodObject(Config.Info).strict().meta({ ref: "Config" })), null, 2))
|
||||
await Bun.write(configFile, JSON.stringify(generateEffect(Config.Info), null, 2))
|
||||
|
||||
if (tuiFile) {
|
||||
console.log(tuiFile)
|
||||
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiJsonSchema.Info), null, 2))
|
||||
await Bun.write(tuiFile, JSON.stringify(generateEffect(TuiInfo), null, 2))
|
||||
}
|
||||
|
||||
@@ -57,17 +57,9 @@ Rules:
|
||||
- Avoid service-local `makeRuntime(...)` facades unless a file is still intentionally in the older migration phase
|
||||
- No `Layer.fresh` for normal per-directory isolation; use `InstanceState`
|
||||
|
||||
## Schema → Zod interop
|
||||
## Schema boundaries
|
||||
|
||||
When a service uses Effect Schema internally but needs Zod schemas for the HTTP layer, derive Zod from Schema using the `zod()` helper from `@opencode-ai/core/effect-zod`:
|
||||
|
||||
```ts
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
export const ZodInfo = zod(Info) // derives z.ZodType from Schema.Union
|
||||
```
|
||||
|
||||
See `Auth.ZodInfo` for the canonical example.
|
||||
Use Effect Schema directly at HTTP, tool, and AI SDK boundaries. For provider-facing JSON Schema, use a boundary-specific helper such as `ToolJsonSchema.fromSchema(...)`; do not reintroduce generic Effect Schema → Zod conversion.
|
||||
|
||||
## InstanceState init patterns
|
||||
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
# Schema migration
|
||||
|
||||
Practical reference for migrating data types in `packages/opencode` from
|
||||
Zod-first definitions to Effect Schema with Zod compatibility shims.
|
||||
Zod-first definitions to Effect Schema.
|
||||
|
||||
## Goal
|
||||
|
||||
Use Effect Schema as the source of truth for domain models, IDs, inputs,
|
||||
outputs, and typed errors. Keep Zod available at existing HTTP, tool, and
|
||||
compatibility boundaries by exposing a `.zod` static derived from the Effect
|
||||
schema via `@opencode-ai/core/effect-zod`.
|
||||
outputs, and typed errors. Prefer native Effect Schema, Standard Schema, and
|
||||
native JSON Schema generation at HTTP, tool, and AI SDK boundaries.
|
||||
|
||||
The long-term driver is `specs/effect/http-api.md` — once the HTTP server
|
||||
moves to `@effect/platform`, every Schema-first DTO can flow through
|
||||
`HttpApi` / `HttpRouter` without a zod translation layer, and the entire
|
||||
`effect-zod` walker plus every `.zod` static can be deleted.
|
||||
The long-term driver is `specs/effect/http-api.md`: Schema-first DTOs should
|
||||
flow through `HttpApi` / `HttpRouter` without a Zod translation layer.
|
||||
|
||||
## Preferred shapes
|
||||
|
||||
@@ -26,19 +23,16 @@ export class Info extends Schema.Class<Info>("Foo.Info")({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
enabled: Schema.Boolean,
|
||||
}) {
|
||||
static readonly zod = zod(Info)
|
||||
}
|
||||
}) {}
|
||||
```
|
||||
|
||||
If the class cannot reference itself cleanly during initialization, use the
|
||||
two-step `withStatics` pattern:
|
||||
If a schema needs local static helpers, use the two-step `withStatics` pattern:
|
||||
|
||||
```ts
|
||||
export const Info = Schema.Struct({
|
||||
id: FooID,
|
||||
name: Schema.String,
|
||||
}).pipe(withStatics((s) => ({ zod: zod(s) })))
|
||||
}).pipe(withStatics((s) => ({ decode: Schema.decodeUnknownOption(s) })))
|
||||
```
|
||||
|
||||
### Errors
|
||||
@@ -53,15 +47,13 @@ export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Foo
|
||||
|
||||
### IDs and branded leaf types
|
||||
|
||||
Keep branded/schema-backed IDs as Effect schemas and expose
|
||||
`static readonly zod` for compatibility when callers still expect Zod.
|
||||
Keep branded/schema-backed IDs as Effect schemas.
|
||||
|
||||
### Refinements
|
||||
|
||||
Reuse named refinements instead of re-spelling `z.number().int().positive()`
|
||||
in every schema. The `effect-zod` walker translates the Effect versions into
|
||||
the corresponding zod methods, so JSON Schema output (`type: integer`,
|
||||
`exclusiveMinimum`, `pattern`, `format: uuid`, …) is preserved.
|
||||
Reuse named refinements instead of re-spelling numeric or string constraints in
|
||||
every schema. Boundary JSON Schema helpers should normalize native Effect JSON
|
||||
Schema output only where a provider requires it.
|
||||
|
||||
```ts
|
||||
const PositiveInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0))
|
||||
@@ -69,18 +61,15 @@ const NonNegativeInt = Schema.Number.check(Schema.isInt()).check(Schema.isGreate
|
||||
const HexColor = Schema.String.check(Schema.isPattern(/^#[0-9a-fA-F]{6}$/))
|
||||
```
|
||||
|
||||
See `test/util/effect-zod.test.ts` for the full set of translated checks.
|
||||
|
||||
## Compatibility rule
|
||||
|
||||
During migration, route validators, tool parameters, and any existing
|
||||
Zod-based boundary should consume the derived `.zod` schema instead of
|
||||
During migration, route validators, tool parameters, and AI SDK schemas should
|
||||
consume Effect schemas directly or use a narrow boundary helper. Avoid
|
||||
maintaining a second hand-written Zod schema.
|
||||
|
||||
The default should be:
|
||||
|
||||
- Effect Schema owns the type
|
||||
- `.zod` exists only as a compatibility surface
|
||||
- new domain models should not start Zod-first unless there is a concrete
|
||||
boundary-specific need
|
||||
|
||||
@@ -89,27 +78,22 @@ The default should be:
|
||||
It is fine to keep a Zod-native schema temporarily when:
|
||||
|
||||
- the type is only used at an HTTP or tool boundary and is not reused elsewhere
|
||||
- the validator depends on Zod-only transforms or behavior not yet covered by `zod()`
|
||||
- the validator is part of an existing public API that explicitly accepts Zod
|
||||
- the migration would force unrelated churn across a large call graph
|
||||
|
||||
When this happens, prefer leaving a short note or TODO rather than silently
|
||||
creating a parallel schema source of truth.
|
||||
|
||||
## Escape hatches
|
||||
## Boundary helpers
|
||||
|
||||
The walker in `@opencode-ai/core/effect-zod` exposes two explicit escape hatches for
|
||||
cases the pure-Schema path cannot express. Each one stays in the codebase
|
||||
only as long as its upstream or local dependency requires it — inline
|
||||
comments document when each can be deleted.
|
||||
Use narrow helpers at concrete boundaries instead of a generic Schema → Zod bridge.
|
||||
|
||||
### `ZodOverride` annotation
|
||||
- Tool parameters: `ToolJsonSchema.fromSchema(...)` and `ToolJsonSchema.fromTool(...)`
|
||||
- Public config/TUI schemas: `packages/opencode/script/schema.ts`
|
||||
- AI SDK object generation: `Schema.toStandardSchemaV1(...)` plus `Schema.toStandardJSONSchemaV1(...)`
|
||||
|
||||
Replaces the entire derivation with a hand-crafted zod schema. Used when:
|
||||
|
||||
- the target carries external `$ref` metadata (e.g.
|
||||
`config/model-id.ts` points at `https://models.dev/...`)
|
||||
- the target is a zod-only schema that cannot yet be expressed as Schema
|
||||
(e.g. `ConfigAgent.Info`, `Log.Level`)
|
||||
Plugin tools are the main remaining intentional Zod boundary because the public
|
||||
plugin API exposes `tool.schema = z` and `args: z.ZodRawShape`.
|
||||
|
||||
### Local `DeepMutable<T>` in `config/config.ts`
|
||||
|
||||
@@ -133,7 +117,7 @@ Migrate in this order:
|
||||
2. Exported `Info`, `Input`, `Output`, and DTO types
|
||||
3. Tagged domain errors
|
||||
4. Service-local internal models
|
||||
5. Route and tool boundary validators that can switch to `.zod`
|
||||
5. Route and tool boundary validators that can switch to native Effect Schema helpers
|
||||
|
||||
This keeps shared types canonical first and makes boundary updates mostly
|
||||
mechanical.
|
||||
@@ -142,21 +126,18 @@ mechanical.
|
||||
|
||||
### `src/config/` ✅ complete
|
||||
|
||||
All of `packages/opencode/src/config/` has been migrated. Files that still
|
||||
import `z` do so only for local `ZodOverride` bridges or for `z.ZodType`
|
||||
type annotations — the `export const <Info|Spec>` values are all Effect
|
||||
Schema at source.
|
||||
All of `packages/opencode/src/config/` has been migrated. The `export const
|
||||
<Info|Spec>` values are all Effect Schema at source.
|
||||
|
||||
A file is considered "done" when:
|
||||
|
||||
- its exported schema values (`Info`, `Input`, `Event`, `Definition`, etc.)
|
||||
are authored as Effect Schema
|
||||
- any remaining zod is either a derived compat bridge (via `zod()` /
|
||||
`zodObject()`), a `z.ZodType` type annotation, or a documented
|
||||
`ZodOverride` escape hatch — never a hand-written parallel source of truth
|
||||
- any remaining Zod is an explicit boundary compatibility choice, not a
|
||||
hand-written parallel source of truth
|
||||
|
||||
Files that meet this bar but still carry a compat bridge are checked off
|
||||
with an inline note describing the bridge and what unblocks its removal.
|
||||
Files that meet this bar but still carry a compatibility boundary are checked
|
||||
off with an inline note describing the boundary and what unblocks its removal.
|
||||
|
||||
- [x] skills, formatter, console-state, mcp, lsp, permission (leaves), model-id, command, plugin, provider
|
||||
- [x] server, layout
|
||||
@@ -361,15 +342,8 @@ piecewise.
|
||||
- [ ] `src/util/update-schema.ts`
|
||||
- [ ] `src/worktree/index.ts`
|
||||
|
||||
### Do-not-migrate
|
||||
|
||||
- `src/util/effect-zod.ts` — the walker itself. Stays zod-importing forever
|
||||
(it's what emits zod from Schema). Goes away only when the `.zod`
|
||||
compatibility layer is no longer needed anywhere.
|
||||
|
||||
## Notes
|
||||
|
||||
- Use `@opencode-ai/core/effect-zod` for all Schema → Zod conversion.
|
||||
- Prefer one canonical schema definition. Avoid maintaining parallel Zod and
|
||||
Effect definitions for the same domain type.
|
||||
- Keep the migration incremental. Converting the domain model first is more
|
||||
|
||||
@@ -100,7 +100,7 @@ Verification:
|
||||
|
||||
- Audit `PathParameterSchemas` and `pathParameterSchema()` in `public.ts`.
|
||||
- Check source schemas in files like `packages/opencode/src/session/schema.ts`, `packages/opencode/src/permission/schema.ts`, and pty schema definitions.
|
||||
- Add or fix `ZodOverride` / OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||
- Add or fix OpenAPI-compatible annotations on branded ID schemas so generated path params include the same patterns without `public.ts` overrides.
|
||||
- Delete one path override only after generated OpenAPI is unchanged for that param.
|
||||
|
||||
Concrete first targets:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Config } from "@/config/config"
|
||||
import z from "zod"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
@@ -49,6 +48,12 @@ export const Info = Schema.Struct({
|
||||
}).annotate({ identifier: "Agent" })
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
const GeneratedAgent = Schema.Struct({
|
||||
identifier: Schema.String,
|
||||
whenToUse: Schema.String,
|
||||
systemPrompt: Schema.String,
|
||||
})
|
||||
|
||||
export interface Interface {
|
||||
readonly get: (agent: string) => Effect.Effect<Info>
|
||||
readonly list: () => Effect.Effect<Info[]>
|
||||
@@ -405,11 +410,10 @@ export const layer = Layer.effect(
|
||||
},
|
||||
],
|
||||
model: language,
|
||||
schema: z.object({
|
||||
identifier: z.string(),
|
||||
whenToUse: z.string(),
|
||||
systemPrompt: z.string(),
|
||||
}),
|
||||
schema: Object.assign(
|
||||
Schema.toStandardSchemaV1(GeneratedAgent),
|
||||
Schema.toStandardJSONSchemaV1(GeneratedAgent),
|
||||
),
|
||||
} satisfies Parameters<typeof generateObject>[0]
|
||||
|
||||
if (isOpenaiOauth) {
|
||||
|
||||
@@ -719,6 +719,7 @@ export const RunCommand = effectCmd({
|
||||
}
|
||||
}
|
||||
}
|
||||
return error
|
||||
}
|
||||
const cwd = args.attach ? (directory ?? sess.directory ?? (await current(sdk))) : (directory ?? root)
|
||||
const client = args.attach ? attachSDK(cwd) : sdk
|
||||
@@ -730,10 +731,7 @@ export const RunCommand = effectCmd({
|
||||
|
||||
if (!args.interactive) {
|
||||
const events = await client.event.subscribe()
|
||||
loop(client, events).catch((e) => {
|
||||
console.error(e)
|
||||
process.exit(1)
|
||||
})
|
||||
const completed = loop(client, events)
|
||||
|
||||
if (args.command) {
|
||||
await client.session.command({
|
||||
@@ -744,6 +742,8 @@ export const RunCommand = effectCmd({
|
||||
arguments: message,
|
||||
variant: args.variant,
|
||||
})
|
||||
const error = await completed
|
||||
if (error) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
@@ -755,6 +755,8 @@ export const RunCommand = effectCmd({
|
||||
variant: args.variant,
|
||||
parts: [...files, { type: "text", text: message }],
|
||||
})
|
||||
const error = await completed
|
||||
if (error) process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -989,7 +989,24 @@ export function Prompt(props: PromptProps) {
|
||||
}
|
||||
})
|
||||
|
||||
let submitting = false
|
||||
async function submit() {
|
||||
// Prevent overlapping invocations (e.g. a double-pressed Enter, or the
|
||||
// input's native onSubmit racing another dispatch). Without this guard,
|
||||
// a second call slips past the empty-input check before the first call
|
||||
// clears `store.prompt.input`, then awaits its own `session.create` and
|
||||
// ultimately reads the now-empty store — sending a phantom empty prompt
|
||||
// to a freshly created session.
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
return await submitInner()
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitInner() {
|
||||
setWarpNotice(undefined)
|
||||
|
||||
// IME: double-defer may fire before onContentChange flushes the last
|
||||
|
||||
@@ -3,33 +3,39 @@ export * as TuiKeybind from "./keybind"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import type { BindingCommandMap, BindingConfig, BindingDefaults } from "@opentui/keymap/extras"
|
||||
import z from "zod"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import { Schema } from "effect"
|
||||
|
||||
const KeyStroke = z
|
||||
.object({
|
||||
name: z.string(),
|
||||
ctrl: z.boolean().optional(),
|
||||
shift: z.boolean().optional(),
|
||||
meta: z.boolean().optional(),
|
||||
super: z.boolean().optional(),
|
||||
hyper: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
const KeyStroke = Schema.Struct({
|
||||
name: Schema.String,
|
||||
ctrl: Schema.optional(Schema.Boolean),
|
||||
shift: Schema.optional(Schema.Boolean),
|
||||
meta: Schema.optional(Schema.Boolean),
|
||||
super: Schema.optional(Schema.Boolean),
|
||||
hyper: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const BindingObject = z
|
||||
.object({
|
||||
key: z.union([z.string(), KeyStroke]),
|
||||
event: z.enum(["press", "release"]).optional(),
|
||||
preventDefault: z.boolean().optional(),
|
||||
fallthrough: z.boolean().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
const BindingObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
key: Schema.Union([Schema.String, KeyStroke]),
|
||||
event: Schema.optional(Schema.Literals(["press", "release"])),
|
||||
preventDefault: Schema.optional(Schema.Boolean),
|
||||
fallthrough: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const BindingItem = z.union([z.string(), KeyStroke, BindingObject])
|
||||
export const BindingValueSchema = z.union([z.literal(false), z.literal("none"), BindingItem, z.array(BindingItem)])
|
||||
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
|
||||
export const BindingValueSchema = Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Literal("none"),
|
||||
BindingItem,
|
||||
Schema.Array(BindingItem),
|
||||
])
|
||||
export type BindingValueSchema = DeepMutable<Schema.Schema.Type<typeof BindingValueSchema>>
|
||||
|
||||
type Definition = {
|
||||
default: z.input<typeof BindingValueSchema>
|
||||
default: BindingValueSchema
|
||||
description: string
|
||||
}
|
||||
|
||||
@@ -214,21 +220,17 @@ export const Definitions = {
|
||||
which_key_end: keybind("ctrl+alt+end", "Jump to last which-key binding"),
|
||||
} satisfies Record<string, Definition>
|
||||
|
||||
type KeybindName = keyof typeof Definitions & string
|
||||
type KeybindName = keyof typeof Definitions
|
||||
const KeybindNames = new Set<string>(Object.keys(Definitions))
|
||||
|
||||
const KeybindShape = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
BindingValueSchema.optional().default(item.default).describe(item.description),
|
||||
]),
|
||||
) as Record<KeybindName, z.ZodDefault<z.ZodOptional<typeof BindingValueSchema>>>
|
||||
|
||||
const KeybindOverrideShape = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [name, BindingValueSchema.optional().describe(item.description)]),
|
||||
) as Record<KeybindName, z.ZodOptional<typeof BindingValueSchema>>
|
||||
|
||||
export const Keybinds = z.strictObject(KeybindShape).describe("TUI keybinding configuration")
|
||||
export const KeybindOverrides = z.strictObject(KeybindOverrideShape).describe("TUI keybinding overrides")
|
||||
export const KeybindOverrides = Schema.Struct(
|
||||
Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
Schema.optional(BindingValueSchema).annotate({ description: item.description }),
|
||||
]),
|
||||
),
|
||||
).annotate({ description: "TUI keybinding overrides" })
|
||||
export const Descriptions = Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [name, item.description]),
|
||||
) as Record<KeybindName, string>
|
||||
@@ -387,8 +389,8 @@ const CommandDescriptions = Object.fromEntries(
|
||||
]),
|
||||
) as Record<string, string>
|
||||
|
||||
export type Keybinds = z.output<typeof Keybinds>
|
||||
export type KeybindOverrides = z.output<typeof KeybindOverrides>
|
||||
export type Keybinds = { [K in KeybindName]: BindingValueSchema }
|
||||
export type KeybindOverrides = Partial<Keybinds>
|
||||
export type BindingLookupView = {
|
||||
readonly bindings: readonly Binding<Renderable, KeyEvent>[]
|
||||
get(command: string): readonly Binding<Renderable, KeyEvent>[]
|
||||
@@ -402,6 +404,29 @@ export function toBindingConfig(keybinds: Keybinds): BindingConfig<Renderable, K
|
||||
return Object.fromEntries(Object.entries(keybinds)) as BindingConfig<Renderable, KeyEvent>
|
||||
}
|
||||
|
||||
const decodeBindingValue = Schema.decodeUnknownSync(BindingValueSchema)
|
||||
|
||||
export function defaultValue(name: KeybindName) {
|
||||
return Definitions[name].default
|
||||
}
|
||||
|
||||
export function parse(keybinds: KeybindOverrides): Keybinds {
|
||||
const invalid = unknownKeys(keybinds)
|
||||
if (invalid.length) throw new Error(`Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`)
|
||||
return Object.fromEntries(
|
||||
Object.entries(Definitions).map(([name, item]) => [
|
||||
name,
|
||||
decodeBindingValue(keybinds[name as KeybindName] ?? item.default),
|
||||
]),
|
||||
) as Keybinds
|
||||
}
|
||||
|
||||
export const Keybinds = { parse }
|
||||
|
||||
export function unknownKeys(input: object) {
|
||||
return Object.keys(input).filter((key) => !KeybindNames.has(key))
|
||||
}
|
||||
|
||||
export function bindingDefaults(): BindingDefaults<Renderable, KeyEvent> {
|
||||
return ({ command, binding }) => {
|
||||
if (binding.desc !== undefined) return
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { Schema } from "effect"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
|
||||
const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
|
||||
description: "Leader key timeout in milliseconds",
|
||||
})
|
||||
|
||||
const KeyStroke = Schema.Struct({
|
||||
name: Schema.String,
|
||||
ctrl: Schema.optional(Schema.Boolean),
|
||||
shift: Schema.optional(Schema.Boolean),
|
||||
meta: Schema.optional(Schema.Boolean),
|
||||
super: Schema.optional(Schema.Boolean),
|
||||
hyper: Schema.optional(Schema.Boolean),
|
||||
})
|
||||
|
||||
const BindingObject = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
key: Schema.Union([Schema.String, KeyStroke]),
|
||||
event: Schema.optional(Schema.Literals(["press", "release"])),
|
||||
preventDefault: Schema.optional(Schema.Boolean),
|
||||
fallthrough: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
|
||||
const BindingItem = Schema.Union([Schema.String, KeyStroke, BindingObject])
|
||||
const BindingValue = Schema.Union([
|
||||
Schema.Literal(false),
|
||||
Schema.Literal("none"),
|
||||
BindingItem,
|
||||
Schema.Array(BindingItem),
|
||||
])
|
||||
|
||||
const KeybindOverrides = Schema.Struct(
|
||||
Object.fromEntries(
|
||||
Object.entries(TuiKeybind.Definitions).map(([name, item]) => [
|
||||
name,
|
||||
Schema.optional(BindingValue).annotate({ description: item.description }),
|
||||
]),
|
||||
),
|
||||
).annotate({ description: "TUI keybinding overrides" })
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String),
|
||||
theme: Schema.optional(Schema.String),
|
||||
keybinds: Schema.optional(KeybindOverrides),
|
||||
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
|
||||
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
leader_timeout: Schema.optional(KeymapLeaderTimeout),
|
||||
scroll_speed: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))).annotate({
|
||||
description: "TUI scroll speed",
|
||||
}),
|
||||
scroll_acceleration: Schema.optional(
|
||||
Schema.Struct({
|
||||
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
|
||||
}),
|
||||
).annotate({ description: "Scroll acceleration settings" }),
|
||||
diff_style: Schema.optional(Schema.Literals(["auto", "stacked"])).annotate({
|
||||
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||
}),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
||||
})
|
||||
|
||||
export * as TuiJsonSchema from "./tui-json-schema"
|
||||
@@ -1,8 +1,8 @@
|
||||
import path from "path"
|
||||
import { type ParseError as JsoncParseError, applyEdits, modify, parse as parseJsonc } from "jsonc-parser"
|
||||
import { unique } from "remeda"
|
||||
import z from "zod"
|
||||
import { TuiInfo, TuiOptions } from "./tui-schema"
|
||||
import { Option, Schema } from "effect"
|
||||
import { DiffStyle, ScrollAcceleration, ScrollSpeed } from "./tui-schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -13,16 +13,11 @@ const log = Log.create({ service: "tui.migrate" })
|
||||
|
||||
const TUI_SCHEMA_URL = "https://opencode.ai/tui.json"
|
||||
|
||||
const LegacyTheme = TuiInfo.shape.theme.optional()
|
||||
const LegacyRecord = z.record(z.string(), z.unknown()).optional()
|
||||
|
||||
const TuiLegacy = z
|
||||
.object({
|
||||
scroll_speed: TuiOptions.shape.scroll_speed.catch(undefined),
|
||||
scroll_acceleration: TuiOptions.shape.scroll_acceleration.catch(undefined),
|
||||
diff_style: TuiOptions.shape.diff_style.catch(undefined),
|
||||
})
|
||||
.strip()
|
||||
const decodeTheme = Schema.decodeUnknownOption(Schema.String)
|
||||
const decodeRecord = Schema.decodeUnknownOption(Schema.Record(Schema.String, Schema.Unknown))
|
||||
const decodeScrollSpeed = Schema.decodeUnknownOption(ScrollSpeed)
|
||||
const decodeScrollAcceleration = Schema.decodeUnknownOption(ScrollAcceleration)
|
||||
const decodeDiffStyle = Schema.decodeUnknownOption(DiffStyle)
|
||||
|
||||
interface MigrateInput {
|
||||
cwd: string
|
||||
@@ -46,13 +41,13 @@ export async function migrateTuiConfig(input: MigrateInput) {
|
||||
const data = parseJsonc(source, errors, { allowTrailingComma: true })
|
||||
if (errors.length || !data || typeof data !== "object" || Array.isArray(data)) continue
|
||||
|
||||
const theme = LegacyTheme.safeParse("theme" in data ? data.theme : undefined)
|
||||
const keybinds = LegacyRecord.safeParse("keybinds" in data ? data.keybinds : undefined)
|
||||
const legacyTui = LegacyRecord.safeParse("tui" in data ? data.tui : undefined)
|
||||
const theme = decodeTheme("theme" in data ? data.theme : undefined)
|
||||
const keybinds = decodeRecord("keybinds" in data ? data.keybinds : undefined)
|
||||
const legacyTui = decodeRecord("tui" in data ? data.tui : undefined)
|
||||
const extracted = {
|
||||
theme: theme.success ? theme.data : undefined,
|
||||
keybinds: keybinds.success ? keybinds.data : undefined,
|
||||
tui: legacyTui.success ? legacyTui.data : undefined,
|
||||
theme: Option.getOrUndefined(theme),
|
||||
keybinds: Option.getOrUndefined(keybinds),
|
||||
tui: Option.getOrUndefined(legacyTui),
|
||||
}
|
||||
const tui = extracted.tui ? normalizeTui(extracted.tui) : undefined
|
||||
if (extracted.theme === undefined && extracted.keybinds === undefined && !tui) continue
|
||||
@@ -85,16 +80,23 @@ export async function migrateTuiConfig(input: MigrateInput) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeTui(data: Record<string, unknown>) {
|
||||
const parsed = TuiLegacy.parse(data)
|
||||
if (
|
||||
parsed.scroll_speed === undefined &&
|
||||
function normalizeTui(data: Record<string, unknown>):
|
||||
| {
|
||||
scroll_speed: number | undefined
|
||||
scroll_acceleration: { enabled: boolean } | undefined
|
||||
diff_style: "auto" | "stacked" | undefined
|
||||
}
|
||||
| undefined {
|
||||
const parsed = {
|
||||
scroll_speed: Option.getOrUndefined(decodeScrollSpeed(data.scroll_speed)),
|
||||
scroll_acceleration: Option.getOrUndefined(decodeScrollAcceleration(data.scroll_acceleration)),
|
||||
diff_style: Option.getOrUndefined(decodeDiffStyle(data.diff_style)),
|
||||
}
|
||||
return parsed.scroll_speed === undefined &&
|
||||
parsed.diff_style === undefined &&
|
||||
parsed.scroll_acceleration === undefined
|
||||
) {
|
||||
return
|
||||
}
|
||||
return parsed
|
||||
? undefined
|
||||
: parsed
|
||||
}
|
||||
|
||||
async function backupAndStripLegacy(file: string, source: string) {
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
import z from "zod"
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const KeymapLeaderTimeoutDefault = 2000
|
||||
const KeymapLeaderTimeout = z.number().int().positive().describe("Leader key timeout in milliseconds")
|
||||
|
||||
export const TuiOptions = z.object({
|
||||
leader_timeout: KeymapLeaderTimeout.optional(),
|
||||
scroll_speed: z.number().min(0.001).optional().describe("TUI scroll speed"),
|
||||
scroll_acceleration: z
|
||||
.object({
|
||||
enabled: z.boolean().describe("Enable scroll acceleration"),
|
||||
})
|
||||
.optional()
|
||||
.describe("Scroll acceleration settings"),
|
||||
diff_style: z
|
||||
.enum(["auto", "stacked"])
|
||||
.optional()
|
||||
.describe("Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column"),
|
||||
mouse: z.boolean().optional().describe("Enable or disable mouse capture (default: true)"),
|
||||
const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
|
||||
description: "Leader key timeout in milliseconds",
|
||||
})
|
||||
|
||||
export const TuiInfo = z
|
||||
.object({
|
||||
$schema: z.string().optional(),
|
||||
theme: z.string().optional(),
|
||||
keybinds: TuiKeybind.KeybindOverrides.optional(),
|
||||
plugin: ConfigPlugin.Spec.zod.array().optional(),
|
||||
plugin_enabled: z.record(z.string(), z.boolean()).optional(),
|
||||
})
|
||||
.extend(TuiOptions.shape)
|
||||
.strict()
|
||||
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
|
||||
|
||||
export const ScrollAcceleration = Schema.Struct({
|
||||
enabled: Schema.Boolean.annotate({ description: "Enable scroll acceleration" }),
|
||||
}).annotate({ description: "Scroll acceleration settings" })
|
||||
|
||||
export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
|
||||
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||
})
|
||||
|
||||
export const TuiInfo = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String),
|
||||
theme: Schema.optional(Schema.String),
|
||||
keybinds: Schema.optional(TuiKeybind.KeybindOverrides),
|
||||
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
|
||||
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
leader_timeout: Schema.optional(KeymapLeaderTimeout),
|
||||
scroll_speed: Schema.optional(ScrollSpeed).annotate({
|
||||
description: "TUI scroll speed",
|
||||
}),
|
||||
scroll_acceleration: Schema.optional(ScrollAcceleration),
|
||||
diff_style: Schema.optional(DiffStyle),
|
||||
mouse: Schema.optional(Schema.Boolean).annotate({ description: "Enable or disable mouse capture (default: true)" }),
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import type z from "zod"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer } from "effect"
|
||||
import { Context, Effect, Fiber, Layer, Schema } from "effect"
|
||||
import { ConfigParse } from "@/config/parse"
|
||||
import { InvalidError } from "@/config/error"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
@@ -22,11 +21,12 @@ import { Filesystem } from "@/util/filesystem"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
export const Info = TuiInfo
|
||||
export type Info = z.output<typeof Info>
|
||||
export type Info = DeepMutable<Schema.Schema.Type<typeof Info>>
|
||||
|
||||
type Acc = {
|
||||
result: Info
|
||||
@@ -91,9 +91,17 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
if (!isRecord(data)) return {} as Info
|
||||
// Flatten a nested "tui" key so users who wrote `{ "tui": { ... } }` inside tui.json
|
||||
// (mirroring the old opencode.json shape) still get their settings applied.
|
||||
const parsed = Info.safeParse(normalize(data))
|
||||
if (!parsed.success) throw new InvalidError({ path: configFilepath, issues: parsed.error.issues })
|
||||
const validated = parsed.data
|
||||
const normalized = normalize(data)
|
||||
if (isRecord(normalized.keybinds)) {
|
||||
const invalid = TuiKeybind.unknownKeys(normalized.keybinds)
|
||||
if (invalid.length) {
|
||||
throw new InvalidError({
|
||||
path: configFilepath,
|
||||
message: `Unrecognized keybind${invalid.length === 1 ? "" : "s"}: ${invalid.join(", ")}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
const validated = ConfigParse.schema(Info, normalized, configFilepath)
|
||||
return yield* resolvePlugins(validated, configFilepath)
|
||||
}).pipe(
|
||||
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
|
||||
@@ -179,16 +187,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
}
|
||||
}
|
||||
|
||||
const keybinds = { ...(acc.result.keybinds ?? {}) }
|
||||
const keybinds = { ...acc.result.keybinds }
|
||||
if (process.platform === "win32") {
|
||||
// Native Windows terminals do not support POSIX suspend, so prefer prompt undo.
|
||||
keybinds.terminal_suspend = "none"
|
||||
keybinds.input_undo ??= unique([
|
||||
"ctrl+z",
|
||||
...String(TuiKeybind.Keybinds.shape.input_undo.parse(undefined)).split(","),
|
||||
]).join(",")
|
||||
const inputUndo = TuiKeybind.defaultValue("input_undo")
|
||||
keybinds.input_undo ??= unique(["ctrl+z", ...(typeof inputUndo === "string" ? inputUndo.split(",") : [])]).join(",")
|
||||
}
|
||||
const parsedKeybinds = TuiKeybind.Keybinds.parse(keybinds)
|
||||
const parsedKeybinds = TuiKeybind.parse(keybinds)
|
||||
const result: Resolved = {
|
||||
...acc.result,
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
|
||||
|
||||
@@ -1,33 +1,36 @@
|
||||
import { Database } from "bun:sqlite"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import z from "zod"
|
||||
import { Option, Schema } from "effect"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import type { EditorSelection } from "./editor"
|
||||
|
||||
const ZedEditorRowSchema = z.object({
|
||||
item_kind: z.string(),
|
||||
editor_id: z.number().nullable(),
|
||||
workspace_id: z.number(),
|
||||
workspace_paths: z.string().nullable(),
|
||||
timestamp: z.string(),
|
||||
buffer_path: z.string().nullable(),
|
||||
const ZedEditorRowSchema = Schema.Struct({
|
||||
item_kind: Schema.String,
|
||||
editor_id: Schema.NullOr(Schema.Number),
|
||||
workspace_id: Schema.Number,
|
||||
workspace_paths: Schema.NullOr(Schema.String),
|
||||
timestamp: Schema.String,
|
||||
buffer_path: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const ZedSelectionRowSchema = z.object({
|
||||
selection_start: z.number().nullable(),
|
||||
selection_end: z.number().nullable(),
|
||||
const ZedSelectionRowSchema = Schema.Struct({
|
||||
selection_start: Schema.NullOr(Schema.Number),
|
||||
selection_end: Schema.NullOr(Schema.Number),
|
||||
})
|
||||
|
||||
const ZedEditorContentsSchema = z.object({
|
||||
contents: z.string().nullable(),
|
||||
const ZedEditorContentsSchema = Schema.Struct({
|
||||
contents: Schema.NullOr(Schema.String),
|
||||
})
|
||||
|
||||
const decodeZedEditorRow = Schema.decodeUnknownOption(ZedEditorRowSchema)
|
||||
const decodeZedSelectionRow = Schema.decodeUnknownOption(ZedSelectionRowSchema)
|
||||
const decodeZedEditorContents = Schema.decodeUnknownOption(ZedEditorContentsSchema)
|
||||
|
||||
const utf8 = new TextEncoder()
|
||||
|
||||
type ZedEditorRow = z.infer<typeof ZedEditorRowSchema>
|
||||
type ZedEditorRow = Schema.Schema.Type<typeof ZedEditorRowSchema>
|
||||
type ZedActiveEditorRow = ZedEditorRow & { item_kind: "Editor"; editor_id: number }
|
||||
type ZedSelectionRow = z.infer<typeof ZedSelectionRowSchema>
|
||||
|
||||
export type ZedSelectionResult =
|
||||
| { type: "selection"; selection: EditorSelection }
|
||||
@@ -107,8 +110,8 @@ function queryZedActiveEditor(dbPath: string, cwd: string) {
|
||||
.all()
|
||||
|
||||
const rows = raw.flatMap((row) => {
|
||||
const parsed = ZedEditorRowSchema.safeParse(row)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
const parsed = decodeZedEditorRow(row)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && rows.length === 0) return { type: "unavailable" as const }
|
||||
@@ -143,8 +146,8 @@ function queryZedEditorSelections(dbPath: string, row: ZedActiveEditorRow) {
|
||||
.all({ $editorID: row.editor_id, $workspaceID: row.workspace_id })
|
||||
|
||||
const selections = raw.flatMap((selection) => {
|
||||
const parsed = ZedSelectionRowSchema.safeParse(selection)
|
||||
return parsed.success ? [parsed.data] : []
|
||||
const parsed = decodeZedSelectionRow(selection)
|
||||
return Option.isSome(parsed) ? [parsed.value] : []
|
||||
})
|
||||
|
||||
if (raw.length > 0 && selections.length === 0) return { type: "unavailable" as const }
|
||||
@@ -160,7 +163,7 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
let db: Database | undefined
|
||||
try {
|
||||
db = new Database(dbPath, { readonly: true })
|
||||
const parsed = ZedEditorContentsSchema.safeParse(
|
||||
const parsed = decodeZedEditorContents(
|
||||
db
|
||||
.query(
|
||||
`select contents
|
||||
@@ -169,8 +172,8 @@ function queryZedEditorContents(dbPath: string, row: ZedActiveEditorRow) {
|
||||
)
|
||||
.get({ $editorID: row.editor_id, $workspaceID: row.workspace_id }),
|
||||
)
|
||||
if (!parsed.success) return { type: "unavailable" as const }
|
||||
return { type: "contents" as const, contents: parsed.data.contents }
|
||||
if (Option.isNone(parsed)) return { type: "unavailable" as const }
|
||||
return { type: "contents" as const, contents: parsed.value.contents }
|
||||
} catch {
|
||||
return { type: "unavailable" as const }
|
||||
} finally {
|
||||
|
||||
@@ -3,92 +3,102 @@ import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import z from "zod"
|
||||
import { Option, Schema, SchemaGetter } from "effect"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { resolveZedDbPath, resolveZedSelection } from "./editor-zed"
|
||||
|
||||
const MCP_PROTOCOL_VERSION = "2025-11-25"
|
||||
|
||||
const JsonRpcMessageSchema = z.object({
|
||||
id: z.union([z.number(), z.string(), z.null()]).optional(),
|
||||
method: z.string().optional(),
|
||||
params: z.unknown().optional(),
|
||||
result: z.unknown().optional(),
|
||||
error: z
|
||||
.object({
|
||||
code: z.number().optional(),
|
||||
message: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
const JsonRpcMessageSchema = Schema.Struct({
|
||||
id: Schema.optional(Schema.Union([Schema.Number, Schema.String, Schema.Null])),
|
||||
method: Schema.optional(Schema.String),
|
||||
params: Schema.optional(Schema.Unknown),
|
||||
result: Schema.optional(Schema.Unknown),
|
||||
error: Schema.optional(
|
||||
Schema.Struct({
|
||||
code: Schema.optional(Schema.Number),
|
||||
message: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const PositionSchema = z.object({
|
||||
line: z.number(),
|
||||
character: z.number(),
|
||||
const PositionSchema = Schema.Struct({
|
||||
line: Schema.Number,
|
||||
character: Schema.Number,
|
||||
})
|
||||
|
||||
const EditorSelectionRangeSchema = z.object({
|
||||
text: z.string(),
|
||||
selection: z.object({
|
||||
const EditorSelectionRangeSchema = Schema.Struct({
|
||||
text: Schema.String,
|
||||
selection: Schema.Struct({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
})
|
||||
|
||||
const EditorSelectionSchema = z
|
||||
.union([
|
||||
z.object({
|
||||
filePath: z.string(),
|
||||
source: z.enum(["websocket", "zed"]).optional(),
|
||||
ranges: z.array(EditorSelectionRangeSchema).min(1),
|
||||
}),
|
||||
z.object({
|
||||
text: z.string(),
|
||||
filePath: z.string(),
|
||||
source: z.enum(["websocket", "zed"]).optional(),
|
||||
selection: z.object({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
}),
|
||||
])
|
||||
.transform((value) =>
|
||||
"ranges" in value
|
||||
? value
|
||||
: {
|
||||
filePath: value.filePath,
|
||||
source: value.source,
|
||||
ranges: [
|
||||
{
|
||||
text: value.text,
|
||||
selection: value.selection,
|
||||
},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
const EditorMentionSchema = z.object({
|
||||
filePath: z.string(),
|
||||
lineStart: z.number(),
|
||||
lineEnd: z.number(),
|
||||
const EditorSelectionRangesSchema = Schema.Struct({
|
||||
filePath: Schema.String,
|
||||
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
|
||||
ranges: Schema.mutable(Schema.Array(EditorSelectionRangeSchema).check(Schema.isMinLength(1))),
|
||||
})
|
||||
|
||||
const EditorServerInfoSchema = z.object({
|
||||
protocolVersion: z.string().optional(),
|
||||
serverInfo: z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
version: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
const EditorSelectionSchema = Schema.Union([
|
||||
EditorSelectionRangesSchema,
|
||||
Schema.Struct({
|
||||
text: Schema.String,
|
||||
filePath: Schema.String,
|
||||
source: Schema.optional(Schema.Literals(["websocket", "zed"])),
|
||||
selection: Schema.Struct({
|
||||
start: PositionSchema,
|
||||
end: PositionSchema,
|
||||
}),
|
||||
}),
|
||||
]).pipe(
|
||||
Schema.decodeTo(EditorSelectionRangesSchema, {
|
||||
decode: SchemaGetter.transform((value) =>
|
||||
"ranges" in value
|
||||
? value
|
||||
: {
|
||||
filePath: value.filePath,
|
||||
source: value.source,
|
||||
ranges: [
|
||||
{
|
||||
text: value.text,
|
||||
selection: value.selection,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
encode: SchemaGetter.passthrough({ strict: false }),
|
||||
}),
|
||||
)
|
||||
|
||||
const EditorMentionSchema = Schema.Struct({
|
||||
filePath: Schema.String,
|
||||
lineStart: Schema.Number,
|
||||
lineEnd: Schema.Number,
|
||||
})
|
||||
|
||||
type JsonRpcMessage = z.infer<typeof JsonRpcMessageSchema>
|
||||
export type EditorSelection = z.infer<typeof EditorSelectionSchema>
|
||||
export type EditorMention = z.infer<typeof EditorMentionSchema>
|
||||
const EditorServerInfoSchema = Schema.Struct({
|
||||
protocolVersion: Schema.optional(Schema.String),
|
||||
serverInfo: Schema.optional(
|
||||
Schema.Struct({
|
||||
name: Schema.optional(Schema.String),
|
||||
version: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
const decodeJsonRpcMessage = Schema.decodeUnknownOption(JsonRpcMessageSchema)
|
||||
const decodeEditorSelection = Schema.decodeUnknownOption(EditorSelectionSchema)
|
||||
const decodeEditorMention = Schema.decodeUnknownOption(EditorMentionSchema)
|
||||
const decodeEditorServerInfo = Schema.decodeUnknownOption(EditorServerInfoSchema)
|
||||
|
||||
type JsonRpcMessage = Schema.Schema.Type<typeof JsonRpcMessageSchema>
|
||||
export type EditorSelection = Schema.Schema.Type<typeof EditorSelectionSchema>
|
||||
export type EditorMention = Schema.Schema.Type<typeof EditorMentionSchema>
|
||||
export type EditorLabelState = "pending" | "sent" | "none"
|
||||
type EditorServerInfo = z.infer<typeof EditorServerInfoSchema>
|
||||
type EditorServerInfo = Schema.Schema.Type<typeof EditorServerInfoSchema>
|
||||
|
||||
type EditorConnection = {
|
||||
url: string
|
||||
@@ -214,16 +224,15 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
const message = parseMessage(event.data)
|
||||
if (!message) return
|
||||
|
||||
const selection =
|
||||
message.method === "selection_changed" ? EditorSelectionSchema.safeParse(message.params) : undefined
|
||||
if (selection?.success) {
|
||||
setSelection({ ...selection.data, source: "websocket" })
|
||||
const selection = message.method === "selection_changed" ? decodeEditorSelection(message.params) : Option.none()
|
||||
if (Option.isSome(selection)) {
|
||||
setSelection({ ...selection.value, source: "websocket" })
|
||||
return
|
||||
}
|
||||
|
||||
const mention = message.method === "at_mentioned" ? EditorMentionSchema.safeParse(message.params) : undefined
|
||||
if (mention?.success) {
|
||||
mentionListeners.forEach((listener) => listener(mention.data))
|
||||
const mention = message.method === "at_mentioned" ? decodeEditorMention(message.params) : Option.none()
|
||||
if (Option.isSome(mention)) {
|
||||
mentionListeners.forEach((listener) => listener(mention.value))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -235,9 +244,9 @@ export const { use: useEditorContext, provider: EditorContextProvider } = create
|
||||
pending.delete(message.id)
|
||||
if (message.error) return
|
||||
|
||||
const initialize = method === "initialize" ? EditorServerInfoSchema.safeParse(message.result) : undefined
|
||||
if (initialize?.success) {
|
||||
setStore("server", initialize.data)
|
||||
const initialize = method === "initialize" ? decodeEditorServerInfo(message.result) : Option.none()
|
||||
if (Option.isSome(initialize)) {
|
||||
setStore("server", initialize.value)
|
||||
send({ method: "notifications/initialized" })
|
||||
return
|
||||
}
|
||||
@@ -447,7 +456,7 @@ function parseMessage(value: unknown) {
|
||||
if (typeof value !== "string") return
|
||||
|
||||
try {
|
||||
return JsonRpcMessageSchema.parse(JSON.parse(value))
|
||||
return Option.getOrUndefined(decodeJsonRpcMessage(JSON.parse(value)))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ import { EffectBridge } from "@/effect/bridge"
|
||||
import type { InstanceContext } from "@/project/instance"
|
||||
import { SessionID, MessageID } from "@/session/schema"
|
||||
import { Effect, Layer, Context, Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { MCP } from "../mcp"
|
||||
import { Skill } from "../skill"
|
||||
@@ -35,12 +33,11 @@ export const Info = Schema.Struct({
|
||||
model: Schema.optional(Schema.String),
|
||||
source: Schema.optional(Schema.Literals(["command", "mcp", "skill"])),
|
||||
// Some command templates are lazy promises from MCP prompt resolution.
|
||||
template: Schema.Unknown.annotate({ [ZodOverride]: z.promise(z.string()).or(z.string()) }),
|
||||
template: Schema.Unknown,
|
||||
subtask: Schema.optional(Schema.Boolean),
|
||||
hints: Schema.Array(Schema.String),
|
||||
}).annotate({ identifier: "Command" })
|
||||
|
||||
// for some reason zod is inferring `string` for z.promise(z.string()).or(z.string()) so we have to manually override it
|
||||
export type Info = Omit<Schema.Schema.Type<typeof Info>, "template"> & { template: Promise<string> | string }
|
||||
|
||||
export function hints(template: string) {
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
// The original Zod schema carried an external $ref pointing at the models.dev
|
||||
// JSON schema. That external reference is not a named SDK component — it is a
|
||||
// literal pointer to an outside schema — so the walker cannot re-derive it
|
||||
// from AST metadata. Preserve the exact original Zod via ZodOverride.
|
||||
export const ConfigModelID = Schema.String.annotate({
|
||||
[ZodOverride]: z.string().meta({ $ref: "https://models.dev/model-schema.json#/$defs/Model" }),
|
||||
})
|
||||
export const ConfigModelID = Schema.String
|
||||
|
||||
export type ConfigModelID = Schema.Schema.Type<typeof ConfigModelID>
|
||||
|
||||
@@ -2,8 +2,6 @@ import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { Schema } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { isPathPluginSpec, parsePluginSpecifier, resolvePathPluginTarget } from "@/plugin/shared"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
import { withStatics } from "@opencode-ai/core/schema"
|
||||
import path from "path"
|
||||
|
||||
export const Options = Schema.Record(Schema.String, Schema.Unknown)
|
||||
@@ -11,9 +9,7 @@ export type Options = Schema.Schema.Type<typeof Options>
|
||||
|
||||
// Spec is the user-config value: either just a plugin identifier, or the identifier plus inline options.
|
||||
// It answers "what should we load?" but says nothing about where that value came from.
|
||||
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))]).pipe(
|
||||
withStatics((s) => ({ zod: zod(s) })),
|
||||
)
|
||||
export const Spec = Schema.Union([Schema.String, Schema.mutable(Schema.Tuple([Schema.String, Options]))])
|
||||
export type Spec = Schema.Schema.Type<typeof Spec>
|
||||
|
||||
export type Scope = "global" | "local"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { createWrapper } from "@parcel/watcher/wrapper"
|
||||
import type ParcelWatcher from "@parcel/watcher"
|
||||
import { readdir } from "fs/promises"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Bus } from "@/bus"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -45,15 +44,11 @@ export function getReleaseType(current: string, latest: string): ReleaseType {
|
||||
return "patch"
|
||||
}
|
||||
|
||||
export const Info = z
|
||||
.object({
|
||||
version: z.string(),
|
||||
latest: z.string(),
|
||||
})
|
||||
.meta({
|
||||
ref: "InstallationInfo",
|
||||
})
|
||||
export type Info = z.infer<typeof Info>
|
||||
export const Info = Schema.Struct({
|
||||
version: Schema.String,
|
||||
latest: Schema.String,
|
||||
}).annotate({ identifier: "InstallationInfo" })
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import * as LSPClient from "./client"
|
||||
import path from "path"
|
||||
import { pathToFileURL, fileURLToPath } from "url"
|
||||
import * as LSPServer from "./server"
|
||||
import z from "zod"
|
||||
import { Config } from "@/config/config"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Process } from "@/util/process"
|
||||
@@ -14,7 +13,6 @@ import { Effect, Layer, Context, Schema } from "effect"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { containsPath } from "@/project/instance-context"
|
||||
import { NonNegativeInt } from "@opencode-ai/core/schema"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
const log = Log.create({ service: "lsp" })
|
||||
|
||||
@@ -56,9 +54,7 @@ export const Status = Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
root: Schema.String,
|
||||
status: Schema.Literals(["connected", "error"]).annotate({
|
||||
[ZodOverride]: z.union([z.literal("connected"), z.literal("error")]),
|
||||
}),
|
||||
status: Schema.Literals(["connected", "error"]),
|
||||
}).annotate({ identifier: "LSPStatus" })
|
||||
export type Status = typeof Status.Type
|
||||
|
||||
|
||||
@@ -1,33 +1,35 @@
|
||||
import path from "path"
|
||||
import z from "zod"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Effect, Layer, Context } from "effect"
|
||||
import { Effect, Layer, Context, Option, Schema } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
|
||||
export const Tokens = z.object({
|
||||
accessToken: z.string(),
|
||||
refreshToken: z.string().optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
scope: z.string().optional(),
|
||||
export const Tokens = Schema.Struct({
|
||||
accessToken: Schema.mutableKey(Schema.String),
|
||||
refreshToken: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
scope: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
})
|
||||
export type Tokens = z.infer<typeof Tokens>
|
||||
export type Tokens = Schema.Schema.Type<typeof Tokens>
|
||||
|
||||
export const ClientInfo = z.object({
|
||||
clientId: z.string(),
|
||||
clientSecret: z.string().optional(),
|
||||
clientIdIssuedAt: z.number().optional(),
|
||||
clientSecretExpiresAt: z.number().optional(),
|
||||
export const ClientInfo = Schema.Struct({
|
||||
clientId: Schema.mutableKey(Schema.String),
|
||||
clientSecret: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)),
|
||||
})
|
||||
export type ClientInfo = z.infer<typeof ClientInfo>
|
||||
export type ClientInfo = Schema.Schema.Type<typeof ClientInfo>
|
||||
|
||||
export const Entry = z.object({
|
||||
tokens: Tokens.optional(),
|
||||
clientInfo: ClientInfo.optional(),
|
||||
codeVerifier: z.string().optional(),
|
||||
oauthState: z.string().optional(),
|
||||
serverUrl: z.string().optional(),
|
||||
export const Entry = Schema.Struct({
|
||||
tokens: Schema.mutableKey(Schema.optional(Tokens)),
|
||||
clientInfo: Schema.mutableKey(Schema.optional(ClientInfo)),
|
||||
codeVerifier: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
oauthState: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
serverUrl: Schema.mutableKey(Schema.optional(Schema.String)),
|
||||
})
|
||||
export type Entry = z.infer<typeof Entry>
|
||||
export type Entry = Schema.Schema.Type<typeof Entry>
|
||||
|
||||
const decodeAuthData = Schema.decodeUnknownOption(Schema.Record(Schema.String, Entry))
|
||||
type AuthData = Record<string, Entry>
|
||||
|
||||
const filepath = path.join(Global.Path.data, "mcp-auth.json")
|
||||
|
||||
@@ -56,8 +58,8 @@ export const layer = Layer.effect(
|
||||
|
||||
const all = Effect.fn("McpAuth.all")(function* () {
|
||||
return yield* fs.readJson(filepath).pipe(
|
||||
Effect.map((data) => data as Record<string, Entry>),
|
||||
Effect.catch(() => Effect.succeed({} as Record<string, Entry>)),
|
||||
Effect.map((data): AuthData => Option.getOrElse(decodeAuthData(data), () => ({}) as AuthData) as AuthData),
|
||||
Effect.catch(() => Effect.succeed({} as AuthData)),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -93,7 +95,7 @@ export const layer = Layer.effect(
|
||||
yield* set(mcpName, entry, serverUrl)
|
||||
})
|
||||
|
||||
const clearField = <K extends keyof Entry>(field: K, spanName: string) =>
|
||||
const clearField = (field: keyof Entry, spanName: string) =>
|
||||
Effect.fn(`McpAuth.${spanName}`)(function* (mcpName: string) {
|
||||
const entry = yield* get(mcpName)
|
||||
if (entry) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import z from "zod"
|
||||
import { Schema } from "effect"
|
||||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { readFileSync } from "fs"
|
||||
@@ -7,12 +7,11 @@ import * as Bom from "../util/bom"
|
||||
|
||||
const log = Log.create({ service: "patch" })
|
||||
|
||||
// Schema definitions
|
||||
export const PatchSchema = z.object({
|
||||
patchText: z.string().describe("The full patch text that describes all changes to be made"),
|
||||
export const PatchSchema = Schema.Struct({
|
||||
patchText: Schema.String.annotate({ description: "The full patch text that describes all changes to be made" }),
|
||||
})
|
||||
|
||||
export type PatchParams = z.infer<typeof PatchSchema>
|
||||
export type PatchParams = Schema.Schema.Type<typeof PatchSchema>
|
||||
|
||||
// Core types matching the Rust implementation
|
||||
export interface ApplyPatchArgs {
|
||||
|
||||
@@ -1,50 +1,51 @@
|
||||
import { z } from "zod"
|
||||
import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import { Schema } from "effect"
|
||||
|
||||
export const schema = z.object({
|
||||
data: z.array(
|
||||
z.object({
|
||||
model_picker_enabled: z.boolean(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
export const schema = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
model_picker_enabled: Schema.Boolean,
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
// every version looks like: `{model.id}-YYYY-MM-DD`
|
||||
version: z.string(),
|
||||
supported_endpoints: z.array(z.string()).optional(),
|
||||
policy: z
|
||||
.object({
|
||||
state: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
capabilities: z.object({
|
||||
family: z.string(),
|
||||
limits: z.object({
|
||||
max_context_window_tokens: z.number(),
|
||||
max_output_tokens: z.number(),
|
||||
max_prompt_tokens: z.number(),
|
||||
vision: z
|
||||
.object({
|
||||
max_prompt_image_size: z.number(),
|
||||
max_prompt_images: z.number(),
|
||||
supported_media_types: z.array(z.string()),
|
||||
})
|
||||
.optional(),
|
||||
version: Schema.String,
|
||||
supported_endpoints: Schema.optional(Schema.Array(Schema.String)),
|
||||
policy: Schema.optional(
|
||||
Schema.Struct({
|
||||
state: Schema.optional(Schema.String),
|
||||
}),
|
||||
supports: z.object({
|
||||
adaptive_thinking: z.boolean().optional(),
|
||||
max_thinking_budget: z.number().optional(),
|
||||
min_thinking_budget: z.number().optional(),
|
||||
reasoning_effort: z.array(z.string()).optional(),
|
||||
streaming: z.boolean(),
|
||||
structured_outputs: z.boolean().optional(),
|
||||
tool_calls: z.boolean(),
|
||||
vision: z.boolean().optional(),
|
||||
),
|
||||
capabilities: Schema.Struct({
|
||||
family: Schema.String,
|
||||
limits: Schema.Struct({
|
||||
max_context_window_tokens: Schema.Number,
|
||||
max_output_tokens: Schema.Number,
|
||||
max_prompt_tokens: Schema.Number,
|
||||
vision: Schema.optional(
|
||||
Schema.Struct({
|
||||
max_prompt_image_size: Schema.Number,
|
||||
max_prompt_images: Schema.Number,
|
||||
supported_media_types: Schema.Array(Schema.String),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
supports: Schema.Struct({
|
||||
adaptive_thinking: Schema.optional(Schema.Boolean),
|
||||
max_thinking_budget: Schema.optional(Schema.Number),
|
||||
min_thinking_budget: Schema.optional(Schema.Number),
|
||||
reasoning_effort: Schema.optional(Schema.Array(Schema.String)),
|
||||
streaming: Schema.Boolean,
|
||||
structured_outputs: Schema.optional(Schema.Boolean),
|
||||
tool_calls: Schema.Boolean,
|
||||
vision: Schema.optional(Schema.Boolean),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
type Item = z.infer<typeof schema>["data"][number]
|
||||
type Item = Schema.Schema.Type<typeof schema>["data"][number]
|
||||
const decodeModels = Schema.decodeUnknownSync(schema)
|
||||
|
||||
function build(key: string, remote: Item, url: string, prev?: Model): Model {
|
||||
const reasoning =
|
||||
@@ -165,7 +166,7 @@ export async function get(
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch models: ${res.status}`)
|
||||
}
|
||||
return schema.parse(await res.json())
|
||||
return decodeModels(await res.json())
|
||||
})
|
||||
|
||||
const result = { ...existing }
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { ModelMessage, ToolResultPart } from "ai"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type { JSONSchema } from "zod/v4/core"
|
||||
import type * as Provider from "./provider"
|
||||
import type * as ModelsDev from "./models"
|
||||
import { iife } from "@/util/iife"
|
||||
@@ -1281,7 +1280,7 @@ export function maxOutputTokens(model: Provider.Model): number {
|
||||
return Math.min(model.limit.output, OUTPUT_TOKEN_MAX) || OUTPUT_TOKEN_MAX
|
||||
}
|
||||
|
||||
export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JSONSchema7): JSONSchema7 {
|
||||
export function schema(model: Provider.Model, schema: JSONSchema7): JSONSchema7 {
|
||||
/*
|
||||
if (["openai", "azure"].includes(providerID)) {
|
||||
if (schema.type === "object" && schema.properties) {
|
||||
@@ -1312,7 +1311,10 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS
|
||||
return result
|
||||
}
|
||||
|
||||
schema = sanitizeMoonshot(schema) as JSONSchema.BaseSchema | JSONSchema7
|
||||
const sanitized = sanitizeMoonshot(schema)
|
||||
if (typeof sanitized === "object" && sanitized !== null && !Array.isArray(sanitized)) {
|
||||
schema = sanitized
|
||||
}
|
||||
}
|
||||
|
||||
// Convert integer enums to string enums for Google/Gemini
|
||||
@@ -1394,7 +1396,7 @@ export function schema(model: Provider.Model, schema: JSONSchema.BaseSchema | JS
|
||||
schema = sanitizeGemini(schema)
|
||||
}
|
||||
|
||||
return schema as JSONSchema7
|
||||
return schema
|
||||
}
|
||||
|
||||
export * as ProviderTransform from "./transform"
|
||||
|
||||
@@ -5,8 +5,8 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { MCP } from "@/mcp"
|
||||
import { Project } from "@/project/project"
|
||||
import { Session } from "@/session/session"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { Worktree } from "@/worktree"
|
||||
import { Effect, Option } from "effect"
|
||||
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
|
||||
@@ -84,7 +84,7 @@ export const experimentalHandlers = HttpApiBuilder.group(InstanceHttpApi, "exper
|
||||
return list.map((item) => ({
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
parameters: EffectZod.toJsonSchema(item.parameters),
|
||||
parameters: ToolJsonSchema.fromTool(item),
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BusEvent } from "@/bus/bus-event"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import z from "zod"
|
||||
import { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError, convertToModelMessages, LoadAPIKeyError, type ModelMessage, type UIMessage } from "ai"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
@@ -55,7 +54,7 @@ export const APIError = namedSchemaError("APIError", {
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
metadata: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
})
|
||||
export type APIError = z.infer<typeof APIError.Schema>
|
||||
export type APIError = Schema.Schema.Type<typeof APIError.Schema>
|
||||
export const ContextOverflowError = namedSchemaError("ContextOverflowError", {
|
||||
message: Schema.String,
|
||||
responseBody: Schema.optional(Schema.String),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import path from "path"
|
||||
import os from "os"
|
||||
import * as EffectZod from "@opencode-ai/core/effect-zod"
|
||||
import { SessionID, MessageID, PartID } from "./schema"
|
||||
import { MessageV2 } from "./message-v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
@@ -21,6 +20,7 @@ import PROMPT_PLAN from "../session/prompt/plan.txt"
|
||||
import BUILD_SWITCH from "../session/prompt/build-switch.txt"
|
||||
import MAX_STEPS from "../session/prompt/max-steps.txt"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MCP } from "../mcp"
|
||||
import { LSP } from "@/lsp/lsp"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -565,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
||||
providerID: input.model.providerID,
|
||||
agent: input.agent,
|
||||
})) {
|
||||
const schema = ProviderTransform.schema(input.model, EffectZod.toJsonSchema(item.parameters))
|
||||
const schema = ProviderTransform.schema(input.model, ToolJsonSchema.fromTool(item))
|
||||
tools[item.id] = tool({
|
||||
description: item.description,
|
||||
inputSchema: jsonSchema(schema),
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import { JsonSchema, Schema } from "effect"
|
||||
import type * as Tool from "./tool"
|
||||
|
||||
type JsonObject = Record<string, unknown>
|
||||
const cache = new WeakMap<Schema.Top, JSONSchema7>()
|
||||
|
||||
export function fromSchema(schema: Schema.Top): JSONSchema7 {
|
||||
const cached = cache.get(schema)
|
||||
if (cached) return cached
|
||||
|
||||
const document = Schema.toJsonSchemaDocument(schema, { additionalProperties: true })
|
||||
const result = normalize({
|
||||
$schema: JsonSchema.META_SCHEMA_URI_DRAFT_2020_12,
|
||||
...document.schema,
|
||||
...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}),
|
||||
})
|
||||
const inlined = dropDefinitionsIfResolved(inlineLocalReferences(result))
|
||||
if (!isJsonSchema(inlined)) throw new Error("tool JSON Schema helper produced a non-schema value")
|
||||
cache.set(schema, inlined)
|
||||
return inlined
|
||||
}
|
||||
|
||||
export function fromTool(tool: Tool.Def): JSONSchema7 {
|
||||
return tool.jsonSchema ?? fromSchema(tool.parameters as Schema.Top)
|
||||
}
|
||||
|
||||
function normalize(value: unknown, options: { stripNull?: boolean } = {}): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => normalize(item))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const required = Array.isArray(value.required)
|
||||
? new Set(value.required.filter((item) => typeof item === "string"))
|
||||
: undefined
|
||||
const schema = Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [
|
||||
key,
|
||||
key === "properties" && isRecord(item)
|
||||
? Object.fromEntries(
|
||||
Object.entries(item).map(([name, property]) => [
|
||||
name,
|
||||
normalize(property, { stripNull: !required?.has(name) }),
|
||||
]),
|
||||
)
|
||||
: normalize(item),
|
||||
]),
|
||||
)
|
||||
|
||||
if (schema.additionalProperties === true) delete schema.additionalProperties
|
||||
|
||||
if (options.stripNull && Array.isArray(schema.anyOf)) {
|
||||
const withoutNull = schema.anyOf.filter((item) => !isRecord(item) || item.type !== "null")
|
||||
if (withoutNull.length !== schema.anyOf.length) return normalize({ ...schema, anyOf: withoutNull })
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.anyOf)) {
|
||||
const withoutNull = schema.anyOf
|
||||
const number = withoutNull.find((item) => isRecord(item) && item.type === "number")
|
||||
const nonFinite = withoutNull.filter(
|
||||
(item) => isRecord(item) && Array.isArray(item.enum) && item.enum.every((entry) => isNonFiniteNumber(entry)),
|
||||
)
|
||||
if (number && nonFinite.length === withoutNull.length - 1) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ ...number, ...rest })
|
||||
}
|
||||
|
||||
if (isEmptyStructUnion(withoutNull)) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ type: "object", properties: {}, ...rest })
|
||||
}
|
||||
|
||||
if (withoutNull.length === 1 && isRecord(withoutNull[0])) {
|
||||
const { anyOf: _, ...rest } = schema
|
||||
return normalize({ ...withoutNull[0], ...rest })
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(schema.allOf) && schema.allOf.every(isRecord) && canFlattenAllOf(schema.allOf, schema)) {
|
||||
const { allOf, ...rest } = schema
|
||||
return normalize({ ...Object.assign({}, ...allOf), ...rest })
|
||||
}
|
||||
|
||||
if (schema.type === "integer" && schema.maximum === undefined) {
|
||||
return { minimum: Number.MIN_SAFE_INTEGER, ...schema, maximum: Number.MAX_SAFE_INTEGER }
|
||||
}
|
||||
|
||||
return schema
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function isJsonSchema(value: unknown): value is JSONSchema7 {
|
||||
return typeof value === "boolean" || isRecord(value)
|
||||
}
|
||||
|
||||
function isNonFiniteNumber(value: unknown) {
|
||||
return value === "NaN" || value === "Infinity" || value === "-Infinity"
|
||||
}
|
||||
|
||||
function isEmptyStructUnion(items: unknown[]) {
|
||||
return (
|
||||
items.length === 2 &&
|
||||
items.some((item) => isRecord(item) && item.type === "object" && item.properties === undefined) &&
|
||||
items.some((item) => isRecord(item) && item.type === "array" && item.items === undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function canFlattenAllOf(allOf: JsonObject[], parent: JsonObject) {
|
||||
const keys = new Set(Object.keys(parent).filter((key) => key !== "allOf"))
|
||||
return allOf.every((item) =>
|
||||
Object.keys(item).every((key) => {
|
||||
if (keys.has(key)) return false
|
||||
keys.add(key)
|
||||
return true
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function inlineLocalReferences(value: unknown, definitions?: JsonObject, seen = new Set<string>()): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => inlineLocalReferences(item, definitions, seen))
|
||||
if (!isRecord(value)) return value
|
||||
|
||||
const localDefinitions = definitions ?? (isRecord(value.$defs) ? value.$defs : undefined)
|
||||
if (typeof value.$ref === "string" && localDefinitions) {
|
||||
const name = value.$ref.match(/^#\/\$defs\/(.+)$/)?.[1] ?? value.$ref.match(/^#\/definitions\/(.+)$/)?.[1]
|
||||
if (name && !seen.has(name)) {
|
||||
const target = localDefinitions[name]
|
||||
if (target) {
|
||||
const { $ref: _, ...rest } = value
|
||||
return inlineLocalReferences(
|
||||
{ ...(isRecord(target) ? target : {}), ...rest },
|
||||
localDefinitions,
|
||||
new Set(seen).add(name),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, item]) => [key, inlineLocalReferences(item, localDefinitions, seen)]),
|
||||
)
|
||||
}
|
||||
|
||||
function dropDefinitionsIfResolved(value: unknown): unknown {
|
||||
if (!isRecord(value) || hasLocalReference(value)) return value
|
||||
const { $defs: _, definitions: __, ...rest } = value
|
||||
return rest
|
||||
}
|
||||
|
||||
function hasLocalReference(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(hasLocalReference)
|
||||
if (!isRecord(value)) return false
|
||||
if (
|
||||
typeof value.$ref === "string" &&
|
||||
(value.$ref.startsWith("#/$defs/") || value.$ref.startsWith("#/definitions/"))
|
||||
) {
|
||||
return true
|
||||
}
|
||||
return Object.values(value).some(hasLocalReference)
|
||||
}
|
||||
|
||||
export * as ToolJsonSchema from "./json-schema"
|
||||
@@ -15,9 +15,9 @@ import { SkillTool } from "./skill"
|
||||
import * as Tool from "./tool"
|
||||
import { Config } from "@/config/config"
|
||||
import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin"
|
||||
import type { JSONSchema7, JSONSchema7Definition } from "@ai-sdk/provider"
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
import { Plugin } from "../plugin"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ProviderID, type ModelID } from "../provider/schema"
|
||||
@@ -137,17 +137,19 @@ export const layer: Layer.Layer<
|
||||
const custom: Tool.Def[] = []
|
||||
|
||||
function fromPlugin(id: string, def: ToolDefinition): Tool.Def {
|
||||
// Plugin tools define their args as a raw Zod shape. Wrap the
|
||||
// derived Zod object in a `Schema.declare` so it slots into the
|
||||
// Schema-typed framework, and annotate with `ZodOverride` so the
|
||||
// walker emits the original Zod object for LLM JSON Schema.
|
||||
const zodParams = z.object(def.args)
|
||||
const parameters = Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success).annotate({
|
||||
[ZodOverride]: zodParams,
|
||||
})
|
||||
// Plugin tools still expose Zod args publicly; keep that compatibility
|
||||
// boxed at the registry boundary and give the LLM the original JSON Schema.
|
||||
const entries = Object.entries(def.args)
|
||||
const allZod = entries.every((entry) => isZodType(entry[1]))
|
||||
const zodParams = allZod ? z.object(def.args) : undefined
|
||||
const jsonSchema = zodParams ? zodJsonSchema(zodParams) : legacyJsonSchema(entries)
|
||||
const parameters = zodParams
|
||||
? Schema.declare<unknown>((u): u is unknown => zodParams.safeParse(u).success)
|
||||
: Schema.Unknown
|
||||
return {
|
||||
id,
|
||||
parameters,
|
||||
jsonSchema,
|
||||
description: def.description,
|
||||
execute: (args, toolCtx) =>
|
||||
Effect.gen(function* () {
|
||||
@@ -323,8 +325,13 @@ export const layer: Layer.Layer<
|
||||
const output = {
|
||||
description: tool.description,
|
||||
parameters: tool.parameters,
|
||||
jsonSchema: tool.jsonSchema,
|
||||
}
|
||||
yield* plugin.trigger("tool.definition", { toolID: tool.id }, output)
|
||||
const jsonSchema =
|
||||
output.parameters === tool.parameters || output.jsonSchema !== tool.jsonSchema
|
||||
? output.jsonSchema
|
||||
: undefined
|
||||
return {
|
||||
id: tool.id,
|
||||
description: [
|
||||
@@ -335,6 +342,7 @@ export const layer: Layer.Layer<
|
||||
.filter(Boolean)
|
||||
.join("\n"),
|
||||
parameters: output.parameters,
|
||||
jsonSchema,
|
||||
execute: tool.execute,
|
||||
formatValidationError: tool.formatValidationError,
|
||||
}
|
||||
@@ -376,4 +384,50 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
),
|
||||
)
|
||||
|
||||
function isZodType(value: unknown): value is z.ZodType {
|
||||
return typeof value === "object" && value !== null && "_zod" in value
|
||||
}
|
||||
|
||||
function isJsonSchemaDefinition(value: unknown): value is JSONSchema7Definition {
|
||||
return typeof value === "boolean" || (typeof value === "object" && value !== null && !Array.isArray(value))
|
||||
}
|
||||
|
||||
function legacyJsonSchema(entries: [string, unknown][]): JSONSchema7 {
|
||||
const properties = Object.fromEntries(
|
||||
entries.filter((entry): entry is [string, JSONSchema7Definition] => isJsonSchemaDefinition(entry[1])),
|
||||
)
|
||||
return {
|
||||
type: "object",
|
||||
properties,
|
||||
required: Object.keys(properties),
|
||||
}
|
||||
}
|
||||
|
||||
function zodJsonSchema(schema: z.ZodType): JSONSchema7 {
|
||||
const result = normalizeZodJsonSchema(z.toJSONSchema(schema, { io: "input" }))
|
||||
if (!isJsonSchemaObject(result)) throw new Error("plugin tool Zod schema produced a non-object JSON Schema")
|
||||
const { $defs, ...rest } = result
|
||||
return (
|
||||
$defs && isJsonSchemaObject($defs) ? { ...rest, definitions: $defs as JSONSchema7["definitions"] } : rest
|
||||
) as JSONSchema7
|
||||
}
|
||||
|
||||
function normalizeZodJsonSchema(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map((item) => normalizeZodJsonSchema(item))
|
||||
if (typeof value !== "object" || value === null) return value
|
||||
return Object.fromEntries(
|
||||
Object.entries(value)
|
||||
.filter((entry) =>
|
||||
(entry[0] === "exclusiveMaximum" || entry[0] === "exclusiveMinimum") && typeof entry[1] === "boolean"
|
||||
? false
|
||||
: true,
|
||||
)
|
||||
.map(([key, item]) => [key, normalizeZodJsonSchema(item)]),
|
||||
)
|
||||
}
|
||||
|
||||
function isJsonSchemaObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
export * as ToolRegistry from "./registry"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { JSONSchema7 } from "@ai-sdk/provider"
|
||||
import type { MessageV2 } from "../session/message-v2"
|
||||
import type { Permission } from "../permission"
|
||||
import type { SessionID, MessageID } from "../session/schema"
|
||||
@@ -38,6 +39,7 @@ export interface Def<
|
||||
id: string
|
||||
description: string
|
||||
parameters: Parameters
|
||||
jsonSchema?: JSONSchema7
|
||||
execute(args: Schema.Schema.Type<Parameters>, ctx: Context): Effect.Effect<ExecuteResult<M>>
|
||||
formatValidationError?(error: unknown): string
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ const MAX_TIMEOUT = 120 * 1000 // 2 minutes
|
||||
export const Parameters = Schema.Struct({
|
||||
url: Schema.String.annotate({ description: "The URL to fetch content from" }),
|
||||
format: Schema.Literals(["text", "markdown", "html"])
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const)))
|
||||
.annotate({
|
||||
description: "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
}),
|
||||
default: "markdown",
|
||||
})
|
||||
.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("markdown" as const))),
|
||||
timeout: Schema.optional(Schema.Number).annotate({ description: "Optional timeout in seconds (max 120)" }),
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Schema } from "effect"
|
||||
import z from "zod"
|
||||
import { zod } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
/**
|
||||
* Create a Schema-backed NamedError-shaped class.
|
||||
@@ -11,22 +9,14 @@ import { zod } from "@opencode-ai/core/effect-zod"
|
||||
* OpenAPI/SDK output is byte-identical to the original NamedError schema.
|
||||
*
|
||||
* Preserves the existing surface:
|
||||
* - static `Schema` (Zod schema of the wire shape)
|
||||
* - static `Schema` (Effect schema of the wire shape)
|
||||
* - static `isInstance(x)`
|
||||
* - instance `toObject()` returning `{ name, data }`
|
||||
* - `new X({ ...data }, { cause })`
|
||||
*/
|
||||
export function namedSchemaError<Tag extends string, Fields extends Schema.Struct.Fields>(tag: Tag, fields: Fields) {
|
||||
// Wire shape matches the original NamedError output so the SDK stays stable.
|
||||
const dataSchema = Schema.Struct(fields)
|
||||
const wire = z
|
||||
.object({
|
||||
name: z.literal(tag),
|
||||
data: zod(dataSchema),
|
||||
})
|
||||
.meta({ ref: tag })
|
||||
|
||||
// Effect Schema for the wire shape — used by HttpApi OpenAPI generation.
|
||||
// Wire shape matches the original NamedError output so the SDK stays stable.
|
||||
const effectSchema = Schema.Struct({
|
||||
name: Schema.Literal(tag),
|
||||
data: dataSchema,
|
||||
@@ -35,7 +25,7 @@ export function namedSchemaError<Tag extends string, Fields extends Schema.Struc
|
||||
type Data = Schema.Schema.Type<typeof dataSchema>
|
||||
|
||||
class NamedSchemaError extends Error {
|
||||
static readonly Schema = wire
|
||||
static readonly Schema = effectSchema
|
||||
static readonly EffectSchema = effectSchema
|
||||
static readonly tag = tag
|
||||
public static isInstance(input: unknown): input is NamedSchemaError {
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
# Effect Test Migration Plan
|
||||
|
||||
This document describes how to move opencode tests out of Promise-land and into the shared `testEffect` pattern.
|
||||
|
||||
## Target Pattern
|
||||
|
||||
Every test file that exercises Effect services should have one local runner near the top:
|
||||
|
||||
```ts
|
||||
const it = testEffect(layer)
|
||||
```
|
||||
|
||||
Then each test should use one of the runner methods:
|
||||
|
||||
```ts
|
||||
it.effect("pure service behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* SomeService.Service
|
||||
expect(yield* service.run()).toEqual("ok")
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("instance-local behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// test.directory is a scoped temp opencode instance
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("live filesystem or process behavior", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
// real clock / fs / git / process work
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Use `it.effect` for pure Effect code that should run with `TestClock` and `TestConsole`.
|
||||
Use `it.instance` when the test needs one scoped opencode instance.
|
||||
Use `it.live` when the test depends on real time, filesystem mtimes, git, child processes, servers, file watchers, or OS behavior.
|
||||
|
||||
## Anti-Patterns To Remove
|
||||
|
||||
Avoid these in tests that already target Effect services:
|
||||
|
||||
- `test(..., async () => Effect.runPromise(...))`
|
||||
- local `run(...)`, `load(...)`, `svc(...)`, or `runtime.runPromise(...)` wrappers that only provide a layer
|
||||
- `tmpdir()` plus `WithInstance.provide(...)` in Promise test bodies
|
||||
- custom `ManagedRuntime.make(...)` in test files
|
||||
- Promise `try/catch` around Effect failures
|
||||
- `Promise.withResolvers`, `Bun.sleep`, or `setTimeout` for synchronization when `Deferred`, `Fiber`, or `Effect.sleep` can express the same behavior
|
||||
|
||||
Promise helpers are acceptable at the boundary for non-Effect APIs, but they should be yielded from an Effect body with `Effect.promise(...)` rather than becoming the test harness.
|
||||
|
||||
## Layer Rules
|
||||
|
||||
Compose tests from open service layers, not closed `defaultLayer` graphs when a dependency needs replacing.
|
||||
|
||||
Good:
|
||||
|
||||
```ts
|
||||
const layer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
Layer.provide(NpmTest.noop),
|
||||
)
|
||||
```
|
||||
|
||||
Avoid using a fully closed layer and hoping to override an inner dependency later. Once `Agent.defaultLayer` has already provided `Config.defaultLayer`, tests cannot cleanly swap the `Npm.Service` used by that config layer.
|
||||
|
||||
Prefer small reusable fake boundary layers in `test/fake/*`:
|
||||
|
||||
```ts
|
||||
AuthTest.empty
|
||||
AccountTest.empty
|
||||
NpmTest.noop
|
||||
SkillTest.empty
|
||||
ProviderTest.fake().layer
|
||||
```
|
||||
|
||||
Do not add generic test-layer builders until repeated local compositions prove the need. Shared fake boundary services are the first reusable unit. Pre-composed subtrees such as `AgentTest.withPlugins` should come later, only after the same graph appears in multiple files.
|
||||
|
||||
## Fixture Rules
|
||||
|
||||
Use Effect-aware fixtures from `test/fixture/fixture.ts`:
|
||||
|
||||
- `TestInstance` inside `it.instance(...)` for the current temp instance path
|
||||
- `tmpdirScoped(...)` inside `Effect.gen` for additional temp directories
|
||||
- `provideInstance(dir)(effect)` when one test needs to switch instance context
|
||||
- `provideTmpdirInstance((dir) => effect, options)` when a live test needs custom instance setup or multiple instance scopes
|
||||
- `disposeAllInstances()` in `afterEach` only for integration tests that intentionally touch shared instance registries
|
||||
|
||||
Use finalizers only as a temporary bridge for existing global mutations:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const previous = process.env.MY_FLAG
|
||||
process.env.MY_FLAG = "1"
|
||||
return previous
|
||||
}),
|
||||
() => testBody,
|
||||
(previous) =>
|
||||
Effect.sync(() => {
|
||||
if (previous === undefined) delete process.env.MY_FLAG
|
||||
else process.env.MY_FLAG = previous
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
TODO: eliminate this pattern over time. Tests should not toggle process-global flags or env vars when the behavior can be modeled with services. Prefer moving flag/env reads behind injectable services such as `Config.Service`, `Env.Service`, or focused test layers, then provide the desired test value through the layer graph instead of mutating `process.env` or `Global.Path`.
|
||||
|
||||
## Conversion Recipe
|
||||
|
||||
1. Identify the real service under test and its open `*.layer`.
|
||||
2. Build one top-level `layer` with real dependencies where they are relevant and `test/fake/*` layers at slow or external boundaries.
|
||||
3. Replace local Promise wrappers with Effect helpers:
|
||||
|
||||
```ts
|
||||
const run = Effect.fn("MyTest.run")(function* (input: Input) {
|
||||
const service = yield* MyService.Service
|
||||
return yield* service.run(input)
|
||||
})
|
||||
```
|
||||
|
||||
4. Convert `test(..., async () => { ... })` to `it.effect`, `it.instance`, or `it.live`.
|
||||
5. Move `await` calls inside `Effect.gen` as `yield*` calls.
|
||||
6. Replace `await using tmp = await tmpdir(...)` with `yield* tmpdirScoped(...)` when the temp directory is inside an Effect test.
|
||||
7. Replace `WithInstance.provide({ directory, fn })` with `it.instance(...)`, `provideInstance(directory)(effect)`, or `provideTmpdirInstance(...)`.
|
||||
8. Replace Promise failure assertions with Effect assertions:
|
||||
|
||||
```ts
|
||||
const exit = yield * run(input).pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
```
|
||||
|
||||
This is correct but still verbose. Track repeated assertion shapes during migration so we can add small test assertion helpers later instead of copying low-level `Exit` plumbing everywhere.
|
||||
|
||||
9. Keep concurrency concurrent by using `Effect.forkScoped`, `Fiber.join`, `Deferred`, or `Effect.all(..., { concurrency: "unbounded" })` instead of serializing formerly parallel Promise work.
|
||||
10. Run the focused test file and `bun typecheck` from `packages/opencode`.
|
||||
|
||||
## Good Examples
|
||||
|
||||
Use these files as models:
|
||||
|
||||
- `test/tool/write.test.ts`: strong `it.instance` tests, top-level `testEffect(...)`, and Effect-native test helpers.
|
||||
- `test/effect/instance-state.test.ts`: good `it.live` use for scoped directories, instance switching, reload/disposal, and concurrency.
|
||||
- `test/bus/bus-effect.test.ts`: good `Deferred`, streams, and scoped fibers.
|
||||
- `test/tool/truncation.test.ts`: good configured runners and concise live service tests.
|
||||
- `test/tool/repo_clone.test.ts`: good live git integration while staying inside Effect fixtures.
|
||||
- `test/server/httpapi-instance.test.ts`: good scoped integration layer setup and live HTTP assertions.
|
||||
- `test/account/service.test.ts`: good service-level live tests, `Effect.flip`, typed errors, and fake HTTP clients.
|
||||
- `test/agent/plugin-agent-regression.test.ts`: good example of open real service layers plus reusable fake boundary layers.
|
||||
|
||||
## Current Promise-Land Hotspots
|
||||
|
||||
Start with files that already exercise Effect services but still manually run Promises:
|
||||
|
||||
- `test/config/config.test.ts`: many `Effect.runPromise`, `tmpdir()`, and `WithInstance.provide(...)` patterns despite already having `const it = testEffect(layer)`.
|
||||
- `test/tool/shell.test.ts`: custom `ManagedRuntime`, Promise test helpers, and instance setup around shell execution.
|
||||
- `test/tool/edit.test.ts`: manual runtime helpers and Promise concurrency patterns that should become fibers/deferreds.
|
||||
- `test/session/messages-pagination.test.ts`: local Promise service facade over `Session.defaultLayer`.
|
||||
- `test/snapshot/snapshot.test.ts`: Promise helper with `provideInstance` around snapshot operations.
|
||||
- `test/file/index.test.ts`: Promise wrappers for `File.Service` plus repeated temp instance setup.
|
||||
- `test/provider/provider.test.ts`: `AppRuntime.runPromise` helpers and mutable env/config setup.
|
||||
- `test/project/vcs.test.ts`: Promise event waiting and `AppRuntime.runPromise` around VCS service calls.
|
||||
|
||||
## Migration Order
|
||||
|
||||
1. Convert one small file with straightforward service calls and no race behavior.
|
||||
2. Convert `config.test.ts` incrementally by cluster, not in one PR.
|
||||
3. Extract additional `test/fake/*` boundary layers only when a second test needs the same fake.
|
||||
4. Convert files with concurrency or watchers after the simple files, preserving timing semantics with `Deferred` and fibers.
|
||||
5. Leave pure non-Effect utility tests alone unless converting the underlying code to Effect.
|
||||
|
||||
## Claimable Checklist
|
||||
|
||||
Use this as a migration queue. Each checkbox should be safe for one agent or one PR unless the notes say otherwise. Agents should claim one item, convert only that file or cluster, run the focused test file, run `bun typecheck`, and update this checklist in the PR description or follow-up note.
|
||||
|
||||
- [ ] `test/file/index.test.ts`: straightforward service wrapper cleanup. Replace local Promise helpers with Effect helpers and use `it.instance` / `it.live` around existing temp instance cases.
|
||||
- [ ] `test/session/messages-pagination.test.ts`: convert the local `run(...)` / `svc(...)` facade to `testEffect(Session.defaultLayer...)` and direct service yields. Good early target.
|
||||
- [ ] `test/snapshot/snapshot.test.ts`: convert snapshot operations to `it.live` with `tmpdirScoped` / `provideInstance`. Keep git/filesystem behavior live.
|
||||
- [ ] `test/project/vcs.test.ts`: convert `AppRuntime.runPromise` service calls first. Leave event/watcher timing intact until the first Effect version is stable.
|
||||
- [ ] `test/provider/provider.test.ts` cluster 1: convert provider service tests that only read config/env and do not mutate global state heavily.
|
||||
- [ ] `test/provider/provider.test.ts` cluster 2: convert tests with env/config mutation after introducing or reusing service-backed test seams.
|
||||
- [ ] `test/tool/shell.test.ts`: replace custom `ManagedRuntime` with `testEffect`, keep as `it.live`, and preserve process behavior.
|
||||
- [ ] `test/tool/edit.test.ts` cluster 1: convert straightforward edit/read/write cases and remove manual runtime helpers.
|
||||
- [ ] `test/tool/edit.test.ts` cluster 2: convert concurrency/race tests using `Deferred`, fibers, and `Effect.all` without serializing behavior.
|
||||
- [ ] `test/config/config.test.ts` setup pass: replace inline fake layers with shared `test/fake/*` layers where possible and turn Promise helpers into Effect helpers.
|
||||
- [ ] `test/config/config.test.ts` cluster 1: convert simple config load/merge tests that only need one instance.
|
||||
- [ ] `test/config/config.test.ts` cluster 2: convert managed/global config tests that mutate `Global.Path` or managed config directories. Prefer service seams; use finalizers only as a bridge.
|
||||
- [ ] `test/config/config.test.ts` cluster 3: convert plugin/dependency tests after ensuring `NpmTest.noop` or explicit fake NPM layers are used.
|
||||
- [ ] `test/config/config.test.ts` cluster 4: convert remote/account/provider config tests after isolating auth/account/env dependencies through layers.
|
||||
- [ ] Audit remaining `Effect.runPromise` in `packages/opencode/test/**/*.ts` and create follow-up checklist entries for any missed files.
|
||||
- [ ] Audit remaining `WithInstance.provide` in `packages/opencode/test/**/*.ts` and convert cases that can use `it.instance` or `provideInstance` inside Effect.
|
||||
- [ ] Audit repeated `Exit` / `Cause` assertion shapes and propose `test/lib/effect-assert.ts` helpers if at least three files repeat the same pattern.
|
||||
|
||||
Parallelization notes:
|
||||
|
||||
- The first four items are mostly independent and good for separate worktrees.
|
||||
- `provider.test.ts`, `tool/edit.test.ts`, and `config.test.ts` should be split by cluster so agents do not edit the same file concurrently.
|
||||
- Any new fake boundary layer under `test/fake/*` should be small and independently useful. Do not add a fake just for one assertion unless it removes a real external dependency.
|
||||
- Do not combine assertion-helper design with file migrations. First collect repeated shapes, then add helpers in a separate pass.
|
||||
|
||||
Orchestration rules:
|
||||
|
||||
- Prefer supervised foreground agents for implementation. Background agents are acceptable for research-only surveys, but code migrations need a returned diff, focused test output, and local commit before moving on.
|
||||
- Create one worktree per claim and verify the branch/worktree path before edits. A status check should include `git status --short --branch` from the claimed worktree.
|
||||
- After an agent reports completion, the coordinator must independently inspect `git status`, run the focused test, run `bun typecheck`, and review the diff before pushing.
|
||||
- If an agent edits the wrong worktree, move the patch deliberately with `git diff` / `git apply`, then clean the accidental worktree before opening a PR.
|
||||
- Keep dependency setup boring. Prefer reusing existing installed dependencies via worktrees or symlinks over running a fresh `bun install` in a temporary path unless the native build path is known to work.
|
||||
- Do not delete worktrees with unpushed commits or uncommitted changes. Once a migration PR branch is pushed and clean, the local worktree can be removed while leaving the branch on the fork.
|
||||
|
||||
## Effectified Test Rough Edges
|
||||
|
||||
Track patterns that are technically Effect-native but still too noisy. These should become a second cleanup pass after the Promise-land migration is underway.
|
||||
|
||||
- Failure assertions against `Exit` / `Cause` are often verbose. Consider helpers such as `expectEffectFailure(effect)`, `expectTaggedError(effect, Tag)`, or custom Bun matchers if the same shapes repeat.
|
||||
- Some tests still need `Effect.promise(...)` around Node/Bun filesystem helpers. Prefer Effect platform services when the surrounding code already uses them, but do not block migrations on perfect filesystem abstraction.
|
||||
- Scoped global mutation with `process.env`, `Global.Path`, or flags should disappear behind injectable services over time.
|
||||
- Layer composition can be noisy when a test needs a real service subtree plus fake boundaries. Keep extracting small `test/fake/*` boundary layers before inventing larger builders.
|
||||
- Concurrency tests can become harder to read after replacing Promise resolvers with `Deferred` and fibers. Look for repeated patterns that deserve named helpers.
|
||||
@@ -1,9 +1,18 @@
|
||||
import { expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
import { ProviderTest } from "../fake/provider"
|
||||
import { SkillTest } from "../fake/skill"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
|
||||
|
||||
@@ -12,7 +21,24 @@ import { PLUGIN_AGENT } from "../fixture/agent-plugin.constants"
|
||||
// to verify plugin → config hook → Agent.list.
|
||||
const pluginUrl = pathToFileURL(path.join(import.meta.dir, "..", "fixture", "agent-plugin.ts")).href
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Agent.defaultLayer, Plugin.defaultLayer))
|
||||
const provider = ProviderTest.fake()
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(AccountTest.empty),
|
||||
Layer.provide(NpmTest.noop),
|
||||
)
|
||||
const pluginLayer = Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer))
|
||||
const agentLayer = Agent.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
Layer.provide(SkillTest.empty),
|
||||
Layer.provide(provider.layer),
|
||||
Layer.provide(pluginLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(agentLayer, pluginLayer))
|
||||
|
||||
it.instance(
|
||||
"plugin-registered agents appear in Agent.list",
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
// Regression test for the prompt submit race in
|
||||
// packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx (`submit`).
|
||||
//
|
||||
// Before the fix, two concurrent `submit()` calls (e.g. a double-pressed
|
||||
// Enter, or the input's native onSubmit racing another dispatch) each
|
||||
// passed the `if (!store.prompt.input) return false` guard, each
|
||||
// `await sdk.client.session.create(...)`, and each only captured
|
||||
// `inputText = store.prompt.input` AFTER that await. The first invocation
|
||||
// finished, sent the prompt, and cleared the store; the second invocation,
|
||||
// now past its await, read the cleared store and sent an empty prompt to a
|
||||
// second freshly-created session - leaving an orphaned session with the
|
||||
// user's actual text and a phantom session visible to the user containing
|
||||
// only an assistant reply.
|
||||
//
|
||||
// `submitMirror` below has the exact shape of the production `submit()`
|
||||
// after the fix: an in-flight `submitting` guard wraps the original body.
|
||||
// Two concurrent invocations must result in exactly one submission carrying
|
||||
// the user's text, with no empty-text submission.
|
||||
|
||||
type Store = { input: string }
|
||||
|
||||
type SubmitResult = { sessionID: string; text: string }
|
||||
|
||||
type Harness = {
|
||||
store: Store
|
||||
submissions: SubmitResult[]
|
||||
createSession(): Promise<string>
|
||||
sendPrompt(sessionID: string, text: string): Promise<void>
|
||||
}
|
||||
|
||||
function createHarness(opts: { sessionCreateDelayMs: number }): Harness {
|
||||
let sessionCounter = 0
|
||||
const submissions: SubmitResult[] = []
|
||||
|
||||
return {
|
||||
store: { input: "" },
|
||||
submissions,
|
||||
async createSession() {
|
||||
sessionCounter += 1
|
||||
const id = `ses_${sessionCounter}`
|
||||
await Bun.sleep(opts.sessionCreateDelayMs)
|
||||
return id
|
||||
},
|
||||
async sendPrompt(sessionID, text) {
|
||||
submissions.push({ sessionID, text })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createSubmit() {
|
||||
let submitting = false
|
||||
return async function submit(h: Harness) {
|
||||
if (submitting) return false
|
||||
submitting = true
|
||||
try {
|
||||
if (!h.store.input) return false
|
||||
const sessionID = await h.createSession()
|
||||
const inputText = h.store.input
|
||||
await h.sendPrompt(sessionID, inputText)
|
||||
h.store.input = ""
|
||||
return true
|
||||
} finally {
|
||||
submitting = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe("Prompt.submit race", () => {
|
||||
test("concurrent submits must not lose the user's text", async () => {
|
||||
const submit = createSubmit()
|
||||
const h = createHarness({ sessionCreateDelayMs: 5 })
|
||||
h.store.input = "Hello there."
|
||||
|
||||
// Two invocations back-to-back, mimicking a double-Enter.
|
||||
await Promise.all([submit(h), submit(h)])
|
||||
|
||||
// Every submission that did make it through must carry the actual user
|
||||
// text, and no submission may have an empty text payload.
|
||||
expect(h.submissions.every((s) => s.text === "Hello there.")).toBe(true)
|
||||
expect(h.submissions.some((s) => s.text === "")).toBe(false)
|
||||
})
|
||||
|
||||
test("a sequential second submit after clear is a no-op, not a phantom session", async () => {
|
||||
const submit = createSubmit()
|
||||
const h = createHarness({ sessionCreateDelayMs: 1 })
|
||||
h.store.input = "Hello there."
|
||||
|
||||
await submit(h)
|
||||
// After the first submission completes, the store is cleared; a second
|
||||
// Enter on an empty input must not create a phantom session.
|
||||
await submit(h)
|
||||
|
||||
expect(h.submissions).toHaveLength(1)
|
||||
expect(h.submissions[0].text).toBe("Hello there.")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { Account } from "../../src/account/account"
|
||||
|
||||
export const empty = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
|
||||
export * as AccountTest from "./account"
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Auth } from "../../src/auth"
|
||||
|
||||
export const empty = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
|
||||
export * as AuthTest from "./auth"
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import { Effect, Layer } from "effect"
|
||||
|
||||
export const noop = Layer.mock(Npm.Service)({
|
||||
install: () => Effect.void,
|
||||
})
|
||||
|
||||
export * as NpmTest from "./npm"
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Skill } from "../../src/skill"
|
||||
|
||||
export const empty = Layer.mock(Skill.Service)({
|
||||
dirs: () => Effect.succeed([]),
|
||||
})
|
||||
|
||||
export * as SkillTest from "./skill"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,161 +1,150 @@
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { parsePatch } from "diff"
|
||||
import { Effect } from "effect"
|
||||
import { Deferred, Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { FileWatcher } from "../../src/file/watcher"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { GlobalBus } from "../../src/bus/global"
|
||||
import { Git } from "../../src/git"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
|
||||
// Skip in CI — native @parcel/watcher binding needed
|
||||
const describeVcs = FileWatcher.hasNativeBinding() && !process.env.CI ? describe : describe.skip
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function withVcs(directory: string, body: () => Promise<void>) {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const watcher = yield* FileWatcher.Service
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* watcher.init()
|
||||
yield* vcs.init()
|
||||
}),
|
||||
)
|
||||
await Bun.sleep(500)
|
||||
await body()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function withVcsOnly(directory: string, body: () => Promise<void>) {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: async () => {
|
||||
await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.init()
|
||||
}),
|
||||
)
|
||||
await body()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
type BranchEvent = { directory?: string; payload: { type: string; properties: { branch?: string } } }
|
||||
const weird = process.platform === "win32" ? "space file.txt" : "tab\tfile.txt"
|
||||
|
||||
/** Wait for a Vcs.Event.BranchUpdated event on GlobalBus, with retry polling as fallback */
|
||||
function nextBranchUpdate(directory: string, timeout = 10_000) {
|
||||
return new Promise<string | undefined>((resolve, reject) => {
|
||||
let settled = false
|
||||
const layer = Layer.mergeAll(
|
||||
Vcs.layer.pipe(Layer.provideMerge(Git.defaultLayer), Layer.provideMerge(Bus.layer)),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
)
|
||||
const it = testEffect(layer)
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
GlobalBus.off("event", on)
|
||||
reject(new Error("timed out waiting for BranchUpdated event"))
|
||||
}, timeout)
|
||||
const git = Effect.fn("VcsTest.git")(function* (cwd: string, args: string[]) {
|
||||
const result = yield* Git.Service.use((git) => git.run(args, { cwd }))
|
||||
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
|
||||
})
|
||||
|
||||
function on(evt: BranchEvent) {
|
||||
if (evt.directory !== directory) return
|
||||
if (evt.payload.type !== Vcs.Event.BranchUpdated.type) return
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", on)
|
||||
resolve(evt.payload.properties.branch)
|
||||
}
|
||||
const write = Effect.fn("VcsTest.write")(function* (file: string, content: string) {
|
||||
yield* AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
|
||||
})
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
const remove = Effect.fn("VcsTest.remove")(function* (file: string) {
|
||||
yield* AppFileSystem.Service.use((fs) => fs.remove(file))
|
||||
})
|
||||
|
||||
const symlink = (target: string, file: string) => Effect.promise(() => fs.symlink(target, file))
|
||||
|
||||
const init = Effect.fn("VcsTest.init")(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
yield* vcs.init()
|
||||
return vcs
|
||||
})
|
||||
|
||||
const nextBranchUpdate = Effect.fn("VcsTest.nextBranchUpdate")(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const updated = yield* Deferred.make<string | undefined>()
|
||||
|
||||
const off = yield* bus.subscribeCallback(Vcs.Event.BranchUpdated, (evt) => {
|
||||
Effect.runSync(Deferred.succeed(updated, evt.properties.branch))
|
||||
})
|
||||
}
|
||||
yield* Effect.addFinalizer(() => Effect.sync(off))
|
||||
|
||||
return updated
|
||||
})
|
||||
|
||||
const publishHeadChangeUntil = Effect.fn("VcsTest.publishHeadChangeUntil")(function* (
|
||||
pending: Deferred.Deferred<string | undefined>,
|
||||
head: string,
|
||||
) {
|
||||
const bus = yield* Bus.Service
|
||||
for (let i = 0; i < 50; i++) {
|
||||
yield* bus.publish(FileWatcher.Event.Updated, { file: head, event: "change" })
|
||||
if (yield* Deferred.isDone(pending)) return
|
||||
yield* Effect.sleep("10 millis")
|
||||
}
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describeVcs("Vcs", () => {
|
||||
describe("Vcs", () => {
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
test("branch() returns current branch name", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
it.instance(
|
||||
"branch() returns current branch name",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* init()
|
||||
const branch = yield* vcs.branch()
|
||||
|
||||
await withVcs(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.branch()
|
||||
}),
|
||||
)
|
||||
expect(branch).toBeDefined()
|
||||
expect(typeof branch).toBe("string")
|
||||
})
|
||||
})
|
||||
expect(branch).toBeDefined()
|
||||
expect(typeof branch).toBe("string")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("branch() returns undefined for non-git directories", async () => {
|
||||
await using tmp = await tmpdir()
|
||||
it.instance("branch() returns undefined for non-git directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* init()
|
||||
const branch = yield* vcs.branch()
|
||||
|
||||
await withVcs(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.branch()
|
||||
}),
|
||||
)
|
||||
expect(branch).toBeUndefined()
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("publishes BranchUpdated when .git/HEAD changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const branch = `test-${Math.random().toString(36).slice(2)}`
|
||||
await $`git branch ${branch}`.cwd(tmp.path).quiet()
|
||||
it.instance(
|
||||
"publishes BranchUpdated when .git/HEAD changes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const branch = `test-${Math.random().toString(36).slice(2)}`
|
||||
yield* git(test.directory, ["branch", branch])
|
||||
|
||||
await withVcs(tmp.path, async () => {
|
||||
const pending = nextBranchUpdate(tmp.path)
|
||||
const vcs = yield* init()
|
||||
yield* vcs.branch()
|
||||
const pending = yield* nextBranchUpdate()
|
||||
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
const head = path.join(test.directory, ".git", "HEAD")
|
||||
yield* write(head, `ref: refs/heads/${branch}\n`)
|
||||
yield* publishHeadChangeUntil(pending, head)
|
||||
|
||||
const updated = await pending
|
||||
expect(updated).toBe(branch)
|
||||
})
|
||||
})
|
||||
const updated = yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
|
||||
expect(updated).toBe(branch)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("branch() reflects the new branch after HEAD change", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const branch = `test-${Math.random().toString(36).slice(2)}`
|
||||
await $`git branch ${branch}`.cwd(tmp.path).quiet()
|
||||
it.instance(
|
||||
"branch() reflects the new branch after HEAD change",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const branch = `test-${Math.random().toString(36).slice(2)}`
|
||||
yield* git(test.directory, ["branch", branch])
|
||||
|
||||
await withVcs(tmp.path, async () => {
|
||||
const pending = nextBranchUpdate(tmp.path)
|
||||
const vcs = yield* init()
|
||||
yield* vcs.branch()
|
||||
const pending = yield* nextBranchUpdate()
|
||||
|
||||
const head = path.join(tmp.path, ".git", "HEAD")
|
||||
await fs.writeFile(head, `ref: refs/heads/${branch}\n`)
|
||||
const head = path.join(test.directory, ".git", "HEAD")
|
||||
yield* write(head, `ref: refs/heads/${branch}\n`)
|
||||
yield* publishHeadChangeUntil(pending, head)
|
||||
yield* Deferred.await(pending).pipe(Effect.timeout("2 seconds"))
|
||||
|
||||
await pending
|
||||
const current = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.branch()
|
||||
}),
|
||||
)
|
||||
expect(current).toBe(branch)
|
||||
})
|
||||
})
|
||||
const current = yield* vcs.branch()
|
||||
expect(current).toBe(branch)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("Vcs diff", () => {
|
||||
@@ -163,177 +152,176 @@ describe("Vcs diff", () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
test("defaultBranch() falls back to main", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
it.instance(
|
||||
"defaultBranch() falls back to main",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* git(test.directory, ["branch", "-M", "main"])
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.defaultBranch()
|
||||
}),
|
||||
)
|
||||
expect(branch).toBe("main")
|
||||
})
|
||||
})
|
||||
const vcs = yield* init()
|
||||
const branch = yield* vcs.defaultBranch()
|
||||
|
||||
test("defaultBranch() uses init.defaultBranch when available", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M trunk`.cwd(tmp.path).quiet()
|
||||
await $`git config init.defaultBranch trunk`.cwd(tmp.path).quiet()
|
||||
expect(branch).toBe("main")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const branch = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.defaultBranch()
|
||||
}),
|
||||
)
|
||||
expect(branch).toBe("trunk")
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"defaultBranch() uses init.defaultBranch when available",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* git(test.directory, ["branch", "-M", "trunk"])
|
||||
yield* git(test.directory, ["config", "init.defaultBranch", "trunk"])
|
||||
|
||||
test("detects current branch from the active worktree", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await using wt = await tmpdir()
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
const dir = path.join(wt.path, "feature")
|
||||
await $`git worktree add -b feature/test ${dir} HEAD`.cwd(tmp.path).quiet()
|
||||
const vcs = yield* init()
|
||||
const branch = yield* vcs.defaultBranch()
|
||||
|
||||
await withVcsOnly(dir, async () => {
|
||||
const [branch, base] = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
|
||||
}),
|
||||
)
|
||||
expect(branch).toBe("trunk")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("detects current branch from the active worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const wt = yield* tmpdirScoped()
|
||||
yield* git(tmp, ["branch", "-M", "main"])
|
||||
const dir = path.join(wt, "feature")
|
||||
yield* git(tmp, ["worktree", "add", "-b", "feature/test", dir, "HEAD"])
|
||||
|
||||
const [branch, base] = yield* Effect.gen(function* () {
|
||||
const vcs = yield* init()
|
||||
return yield* Effect.all([vcs.branch(), vcs.defaultBranch()], { concurrency: 2 })
|
||||
}).pipe(provideInstance(dir))
|
||||
|
||||
expect(branch).toBeDefined()
|
||||
expect(branch).toBe("feature/test")
|
||||
expect(base).toBe("main")
|
||||
})
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
test("diff('git') returns uncommitted changes", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "original\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "changed\n", "utf-8")
|
||||
it.instance(
|
||||
"diff('git') returns uncommitted changes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* write(path.join(test.directory, "file.txt"), "original\n")
|
||||
yield* git(test.directory, ["add", "."])
|
||||
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
|
||||
yield* write(path.join(test.directory, "file.txt"), "changed\n")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("git")
|
||||
}),
|
||||
)
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "file.txt",
|
||||
status: "modified",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
|
||||
})
|
||||
})
|
||||
const vcs = yield* init()
|
||||
const diff = yield* vcs.diff("git")
|
||||
|
||||
test("diff('git') handles special filenames", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, weird), "hello\n", "utf-8")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "file.txt",
|
||||
status: "modified",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
expect(diff.find((item) => item.file === "file.txt")?.patch).toContain("diff --git")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("git")
|
||||
}),
|
||||
)
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
it.instance(
|
||||
"diff('git') handles special filenames",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* write(path.join(test.directory, weird), "hello\n")
|
||||
|
||||
test("diff('git') keeps batched patches aligned for type changes", async () => {
|
||||
if (process.platform === "win32") return
|
||||
const vcs = yield* init()
|
||||
const diff = yield* vcs.diff("git")
|
||||
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "a.txt"), "old\n", "utf-8")
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "old\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add files"`.cwd(tmp.path).quiet()
|
||||
await fs.unlink(path.join(tmp.path, "a.txt"))
|
||||
await fs.symlink("target", path.join(tmp.path, "a.txt"))
|
||||
await fs.writeFile(path.join(tmp.path, "b.txt"), "new\n", "utf-8")
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: weird,
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("git")
|
||||
}),
|
||||
)
|
||||
const a = diff.find((item) => item.file === "a.txt")
|
||||
const b = diff.find((item) => item.file === "b.txt")
|
||||
it.instance(
|
||||
"diff('git') keeps batched patches aligned for type changes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
if (process.platform === "win32") return
|
||||
|
||||
expect(a?.patch).toContain("deleted file mode")
|
||||
expect(a?.patch).toContain("new file mode")
|
||||
expect(b?.patch).toContain("+new")
|
||||
})
|
||||
})
|
||||
const test = yield* TestInstance
|
||||
yield* write(path.join(test.directory, "a.txt"), "old\n")
|
||||
yield* write(path.join(test.directory, "b.txt"), "old\n")
|
||||
yield* git(test.directory, ["add", "."])
|
||||
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add files"])
|
||||
yield* remove(path.join(test.directory, "a.txt"))
|
||||
yield* symlink("target", path.join(test.directory, "a.txt"))
|
||||
yield* write(path.join(test.directory, "b.txt"), "new\n")
|
||||
|
||||
test("diff('git') keeps carriage returns inside patch hunks", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "add file"`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n", "utf-8")
|
||||
const vcs = yield* init()
|
||||
const diff = yield* vcs.diff("git")
|
||||
const a = diff.find((item) => item.file === "a.txt")
|
||||
const b = diff.find((item) => item.file === "b.txt")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("git")
|
||||
}),
|
||||
)
|
||||
const file = diff.find((item) => item.file === "file.txt")
|
||||
expect(a?.patch).toContain("deleted file mode")
|
||||
expect(a?.patch).toContain("new file mode")
|
||||
expect(b?.patch).toContain("+new")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
expect(file?.patch).toContain(" same\rdiff --git inside")
|
||||
expect(file?.patch).toContain("-delete")
|
||||
expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
|
||||
})
|
||||
}, 20_000)
|
||||
it.instance(
|
||||
"diff('git') keeps carriage returns inside patch hunks",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* write(path.join(test.directory, "file.txt"), "keep\nsame\rdiff --git inside\ndelete\n")
|
||||
yield* git(test.directory, ["add", "."])
|
||||
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "add file"])
|
||||
yield* write(path.join(test.directory, "file.txt"), "keep\nadd\nsame\rdiff --git inside\n")
|
||||
|
||||
test("diff('branch') returns changes against default branch", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await $`git branch -M main`.cwd(tmp.path).quiet()
|
||||
await $`git checkout -b feature/test`.cwd(tmp.path).quiet()
|
||||
await fs.writeFile(path.join(tmp.path, "branch.txt"), "hello\n", "utf-8")
|
||||
await $`git add .`.cwd(tmp.path).quiet()
|
||||
await $`git commit --no-gpg-sign -m "branch file"`.cwd(tmp.path).quiet()
|
||||
const vcs = yield* init()
|
||||
const diff = yield* vcs.diff("git")
|
||||
const file = diff.find((item) => item.file === "file.txt")
|
||||
|
||||
await withVcsOnly(tmp.path, async () => {
|
||||
const diff = await AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const vcs = yield* Vcs.Service
|
||||
return yield* vcs.diff("branch")
|
||||
}),
|
||||
)
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "branch.txt",
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
expect(file?.patch).toContain(" same\rdiff --git inside")
|
||||
expect(file?.patch).toContain("-delete")
|
||||
expect(() => parsePatch(file?.patch ?? "")).not.toThrow()
|
||||
}),
|
||||
{ git: true },
|
||||
20_000,
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"diff('branch') returns changes against default branch",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* git(test.directory, ["branch", "-M", "main"])
|
||||
yield* git(test.directory, ["checkout", "-b", "feature/test"])
|
||||
yield* write(path.join(test.directory, "branch.txt"), "hello\n")
|
||||
yield* git(test.directory, ["add", "."])
|
||||
yield* git(test.directory, ["commit", "--no-gpg-sign", "-m", "branch file"])
|
||||
|
||||
const vcs = yield* init()
|
||||
const diff = yield* vcs.diff("branch")
|
||||
|
||||
expect(diff).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
file: "branch.txt",
|
||||
status: "added",
|
||||
}),
|
||||
]),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Env } from "../../src/env"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const env = makeRuntime(Env.Service, Env.defaultLayer)
|
||||
const set = (k: string, v: string) => env.runSync((svc) => svc.set(k, v))
|
||||
@@ -70,6 +71,8 @@ function paid(providers: Awaited<ReturnType<typeof list>>) {
|
||||
return Object.values(item.models).filter((model) => model.cost.input > 0).length
|
||||
}
|
||||
|
||||
const it = testEffect(Provider.defaultLayer)
|
||||
|
||||
test("provider loaded from env variable", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -515,144 +518,116 @@ test("defaultModel respects config model setting", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("provider with baseURL from config", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"custom-openai": {
|
||||
name: "Custom OpenAI",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"gpt-4": {
|
||||
name: "GPT-4",
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
it.instance(
|
||||
"provider with baseURL from config",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||
expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"custom-openai": {
|
||||
name: "Custom OpenAI",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"gpt-4": {
|
||||
name: "GPT-4",
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.openai.com/v1",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"model cost defaults to zero when not specified",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||
const model = providers[ProviderID.make("test-provider")].models["test-model"]
|
||||
expect(model.cost.input).toBe(0)
|
||||
expect(model.cost.output).toBe(0)
|
||||
expect(model.cost.cache.read).toBe(0)
|
||||
expect(model.cost.cache.write).toBe(0)
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
"test-provider": {
|
||||
name: "Test Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"test-model": {
|
||||
name: "Test Model",
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"model options are merged from existing model",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
options: {
|
||||
apiKey: "test-api-key",
|
||||
},
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
baseURL: "https://custom.openai.com/v1",
|
||||
customOption: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.make("custom-openai")]).toBeDefined()
|
||||
expect(providers[ProviderID.make("custom-openai")].options.baseURL).toBe("https://custom.openai.com/v1")
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test("model cost defaults to zero when not specified", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
"test-provider": {
|
||||
name: "Test Provider",
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
env: [],
|
||||
models: {
|
||||
"test-model": {
|
||||
name: "Test Model",
|
||||
tool_call: true,
|
||||
limit: { context: 128000, output: 4096 },
|
||||
},
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-key",
|
||||
},
|
||||
},
|
||||
it.instance(
|
||||
"provider removed when all models filtered out",
|
||||
Effect.gen(function* () {
|
||||
const providers = yield* Provider.Service.use((provider) => provider.list())
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
provider: {
|
||||
anthropic: {
|
||||
options: {
|
||||
apiKey: "test-api-key",
|
||||
},
|
||||
}),
|
||||
)
|
||||
whitelist: ["nonexistent-model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.make("test-provider")].models["test-model"]
|
||||
expect(model.cost.input).toBe(0)
|
||||
expect(model.cost.output).toBe(0)
|
||||
expect(model.cost.cache.read).toBe(0)
|
||||
expect(model.cost.cache.write).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("model options are merged from existing model", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
models: {
|
||||
"claude-sonnet-4-20250514": {
|
||||
options: {
|
||||
customOption: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = await list()
|
||||
const model = providers[ProviderID.anthropic].models["claude-sonnet-4-20250514"]
|
||||
expect(model.options.customOption).toBe("custom-value")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("provider removed when all models filtered out", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
provider: {
|
||||
anthropic: {
|
||||
whitelist: ["nonexistent-model"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("ANTHROPIC_API_KEY", "test-api-key")
|
||||
const providers = await list()
|
||||
expect(providers[ProviderID.anthropic]).toBeUndefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
test("closest finds model by partial match", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"
|
||||
import type { NamedError } from "@opencode-ai/core/util/error"
|
||||
import { APICallError } from "ai"
|
||||
import { setTimeout as sleep } from "node:timers/promises"
|
||||
import { Effect, Layer, Schedule } from "effect"
|
||||
import { Effect, Layer, Schedule, Schema } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { SessionRetry } from "../../src/session/retry"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
@@ -17,7 +17,7 @@ const retryProvider = "test"
|
||||
const it = testEffect(Layer.mergeAll(SessionStatus.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function apiError(headers?: Record<string, string>): MessageV2.APIError {
|
||||
return MessageV2.APIError.Schema.parse(
|
||||
return Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "boom",
|
||||
isRetryable: true,
|
||||
@@ -94,7 +94,7 @@ describe("session.retry.delay", () => {
|
||||
const step = yield* Schedule.toStepWithMetadata(
|
||||
SessionRetry.policy({
|
||||
provider: "test",
|
||||
parse: (err) => MessageV2.APIError.Schema.parse(err),
|
||||
parse: Schema.decodeUnknownSync(MessageV2.APIError.Schema),
|
||||
set: (info) =>
|
||||
status.set(sessionID, {
|
||||
type: "retry",
|
||||
@@ -173,7 +173,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries 500 errors even when isRetryable is false", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Internal server error",
|
||||
isRetryable: false,
|
||||
@@ -186,7 +186,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries 502 bad gateway errors", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Bad gateway",
|
||||
isRetryable: false,
|
||||
@@ -198,7 +198,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries 503 service unavailable errors", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Service unavailable",
|
||||
isRetryable: false,
|
||||
@@ -210,7 +210,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("does not retry 4xx errors when isRetryable is false", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Bad request",
|
||||
isRetryable: false,
|
||||
@@ -222,7 +222,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("retries ZlibError decompression failures", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Response decompression failed",
|
||||
isRetryable: true,
|
||||
@@ -236,7 +236,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("maps free limits to Go upsell action", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Free usage exceeded",
|
||||
isRetryable: true,
|
||||
@@ -262,7 +262,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("maps Go subscription limits to workspace PAYG upsell", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Subscription quota exceeded. You can continue using free models.",
|
||||
isRetryable: true,
|
||||
@@ -300,7 +300,7 @@ describe("session.retry.retryable", () => {
|
||||
})
|
||||
|
||||
test("maps Go subscription limits without limit metadata", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Subscription quota exceeded. You can continue using free models.",
|
||||
isRetryable: true,
|
||||
@@ -366,7 +366,7 @@ describe("session.message-v2.fromError", () => {
|
||||
)
|
||||
|
||||
test("ECONNRESET socket error is retryable", () => {
|
||||
const error = MessageV2.APIError.Schema.parse(
|
||||
const error = Schema.decodeUnknownSync(MessageV2.APIError.Schema)(
|
||||
new MessageV2.APIError({
|
||||
message: "Connection reset by server",
|
||||
isRetryable: true,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,6 +45,7 @@ Output: Creates directory 'foo'"
|
||||
"description": "Optional timeout in milliseconds",
|
||||
"exclusiveMinimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer",
|
||||
},
|
||||
"workdir": {
|
||||
@@ -240,7 +241,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionOption",
|
||||
"required": [
|
||||
"label",
|
||||
"description",
|
||||
@@ -254,7 +254,6 @@ exports[`tool parameters JSON Schema (wire shape) question 1`] = `
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
"ref": "QuestionPrompt",
|
||||
"required": [
|
||||
"question",
|
||||
"header",
|
||||
@@ -393,14 +392,21 @@ exports[`tool parameters JSON Schema (wire shape) webfetch 1`] = `
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"properties": {
|
||||
"format": {
|
||||
"default": "markdown",
|
||||
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"html",
|
||||
"anyOf": [
|
||||
{
|
||||
"default": "markdown",
|
||||
"description": "The format to return the content in (text, markdown, or html). Defaults to markdown.",
|
||||
"enum": [
|
||||
"text",
|
||||
"markdown",
|
||||
"html",
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
{
|
||||
"type": "null",
|
||||
},
|
||||
],
|
||||
"type": "string",
|
||||
},
|
||||
"timeout": {
|
||||
"description": "Optional timeout in seconds (max 120)",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Result, Schema } from "effect"
|
||||
import { toJsonSchema } from "@opencode-ai/core/effect-zod"
|
||||
import { ToolJsonSchema } from "../../src/tool/json-schema"
|
||||
|
||||
// Each tool exports its parameters schema at module scope so this test can
|
||||
// import them without running the tool's Effect-based init. The JSON Schema
|
||||
// snapshot captures what the LLM sees; the parse assertions pin down the
|
||||
// accepts/rejects contract. `toJsonSchema` is the same helper `session/
|
||||
// accepts/rejects contract. `ToolJsonSchema.fromSchema` is the same helper `session/
|
||||
// prompt.ts` uses to emit tool schemas to the LLM, so the snapshots stay
|
||||
// byte-identical regardless of whether a tool has migrated from zod to Schema.
|
||||
// provider-compatible while tools use Effect Schema internally.
|
||||
|
||||
import { Parameters as ApplyPatch } from "../../src/tool/apply_patch"
|
||||
import { Parameters as Edit } from "../../src/tool/edit"
|
||||
@@ -32,6 +32,8 @@ const parse = <S extends Schema.Decoder<unknown>>(schema: S, input: unknown): S[
|
||||
const accepts = (schema: Schema.Decoder<unknown>, input: unknown): boolean =>
|
||||
Result.isSuccess(Schema.decodeUnknownResult(schema)(input))
|
||||
|
||||
const toJsonSchema = ToolJsonSchema.fromSchema
|
||||
|
||||
describe("tool parameters", () => {
|
||||
describe("JSON Schema (wire shape)", () => {
|
||||
test("apply_patch", () => expect(toJsonSchema(ApplyPatch)).toMatchSnapshot())
|
||||
@@ -50,6 +52,36 @@ describe("tool parameters", () => {
|
||||
test("webfetch", () => expect(toJsonSchema(WebFetch)).toMatchSnapshot())
|
||||
test("websearch", () => expect(toJsonSchema(WebSearch)).toMatchSnapshot())
|
||||
test("write", () => expect(toJsonSchema(Write)).toMatchSnapshot())
|
||||
|
||||
test("inlines named child schemas for provider compatibility", () => {
|
||||
const schema = toJsonSchema(Question)
|
||||
expect(schema).not.toHaveProperty("$defs")
|
||||
expect(schema).toMatchObject({
|
||||
properties: {
|
||||
questions: { items: { properties: { options: { items: { properties: { label: { type: "string" } } } } } } },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("preserves required nullable fields", () => {
|
||||
expect(toJsonSchema(Schema.Struct({ value: Schema.NullOr(Schema.String) }))).toMatchObject({
|
||||
properties: { value: { anyOf: expect.arrayContaining([{ type: "null" }]) } },
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps repeated allOf constraints instead of dropping duplicates", () => {
|
||||
expect(
|
||||
toJsonSchema(
|
||||
Schema.Struct({ value: Schema.String.check(Schema.isPattern(/^a/)).check(Schema.isPattern(/z$/)) }),
|
||||
),
|
||||
).toMatchObject({ properties: { value: { allOf: [{ pattern: "^a" }, { pattern: "z$" }] } } })
|
||||
})
|
||||
|
||||
test("bounds bare integer fields to safe integer range", () => {
|
||||
expect(toJsonSchema(Schema.Struct({ value: Schema.Int }))).toMatchObject({
|
||||
properties: { value: { minimum: Number.MIN_SAFE_INTEGER, maximum: Number.MAX_SAFE_INTEGER } },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("apply_patch", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Result, Schema } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
@@ -26,6 +27,8 @@ import { Ripgrep } from "@/file/ripgrep"
|
||||
import * as Truncate from "@/tool/truncate"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||
@@ -55,7 +58,7 @@ const registryLayer = ToolRegistry.layer.pipe(
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(registryLayer, node))
|
||||
const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
|
||||
@@ -141,6 +144,89 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const customTools = path.join(test.directory, ".opencode", "tools")
|
||||
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "sql.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'query database',",
|
||||
" args: { query: tool.schema.string().describe('SQL query to execute') },",
|
||||
" execute: async ({ query }) => query,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
|
||||
if (!loaded) throw new Error("custom sql tool was not loaded")
|
||||
expect(loaded?.jsonSchema).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
query: { type: "string", description: "SQL query to execute" },
|
||||
},
|
||||
required: ["query"],
|
||||
})
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({ query: "select 1" }))).toBe(true)
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
const promptTools = yield* registry.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("test"),
|
||||
agent: yield* agents.get(yield* agents.defaultAgent()),
|
||||
})
|
||||
const promptTool = promptTools.find((tool) => tool.id === "sql")
|
||||
if (!promptTool) throw new Error("custom sql tool was not returned for prompts")
|
||||
expect(ToolJsonSchema.fromTool(promptTool)).toMatchObject({
|
||||
properties: {
|
||||
query: { type: "string", description: "SQL query to execute" },
|
||||
},
|
||||
required: ["query"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const tools = path.join(test.directory, ".opencode", "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "legacy.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'legacy schema tool',",
|
||||
" args: { text: { type: 'string', description: 'Text to render' } },",
|
||||
" execute: async ({ text }) => text,",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
|
||||
if (!loaded) throw new Error("legacy custom tool was not loaded")
|
||||
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
|
||||
type: "object",
|
||||
properties: {
|
||||
text: { type: "string", description: "Text to render" },
|
||||
},
|
||||
required: ["text"],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads tools with external dependencies without crashing", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
|
||||
@@ -1,754 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema, SchemaGetter } from "effect"
|
||||
import z from "zod"
|
||||
|
||||
import { zod, ZodOverride } from "@opencode-ai/core/effect-zod"
|
||||
|
||||
function json(schema: z.ZodTypeAny) {
|
||||
const { $schema: _, ...rest } = z.toJSONSchema(schema)
|
||||
return rest
|
||||
}
|
||||
|
||||
describe("util.effect-zod", () => {
|
||||
test("converts class schemas for route dto shapes", () => {
|
||||
class Method extends Schema.Class<Method>("ProviderAuthMethod")({
|
||||
type: Schema.Union([Schema.Literal("oauth"), Schema.Literal("api")]),
|
||||
label: Schema.String,
|
||||
}) {}
|
||||
|
||||
const out = zod(Method)
|
||||
|
||||
expect(out.meta()?.ref).toBe("ProviderAuthMethod")
|
||||
expect(
|
||||
out.parse({
|
||||
type: "oauth",
|
||||
label: "OAuth",
|
||||
}),
|
||||
).toEqual({
|
||||
type: "oauth",
|
||||
label: "OAuth",
|
||||
})
|
||||
})
|
||||
|
||||
test("converts structs with optional fields, arrays, and records", () => {
|
||||
const out = zod(
|
||||
Schema.Struct({
|
||||
foo: Schema.optional(Schema.String),
|
||||
bar: Schema.Array(Schema.Number),
|
||||
baz: Schema.Record(Schema.String, Schema.Boolean),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(
|
||||
out.parse({
|
||||
bar: [1, 2],
|
||||
baz: { ok: true },
|
||||
}),
|
||||
).toEqual({
|
||||
bar: [1, 2],
|
||||
baz: { ok: true },
|
||||
})
|
||||
expect(
|
||||
out.parse({
|
||||
foo: "hi",
|
||||
bar: [1],
|
||||
baz: { ok: false },
|
||||
}),
|
||||
).toEqual({
|
||||
foo: "hi",
|
||||
bar: [1],
|
||||
baz: { ok: false },
|
||||
})
|
||||
})
|
||||
|
||||
describe("Tuples", () => {
|
||||
test("fixed-length tuple parses matching array", () => {
|
||||
const out = zod(Schema.Tuple([Schema.String, Schema.Number]))
|
||||
expect(out.parse(["a", 1])).toEqual(["a", 1])
|
||||
expect(out.safeParse(["a"]).success).toBe(false)
|
||||
expect(out.safeParse(["a", "b"]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("single-element tuple parses a one-element array", () => {
|
||||
const out = zod(Schema.Tuple([Schema.Boolean]))
|
||||
expect(out.parse([true])).toEqual([true])
|
||||
expect(out.safeParse([true, false]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("tuple inside a union picks the right branch", () => {
|
||||
const out = zod(Schema.Union([Schema.String, Schema.Tuple([Schema.String, Schema.Number])]))
|
||||
expect(out.parse("hello")).toBe("hello")
|
||||
expect(out.parse(["foo", 42])).toEqual(["foo", 42])
|
||||
expect(out.safeParse(["foo"]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("plain arrays still work (no element positions)", () => {
|
||||
const out = zod(Schema.Array(Schema.String))
|
||||
expect(out.parse(["a", "b", "c"])).toEqual(["a", "b", "c"])
|
||||
expect(out.parse([])).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
test("string literal unions produce z.enum with enum in JSON Schema", () => {
|
||||
const Action = Schema.Literals(["allow", "deny", "ask"])
|
||||
const out = zod(Action)
|
||||
|
||||
expect(out.parse("allow")).toBe("allow")
|
||||
expect(out.parse("deny")).toBe("deny")
|
||||
expect(() => out.parse("nope")).toThrow()
|
||||
|
||||
// Matches native z.enum JSON Schema output
|
||||
const bridged = json(out)
|
||||
const native = json(z.enum(["allow", "deny", "ask"]))
|
||||
expect(bridged).toEqual(native)
|
||||
expect(bridged.enum).toEqual(["allow", "deny", "ask"])
|
||||
})
|
||||
|
||||
test("ZodOverride annotation provides the Zod schema for branded IDs", () => {
|
||||
const override = z.string().startsWith("per")
|
||||
const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("TestID"))
|
||||
|
||||
const Parent = Schema.Struct({ id: ID, name: Schema.String })
|
||||
const out = zod(Parent)
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
expect((out as any).parse({ id: "per_abc", name: "test" })).toEqual({ id: "per_abc", name: "test" })
|
||||
|
||||
const schema = json(out) as any
|
||||
expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||
})
|
||||
|
||||
test("Schema.Class nested in a parent preserves ref via identifier", () => {
|
||||
class Inner extends Schema.Class<Inner>("MyInner")({
|
||||
value: Schema.String,
|
||||
}) {}
|
||||
|
||||
class Outer extends Schema.Class<Outer>("MyOuter")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
const out = zod(Outer)
|
||||
expect(out.meta()?.ref).toBe("MyOuter")
|
||||
|
||||
const shape = (out as any).shape ?? (out as any)._def?.shape?.()
|
||||
expect(shape.inner.meta()?.ref).toBe("MyInner")
|
||||
})
|
||||
|
||||
test("Schema.Class preserves identifier and uses enum format", () => {
|
||||
class Rule extends Schema.Class<Rule>("PermissionRule")({
|
||||
permission: Schema.String,
|
||||
pattern: Schema.String,
|
||||
action: Schema.Literals(["allow", "deny", "ask"]),
|
||||
}) {}
|
||||
|
||||
const out = zod(Rule)
|
||||
expect(out.meta()?.ref).toBe("PermissionRule")
|
||||
|
||||
const schema = json(out) as any
|
||||
expect(schema.properties.action).toEqual({
|
||||
type: "string",
|
||||
enum: ["allow", "deny", "ask"],
|
||||
})
|
||||
})
|
||||
|
||||
test("ZodOverride on ID carries pattern through Schema.Class", () => {
|
||||
const ID = Schema.String.annotate({
|
||||
[ZodOverride]: z.string().startsWith("per"),
|
||||
})
|
||||
|
||||
class Request extends Schema.Class<Request>("TestRequest")({
|
||||
id: ID,
|
||||
name: Schema.String,
|
||||
}) {}
|
||||
|
||||
const schema = json(zod(Request)) as any
|
||||
expect(schema.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||
expect(schema.properties.name).toEqual({ type: "string" })
|
||||
})
|
||||
|
||||
test("Permission schemas match original Zod equivalents", () => {
|
||||
const MsgID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("msg") })
|
||||
const PerID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("per") })
|
||||
const SesID = Schema.String.annotate({ [ZodOverride]: z.string().startsWith("ses") })
|
||||
|
||||
class Tool extends Schema.Class<Tool>("PermissionTool")({
|
||||
messageID: MsgID,
|
||||
callID: Schema.String,
|
||||
}) {}
|
||||
|
||||
class Request extends Schema.Class<Request>("PermissionRequest")({
|
||||
id: PerID,
|
||||
sessionID: SesID,
|
||||
permission: Schema.String,
|
||||
patterns: Schema.Array(Schema.String),
|
||||
metadata: Schema.Record(Schema.String, Schema.Unknown),
|
||||
always: Schema.Array(Schema.String),
|
||||
tool: Schema.optional(Tool),
|
||||
}) {}
|
||||
|
||||
const bridged = json(zod(Request)) as any
|
||||
expect(bridged.properties.id).toEqual({ type: "string", pattern: "^per.*" })
|
||||
expect(bridged.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
|
||||
expect(bridged.properties.permission).toEqual({ type: "string" })
|
||||
expect(bridged.required?.sort()).toEqual(["id", "sessionID", "permission", "patterns", "metadata", "always"].sort())
|
||||
|
||||
// Tool field is present with the ref from Schema.Class identifier
|
||||
const toolSchema = json(zod(Tool)) as any
|
||||
expect(toolSchema.properties.messageID).toEqual({ type: "string", pattern: "^msg.*" })
|
||||
expect(toolSchema.properties.callID).toEqual({ type: "string" })
|
||||
})
|
||||
|
||||
test("ZodOverride survives Schema.brand", () => {
|
||||
const override = z.string().startsWith("ses")
|
||||
const ID = Schema.String.annotate({ [ZodOverride]: override }).pipe(Schema.brand("SessionID"))
|
||||
|
||||
// The branded schema's AST still has the override
|
||||
class Parent extends Schema.Class<Parent>("Parent")({
|
||||
sessionID: ID,
|
||||
}) {}
|
||||
|
||||
const schema = json(zod(Parent)) as any
|
||||
expect(schema.properties.sessionID).toEqual({ type: "string", pattern: "^ses.*" })
|
||||
})
|
||||
|
||||
describe("Schema.check translation", () => {
|
||||
test("filter returning string triggers refinement with that message", () => {
|
||||
const isEven = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "expected an even number"))
|
||||
const schema = zod(Schema.Number.check(isEven))
|
||||
|
||||
expect(schema.parse(4)).toBe(4)
|
||||
const result = schema.safeParse(3)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("expected an even number")
|
||||
})
|
||||
|
||||
test("filter returning false triggers refinement with fallback message", () => {
|
||||
const nonEmpty = Schema.makeFilter((s: string) => s.length > 0)
|
||||
const schema = zod(Schema.String.check(nonEmpty))
|
||||
|
||||
expect(schema.parse("hi")).toBe("hi")
|
||||
const result = schema.safeParse("")
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toMatch(/./)
|
||||
})
|
||||
|
||||
test("filter returning undefined passes validation", () => {
|
||||
const alwaysOk = Schema.makeFilter(() => undefined)
|
||||
const schema = zod(Schema.Number.check(alwaysOk))
|
||||
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("annotations.message on the filter is used when filter returns false", () => {
|
||||
const positive = Schema.makeFilter((n: number) => n > 0, { message: "must be positive" })
|
||||
const schema = zod(Schema.Number.check(positive))
|
||||
|
||||
const result = schema.safeParse(-1)
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("must be positive")
|
||||
})
|
||||
|
||||
test("cross-field check on a record flags missing key", () => {
|
||||
const hasKey = Schema.makeFilter((data: Record<string, { enabled: boolean }>) =>
|
||||
"required" in data ? undefined : "missing 'required' key",
|
||||
)
|
||||
const schema = zod(Schema.Record(Schema.String, Schema.Struct({ enabled: Schema.Boolean })).check(hasKey))
|
||||
|
||||
expect(schema.parse({ required: { enabled: true } })).toEqual({
|
||||
required: { enabled: true },
|
||||
})
|
||||
|
||||
const result = schema.safeParse({ other: { enabled: true } })
|
||||
expect(result.success).toBe(false)
|
||||
expect(result.error!.issues[0].message).toBe("missing 'required' key")
|
||||
})
|
||||
})
|
||||
|
||||
describe("StructWithRest / catchall", () => {
|
||||
test("struct with a string-keyed record rest parses known AND extra keys", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
apiKey: Schema.optional(Schema.String),
|
||||
baseURL: Schema.optional(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
)
|
||||
|
||||
// Known fields come through as declared
|
||||
expect(schema.parse({ apiKey: "sk-x" })).toEqual({ apiKey: "sk-x" })
|
||||
|
||||
// Extra keys are preserved (catchall)
|
||||
expect(
|
||||
schema.parse({
|
||||
apiKey: "sk-x",
|
||||
baseURL: "https://api.example.com",
|
||||
customField: "anything",
|
||||
nested: { foo: 1 },
|
||||
}),
|
||||
).toEqual({
|
||||
apiKey: "sk-x",
|
||||
baseURL: "https://api.example.com",
|
||||
customField: "anything",
|
||||
nested: { foo: 1 },
|
||||
})
|
||||
})
|
||||
|
||||
test("catchall value type constrains the extras", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
count: Schema.Number,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Number)],
|
||||
),
|
||||
)
|
||||
|
||||
// Known field + numeric extras
|
||||
expect(schema.parse({ count: 10, a: 1, b: 2 })).toEqual({ count: 10, a: 1, b: 2 })
|
||||
|
||||
// Non-numeric extra is rejected
|
||||
expect(schema.safeParse({ count: 10, bad: "not a number" }).success).toBe(false)
|
||||
})
|
||||
|
||||
test("JSON schema output marks additionalProperties appropriately", () => {
|
||||
const schema = zod(
|
||||
Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
),
|
||||
)
|
||||
const shape = json(schema) as { additionalProperties?: unknown }
|
||||
// Presence of `additionalProperties` (truthy or a schema) signals catchall.
|
||||
expect(shape.additionalProperties).not.toBe(false)
|
||||
expect(shape.additionalProperties).toBeDefined()
|
||||
})
|
||||
|
||||
test("plain struct without rest still emits additionalProperties unchanged (regression)", () => {
|
||||
const schema = zod(Schema.Struct({ id: Schema.String }))
|
||||
expect(schema.parse({ id: "x" })).toEqual({ id: "x" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("transforms (Schema.decodeTo)", () => {
|
||||
test("Number -> pseudo-Duration (seconds) applies the decode function", () => {
|
||||
// Models the account/account.ts DurationFromSeconds pattern.
|
||||
const SecondsToMs = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n * 1000),
|
||||
encode: SchemaGetter.transform((ms: number) => ms / 1000),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(SecondsToMs)
|
||||
expect(schema.parse(3)).toBe(3000)
|
||||
expect(schema.parse(0)).toBe(0)
|
||||
})
|
||||
|
||||
test("String -> Number via parseInt decode", () => {
|
||||
const ParsedInt = Schema.String.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
|
||||
encode: SchemaGetter.transform((n: number) => String(n)),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(ParsedInt)
|
||||
expect(schema.parse("42")).toBe(42)
|
||||
expect(schema.parse("0")).toBe(0)
|
||||
})
|
||||
|
||||
test("transform inside a struct field applies per-field", () => {
|
||||
const Field = Schema.Number.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n + 1),
|
||||
encode: SchemaGetter.transform((n: number) => n - 1),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
plain: Schema.Number,
|
||||
bumped: Field,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(schema.parse({ plain: 5, bumped: 10 })).toEqual({ plain: 5, bumped: 11 })
|
||||
})
|
||||
|
||||
test("chained decodeTo composes transforms in order", () => {
|
||||
// String -> Number (parseInt) -> Number (doubled).
|
||||
// Exercises the encoded() reduce, not just a single link.
|
||||
const Chained = Schema.String.pipe(
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((s: string) => Number.parseInt(s, 10)),
|
||||
encode: SchemaGetter.transform((n: number) => String(n)),
|
||||
}),
|
||||
Schema.decodeTo(Schema.Number, {
|
||||
decode: SchemaGetter.transform((n: number) => n * 2),
|
||||
encode: SchemaGetter.transform((n: number) => n / 2),
|
||||
}),
|
||||
)
|
||||
|
||||
const schema = zod(Chained)
|
||||
expect(schema.parse("21")).toBe(42)
|
||||
expect(schema.parse("0")).toBe(0)
|
||||
})
|
||||
|
||||
test("Schema.Class is unaffected by transform walker (returns plain object, not instance)", () => {
|
||||
// Schema.Class uses Declaration + encoding under the hood to construct
|
||||
// class instances. The walker must NOT apply that transform, or zod
|
||||
// parsing would return class instances instead of plain objects.
|
||||
class Method extends Schema.Class<Method>("TxTestMethod")({
|
||||
type: Schema.String,
|
||||
value: Schema.Number,
|
||||
}) {}
|
||||
|
||||
const schema = zod(Method)
|
||||
const parsed = schema.parse({ type: "oauth", value: 1 })
|
||||
expect(parsed).toEqual({ type: "oauth", value: 1 })
|
||||
// Guardrail: ensure we didn't get back a Method instance.
|
||||
expect(parsed).not.toBeInstanceOf(Method)
|
||||
})
|
||||
})
|
||||
|
||||
describe("optimizations", () => {
|
||||
test("walk() memoizes by AST identity — same AST node returns same Zod", () => {
|
||||
const shared = Schema.Struct({ id: Schema.String, name: Schema.String })
|
||||
const left = zod(shared)
|
||||
const right = zod(shared)
|
||||
expect(left).toBe(right)
|
||||
})
|
||||
|
||||
test("nested reuse of the same AST reuses the cached Zod child", () => {
|
||||
// Two different parents embed the same inner schema. The inner zod
|
||||
// child should be identical by reference inside both parents.
|
||||
class Inner extends Schema.Class<Inner>("MemoTestInner")({
|
||||
value: Schema.String,
|
||||
}) {}
|
||||
|
||||
class OuterA extends Schema.Class<OuterA>("MemoTestOuterA")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
class OuterB extends Schema.Class<OuterB>("MemoTestOuterB")({
|
||||
inner: Inner,
|
||||
}) {}
|
||||
|
||||
const shapeA = (zod(OuterA) as any).shape ?? (zod(OuterA) as any)._def?.shape?.()
|
||||
const shapeB = (zod(OuterB) as any).shape ?? (zod(OuterB) as any)._def?.shape?.()
|
||||
expect(shapeA.inner).toBe(shapeB.inner)
|
||||
})
|
||||
|
||||
test("multiple checks run in a single refinement layer (all fire on one value)", () => {
|
||||
// Three checks attached to the same schema. All three must run and
|
||||
// report — asserting that no check silently got dropped when we
|
||||
// flattened into one superRefine.
|
||||
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
|
||||
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
|
||||
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
|
||||
|
||||
const schema = zod(Schema.Number.check(positive).check(even).check(under100))
|
||||
|
||||
const neg = schema.safeParse(-3)
|
||||
expect(neg.success).toBe(false)
|
||||
expect(neg.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
|
||||
|
||||
const big = schema.safeParse(101)
|
||||
expect(big.success).toBe(false)
|
||||
expect(big.error!.issues.map((i) => i.message)).toContain("too big")
|
||||
|
||||
// Passing value satisfies all three
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
})
|
||||
|
||||
test("FilterGroup flattens into the single refinement layer alongside its siblings", () => {
|
||||
const positive = Schema.makeFilter((n: number) => (n > 0 ? undefined : "not positive"))
|
||||
const even = Schema.makeFilter((n: number) => (n % 2 === 0 ? undefined : "not even"))
|
||||
const group = Schema.makeFilterGroup([positive, even])
|
||||
const under100 = Schema.makeFilter((n: number) => (n < 100 ? undefined : "too big"))
|
||||
|
||||
const schema = zod(Schema.Number.check(group).check(under100))
|
||||
|
||||
const bad = schema.safeParse(-3)
|
||||
expect(bad.success).toBe(false)
|
||||
expect(bad.error!.issues.map((i) => i.message)).toEqual(expect.arrayContaining(["not positive", "not even"]))
|
||||
})
|
||||
})
|
||||
|
||||
describe("well-known refinement translation", () => {
|
||||
test("Schema.isInt emits type: integer in JSON Schema", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()))
|
||||
const native = json(z.number().int())
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse(3)).toBe(3)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isGreaterThan(0) emits exclusiveMinimum: 0", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isGreaterThan(0)))
|
||||
expect((json(schema) as any).exclusiveMinimum).toBe(0)
|
||||
expect(schema.parse(1)).toBe(1)
|
||||
expect(schema.safeParse(0).success).toBe(false)
|
||||
expect(schema.safeParse(-1).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isGreaterThanOrEqualTo(0) emits minimum: 0", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0)))
|
||||
expect((json(schema) as any).minimum).toBe(0)
|
||||
expect(schema.parse(0)).toBe(0)
|
||||
expect(schema.safeParse(-1).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isLessThan(10) emits exclusiveMaximum: 10", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isLessThan(10)))
|
||||
expect((json(schema) as any).exclusiveMaximum).toBe(10)
|
||||
expect(schema.parse(9)).toBe(9)
|
||||
expect(schema.safeParse(10).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isLessThanOrEqualTo(10) emits maximum: 10", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isLessThanOrEqualTo(10)))
|
||||
expect((json(schema) as any).maximum).toBe(10)
|
||||
expect(schema.parse(10)).toBe(10)
|
||||
expect(schema.safeParse(11).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMultipleOf(5) emits multipleOf: 5", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isMultipleOf(5)))
|
||||
expect((json(schema) as any).multipleOf).toBe(5)
|
||||
expect(schema.parse(10)).toBe(10)
|
||||
expect(schema.safeParse(7).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isFinite validates at runtime", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isFinite()))
|
||||
expect(schema.parse(1)).toBe(1)
|
||||
expect(schema.safeParse(Infinity).success).toBe(false)
|
||||
expect(schema.safeParse(NaN).success).toBe(false)
|
||||
})
|
||||
|
||||
test("chained isInt + isGreaterThan(0) matches z.number().int().positive()", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
|
||||
const native = json(z.number().int().positive())
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse(3)).toBe(3)
|
||||
expect(schema.safeParse(0).success).toBe(false)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
})
|
||||
|
||||
test("chained isInt + isGreaterThanOrEqualTo(0) matches z.number().int().min(0)", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThanOrEqualTo(0)))
|
||||
const native = json(z.number().int().min(0))
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse(0)).toBe(0)
|
||||
expect(schema.safeParse(-1).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isBetween emits both bounds", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isBetween({ minimum: 1, maximum: 10 })))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.minimum).toBe(1)
|
||||
expect(shape.maximum).toBe(10)
|
||||
expect(schema.parse(5)).toBe(5)
|
||||
expect(schema.safeParse(11).success).toBe(false)
|
||||
expect(schema.safeParse(0).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isBetween with exclusive bounds emits exclusiveMinimum/Maximum", () => {
|
||||
const schema = zod(
|
||||
Schema.Number.check(
|
||||
Schema.isBetween({ minimum: 1, maximum: 10, exclusiveMinimum: true, exclusiveMaximum: true }),
|
||||
),
|
||||
)
|
||||
const shape = json(schema) as any
|
||||
expect(shape.exclusiveMinimum).toBe(1)
|
||||
expect(shape.exclusiveMaximum).toBe(10)
|
||||
expect(schema.parse(5)).toBe(5)
|
||||
expect(schema.safeParse(1).success).toBe(false)
|
||||
expect(schema.safeParse(10).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isInt32 (FilterGroup) produces integer bounds", () => {
|
||||
const schema = zod(Schema.Number.check(Schema.isInt32()))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.type).toBe("integer")
|
||||
expect(shape.minimum).toBe(-2147483648)
|
||||
expect(shape.maximum).toBe(2147483647)
|
||||
expect(schema.parse(42)).toBe(42)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
expect(schema.safeParse(2147483648).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMinLength on string emits minLength", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isMinLength(3)))
|
||||
expect((json(schema) as any).minLength).toBe(3)
|
||||
expect(schema.parse("abc")).toBe("abc")
|
||||
expect(schema.safeParse("ab").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMaxLength on string emits maxLength", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isMaxLength(5)))
|
||||
expect((json(schema) as any).maxLength).toBe(5)
|
||||
expect(schema.parse("abcde")).toBe("abcde")
|
||||
expect(schema.safeParse("abcdef").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isLengthBetween on string emits both bounds", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isLengthBetween(2, 4)))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.minLength).toBe(2)
|
||||
expect(shape.maxLength).toBe(4)
|
||||
expect(schema.parse("abc")).toBe("abc")
|
||||
expect(schema.safeParse("a").success).toBe(false)
|
||||
expect(schema.safeParse("abcde").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isMinLength on array emits minItems", () => {
|
||||
const schema = zod(Schema.Array(Schema.String).check(Schema.isMinLength(1)))
|
||||
expect((json(schema) as any).minItems).toBe(1)
|
||||
expect(schema.parse(["x"])).toEqual(["x"])
|
||||
expect(schema.safeParse([]).success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isPattern emits pattern", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isPattern(/^per/)))
|
||||
expect((json(schema) as any).pattern).toBe("^per")
|
||||
expect(schema.parse("per_abc")).toBe("per_abc")
|
||||
expect(schema.safeParse("abc").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isStartsWith matches native zod .startsWith() JSON Schema", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isStartsWith("per")))
|
||||
const native = json(z.string().startsWith("per"))
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse("per_abc")).toBe("per_abc")
|
||||
expect(schema.safeParse("abc").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isEndsWith matches native zod .endsWith() JSON Schema", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isEndsWith(".json")))
|
||||
const native = json(z.string().endsWith(".json"))
|
||||
expect(json(schema)).toEqual(native)
|
||||
expect(schema.parse("a.json")).toBe("a.json")
|
||||
expect(schema.safeParse("a.txt").success).toBe(false)
|
||||
})
|
||||
|
||||
test("Schema.isUUID emits format: uuid", () => {
|
||||
const schema = zod(Schema.String.check(Schema.isUUID()))
|
||||
expect((json(schema) as any).format).toBe("uuid")
|
||||
})
|
||||
|
||||
test("mix of well-known and anonymous filters translates known and reroutes unknown to superRefine", () => {
|
||||
// isInt is well-known (translates to .int()); the anonymous filter falls
|
||||
// back to superRefine.
|
||||
const notSeven = Schema.makeFilter((n: number) => (n !== 7 ? undefined : "no sevens allowed"))
|
||||
const schema = zod(Schema.Number.check(Schema.isInt()).check(notSeven))
|
||||
|
||||
const shape = json(schema) as any
|
||||
// Well-known translation is preserved — type is integer, not plain number
|
||||
expect(shape.type).toBe("integer")
|
||||
|
||||
// Runtime: both constraints fire
|
||||
expect(schema.parse(3)).toBe(3)
|
||||
expect(schema.safeParse(1.5).success).toBe(false)
|
||||
const seven = schema.safeParse(7)
|
||||
expect(seven.success).toBe(false)
|
||||
expect(seven.error!.issues[0].message).toBe("no sevens allowed")
|
||||
})
|
||||
|
||||
test("inside a struct field, well-known refinements propagate through", () => {
|
||||
// Mirrors config.ts port: z.number().int().positive().optional()
|
||||
const Port = Schema.optional(Schema.Number.check(Schema.isInt()).check(Schema.isGreaterThan(0)))
|
||||
const schema = zod(Schema.Struct({ port: Port }))
|
||||
const shape = json(schema) as any
|
||||
expect(shape.properties.port.type).toBe("integer")
|
||||
expect(shape.properties.port.exclusiveMinimum).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Schema.optionalWith defaults", () => {
|
||||
test("parsing undefined returns the default value", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({})).toEqual({ mode: "ctrl-x" })
|
||||
expect(schema.parse({ mode: undefined })).toEqual({ mode: "ctrl-x" })
|
||||
})
|
||||
|
||||
test("parsing a real value returns that value (default does not fire)", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({ mode: "ctrl-y" })).toEqual({ mode: "ctrl-y" })
|
||||
})
|
||||
|
||||
test("default on a number field", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
count: Schema.Number.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed(42))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({})).toEqual({ count: 42 })
|
||||
expect(schema.parse({ count: 7 })).toEqual({ count: 7 })
|
||||
})
|
||||
|
||||
test("multiple defaulted fields inside a struct", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
leader: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
quit: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-c"))),
|
||||
inner: Schema.String,
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({ inner: "hi" })).toEqual({
|
||||
leader: "ctrl-x",
|
||||
quit: "ctrl-c",
|
||||
inner: "hi",
|
||||
})
|
||||
expect(schema.parse({ leader: "a", quit: "b", inner: "c" })).toEqual({
|
||||
leader: "a",
|
||||
quit: "b",
|
||||
inner: "c",
|
||||
})
|
||||
})
|
||||
|
||||
test("JSON Schema output includes the default key", () => {
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
mode: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.succeed("ctrl-x"))),
|
||||
}),
|
||||
)
|
||||
const shape = json(schema) as any
|
||||
expect(shape.properties.mode.default).toBe("ctrl-x")
|
||||
})
|
||||
|
||||
test("default referencing a computed value resolves when evaluated", () => {
|
||||
// Simulates `keybinds.ts` style of per-platform defaults: the default is
|
||||
// produced by an Effect that computes a value at decode time.
|
||||
const platform = "darwin"
|
||||
const fallback = platform === "darwin" ? "cmd-k" : "ctrl-k"
|
||||
const schema = zod(
|
||||
Schema.Struct({
|
||||
command_palette: Schema.String.pipe(Schema.optional, Schema.withDecodingDefault(Effect.sync(() => fallback))),
|
||||
}),
|
||||
)
|
||||
expect(schema.parse({})).toEqual({ command_palette: "cmd-k" })
|
||||
const shape = json(schema) as any
|
||||
expect(shape.properties.command_palette.default).toBe("cmd-k")
|
||||
})
|
||||
|
||||
test("plain Schema.optional (no default) still emits .optional() (regression)", () => {
|
||||
const schema = zod(Schema.Struct({ foo: Schema.optional(Schema.String) }))
|
||||
expect(schema.parse({})).toEqual({})
|
||||
expect(schema.parse({ foo: "hi" })).toEqual({ foo: "hi" })
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user