Compare commits

..
Author SHA1 Message Date
Hona 1550cfe21d fix(session-ui): use the custom scroll view in execute call details 2026-10-16 04:57:19 +00:00
Luke Parker 8d5e6e23c0 feat(desktop): install Windows updates in about a second (#55933) 2026-10-16 14:10:01 +10:00
Brendan Allan 52c2594aae feat(session-ui): list Code Mode tool calls in execute rows (#55649) 2026-10-16 12:02:26 +08:00
Luke Parker 8ba78414d2 chore(ci): speed up the beta desktop publish (#55919) 2026-10-16 13:46:53 +10:00
LukeParkerDev deb5cba571 chore: merge dev into beta 2026-10-16 11:44:57 +10:00
Luke Parker f2323623c4 feat(app): add generative UI to the GUI (#54747) 2026-10-16 11:41:43 +10:00
Luke Parker e25b7abf89 fix(session-ui): slide the tool group count when it changes (#55617) 2026-10-16 11:10:33 +10:00
xin zhuangandAiden Cline 706a688994 fix(core): omit compaction replay context from sanitized exports (#55592)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-15 20:10:25 -05:00
Aiden Cline 20d172e462 docs: document external_directory allow default (#55885) 2026-10-15 20:09:02 -05:00
opencode-agent[bot] 7049e5af4d chore(core): refresh bundled models.dev snapshot 2026-10-16 00:40:26 +00:00
LukeParkerDev f834dd8ee3 fix(infra): deploy the beta web app with Workers Static Assets
StaticSite stores assets base64-encoded in KV, which pushed the 20 MB Word preview wasm past KV's 25 MiB value limit.
2026-10-16 10:27:59 +10:00
Luke Parker 699e74ae86 fix(app): keep the file view in place when a link rereads it (#55899) 2026-10-16 10:23:51 +10:00
Luke Parker 8737af203d feat(app): add commentary timeline category (#55248) 2026-10-16 10:16:28 +10:00
Luke Parker d7d44a7e03 fix(app): fade long file names at the edge of the file tree (#55889) 2026-10-15 23:46:27 +00:00
Luke Parker 867a77a38f fix(app): keep the review pane steady when the side terminal toggles (#55879) 2026-10-15 23:34:29 +00:00
2e7ac8c8ea fix(tui): roll the compact form over to M at the rounding boundary (#55871)
Co-authored-by: argszero <argszero@users.noreply.github.com>
Co-authored-by: Tarquinen <78942835+Tarquinen@users.noreply.github.com>
2026-10-15 19:34:22 -04:00
LukeParkerDev 3fe48b7d81 chore: merge dev into beta
# Conflicts:
#	script/publish.ts
2026-10-16 09:02:13 +10:00
Luke Parker 13263f30ed chore(release): publish the desktop app from beta (#55878) 2026-10-16 09:01:10 +10:00
opencode-agent[bot] b3aa790aae chore: update nix node_modules hashes 2026-10-15 22:45:48 +00:00
Filip d0f3204619 fix(util): hide plugin Git console windows on Windows (#55475) 2026-10-16 00:45:40 +02:00
Filip 17229d24f5 chore(ci): speed up and stabilize the test workflow (#55866) 2026-10-16 00:35:49 +02:00
DanteandDaniel Smolsky 7a4a03212e fix(core): seed configured limits before model transforms (#55778)
Co-authored-by: Daniel Smolsky <dannysmo@gmail.com>
2026-10-15 18:26:21 -04:00
Hona e5ecb5719d Merge remote-tracking branch 'origin/v2' into beta-release 2026-09-17 15:35:10 +00:00
opencode-agent[bot] f5afd58cf4 chore(release): retry desktop beta R2 2026-09-17 08:22:01 +00:00
Brendonovich 9ebee2762a chore(release): label desktop beta publish 2026-09-17 07:48:53 +00:00
Hona 9a91540182 Merge remote-tracking branch 'origin/v2' into beta-release 2026-09-17 07:48:33 +00:00
Brendonovich 785c563301 chore(release): publish desktop beta to R2 2026-09-17 07:48:33 +00:00
opencode-agent[bot] a1e9ee6f23 chore(release): retry GitHub beta 2026-09-17 07:10:20 +00:00
Brendonovich 2ad698947f chore(release): keep beta on GitHub 2026-09-17 06:32:45 +00:00
Brendonovich 218534d936 chore(release): publish beta to GitHub 2026-09-17 06:19:46 +00:00
190 changed files with 9864 additions and 1015 deletions

No files matched your search

+8 -10
View File
@@ -49,31 +49,29 @@ runs:
echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
echo "version=$(bun --version)" >> "$GITHUB_OUTPUT"
# On Windows, extracting the cache archive takes longer than Bun downloading and
# extracting the same packages itself, so Windows installs skip the cache.
- name: Restore Bun dependencies
id: bun-cache
if: runner.os != 'Windows'
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ steps.cache.outputs.version }}-${{ hashFiles('bun.lock', 'patches/**') }}
# Each dev merge changes beta's bun.lock. Beta falls back to the newest cache for the same Bun version
# (Bun's cache stores every package version separately) instead of installing everything from npm.
restore-keys: ${{ (github.ref_name == 'beta' && format('{0}-{1}-bun-{2}-', runner.os, runner.arch, steps.cache.outputs.version)) || '' }}
- name: Install setuptools for distutils compatibility
run: python3 -m pip install setuptools || pip install setuptools || true
shell: bash
- name: Install dependencies
run: |
# Workaround for patched peer variants
# e.g. ./patches/ for standard-openapi
# https://github.com/oven-sh/bun/issues/28147
if [ "$RUNNER_OS" = "Windows" ]; then
bun install --frozen-lockfile --linker hoisted ${{ inputs.install-flags }}
else
bun install --frozen-lockfile ${{ inputs.install-flags }}
fi
run: bun install --frozen-lockfile ${{ inputs.install-flags }}
shell: bash
- name: Save Bun dependencies
if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
if: runner.os != 'Windows' && steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: ${{ steps.cache.outputs.dir }}
+1 -1
View File
@@ -6,7 +6,7 @@ permissions:
on:
workflow_dispatch:
push:
branches: [dev, beta]
branches: [dev]
paths:
- "bun.lock"
- "package.json"
+179 -17
View File
@@ -28,12 +28,18 @@ on:
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'dev' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'dev' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
# Beta ships only the newest commit to the team. A cancelled beta run can leave unreferenced files in R2, but only
# its publish job records a release, in one request.
cancel-in-progress: ${{ github.ref_name == 'beta' }}
permissions:
id-token: write
contents: write
packages: write
# Beta desktop jobs poll this run's artifacts while the CLI builds.
actions: read
env:
OPENCODE_CHANNEL: ${{ (github.ref_name == 'dev' && !inputs.bump && !inputs.version && 'dev') || '' }}
@@ -42,15 +48,35 @@ jobs:
version:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.repository == 'anomalyco/opencode'
env:
BETA: ${{ github.ref_name == 'beta' }}
steps:
# A beta version only depends on the run number, so beta computes it without a checkout or an install.
- name: Beta version
id: beta
if: env.BETA == 'true'
run: |
# Matches packages/script: 0.0.0-<channel>-<run number>[.<attempt>] for preview channels.
version="0.0.0-beta-${GITHUB_RUN_NUMBER}"
if [ "$GITHUB_RUN_ATTEMPT" != "1" ]; then version="${version}.${GITHUB_RUN_ATTEMPT}"; fi
{
echo "version=$version"
echo "release=true"
echo "tag=v$version"
echo "repo=anomalyco/opencode-beta"
} >> "$GITHUB_OUTPUT"
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
if: env.BETA != 'true'
with:
fetch-depth: 0
- uses: ./.github/actions/setup-bun
if: env.BETA != 'true'
- name: Setup git committer
id: committer
if: env.BETA != 'true'
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
@@ -61,6 +87,7 @@ jobs:
run: bun i -g opencode-ai
- id: version
if: env.BETA != 'true'
run: |
./script/version.ts
env:
@@ -70,16 +97,17 @@ jobs:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
GH_REPO: ${{ (github.ref_name == 'beta' && 'anomalyco/opencode-beta') || github.repository }}
outputs:
version: ${{ steps.version.outputs.version }}
release: ${{ steps.version.outputs.release }}
tag: ${{ steps.version.outputs.tag }}
repo: ${{ steps.version.outputs.repo }}
version: ${{ steps.version.outputs.version || steps.beta.outputs.version }}
release: ${{ steps.version.outputs.release || steps.beta.outputs.release }}
tag: ${{ steps.version.outputs.tag || steps.beta.outputs.tag }}
repo: ${{ steps.version.outputs.repo || steps.beta.outputs.repo }}
build-cli:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
# Beta builds only the CLIs its desktop app bundles, in build-cli-beta.
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
with:
@@ -138,10 +166,53 @@ jobs:
outputs:
version: ${{ needs.version.outputs.version }}
# Beta publishes only the desktop app, so it builds just the 6 CLIs the desktop bundles, each in its own job and
# artifact. Desktop jobs start alongside and wait for their CLI only when they need it.
build-cli-beta:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'beta'
strategy:
fail-fast: false
matrix:
# macOS first: notarization makes those desktop jobs the longest.
target:
- darwin-x64-baseline
- darwin-arm64
- linux-arm64
- windows-arm64
- windows-x64-baseline
- linux-x64-baseline
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
with:
bun-version: 1.4.2
- name: Build CLI
run: ./packages/cli/script/build.ts --target=opencode-${{ matrix.target }}
env:
BUN_COMPILE_RELEASE: bun-v1.4.2
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
GH_REPO: ${{ needs.version.outputs.repo }}
GH_TOKEN: ${{ github.token }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-cli-unsigned-cli-${{ matrix.target }}
path: packages/cli/dist/cli-${{ matrix.target }}
# Deflate level 1 compresses a CLI binary twice as fast as the default for 7% more bytes.
compression-level: 1
if-no-files-found: error
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
# Beta publishes only the desktop app, which signs the CLI it bundles.
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -196,7 +267,7 @@ jobs:
needs: version
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'dev' && (inputs.bump || inputs.version))
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' && !(github.ref_name == 'dev' && (inputs.bump || inputs.version))
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
@@ -218,7 +289,7 @@ jobs:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode' && !(github.ref_name == 'dev' && (inputs.bump || inputs.version))
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta' && !(github.ref_name == 'dev' && (inputs.bump || inputs.version))
strategy:
fail-fast: false
matrix:
@@ -276,7 +347,7 @@ jobs:
needs:
- sign-cli-macos
runs-on: blacksmith-4vcpu-windows-2025
if: github.repository == 'anomalyco/opencode' && (github.ref_name == 'dev' || github.ref_name == 'beta')
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'dev'
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }}
@@ -343,10 +414,11 @@ jobs:
build-electron:
# Desktop packaging re-signs the bundled CLI on macOS and Windows, so it starts from the unsigned build.
# Beta skips build-cli and waits for its own CLI from build-cli-beta just before Prepare.
needs:
- version
- build-cli
if: github.repository == 'anomalyco/opencode' && ((github.ref_name == 'dev' && needs.version.outputs.release != '') || github.ref_name == 'beta')
if: ${{ !cancelled() && !failure() && github.repository == 'anomalyco/opencode' && ((github.ref_name == 'dev' && needs.version.outputs.release != '') || github.ref_name == 'beta') }}
continue-on-error: false
env:
AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }}
@@ -358,34 +430,44 @@ jobs:
strategy:
fail-fast: false
matrix:
# Beta packages macOS x64 on Apple Silicon and Windows arm64 on Blacksmith. electron-builder's afterPack rejects
# a bundle whose CLI or node-pty does not match the target CPU.
settings:
- host: macos-26-intel
- host: ${{ (github.ref_name == 'beta' && 'macos-26') || 'macos-26-intel' }}
target: x86_64-apple-darwin
cli: darwin-x64-baseline
platform_flag: --mac --x64
bun_install_flags: --os=darwin --cpu=x64
bun_install_flags: ${{ (github.ref_name == 'beta' && '--os=darwin --cpu=*') || '--os=darwin --cpu=x64' }}
- host: macos-26
target: aarch64-apple-darwin
cli: darwin-arm64
platform_flag: --mac --arm64
bun_install_flags: --os=darwin --cpu=arm64
# github-hosted: blacksmith lacks ARM64 MSVC cross-compilation toolchain
- host: "windows-2025"
# Releases keep github-hosted Windows from the Tauri build, which needed an ARM64 MSVC toolchain; Electron
# packaging compiles nothing.
- host: ${{ (github.ref_name == 'beta' && 'blacksmith-4vcpu-windows-2025') || 'windows-2025' }}
target: aarch64-pc-windows-msvc
cli: windows-arm64
platform_flag: --win --arm64
bun_install_flags: --cpu=*
- host: "blacksmith-4vcpu-windows-2025"
target: x86_64-pc-windows-msvc
cli: windows-x64-baseline
platform_flag: --win
- host: "blacksmith-4vcpu-ubuntu-2404"
target: x86_64-unknown-linux-gnu
cli: linux-x64-baseline
platform_flag: --linux
- host: "blacksmith-4vcpu-ubuntu-2404-arm"
target: aarch64-unknown-linux-gnu
cli: linux-arm64
platform_flag: --linux --arm64
runs-on: ${{ matrix.settings.host }}
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
@@ -441,6 +523,48 @@ jobs:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
# Beta trades larger installers for faster packaging. These variables must stay unset elsewhere:
# electron-builder rejects an empty ELECTRON_BUILDER_COMPRESSION_LEVEL.
- name: Beta packaging settings
if: github.ref_name == 'beta'
shell: bash
run: |
# fpm sets XZ_OPT for the deb and rpm level, but xz still reads XZ_DEFAULTS, so it uses every CPU.
if [ "$RUNNER_OS" = "Linux" ]; then echo "XZ_DEFAULTS=-T0" >> "$GITHUB_ENV"; fi
# The macOS zip (7z deflate, default -mx=7) and DMG (UDZO zlib, default level 9) at level 1.
if [ "$RUNNER_OS" = "macOS" ]; then echo "ELECTRON_BUILDER_COMPRESSION_LEVEL=1" >> "$GITHUB_ENV"; fi
# Beta builds the app before its CLI exists. `bun run build` would run the prebuild hook, which copies the CLI;
# Prepare runs it once the CLI is here.
- name: Build (beta)
if: github.ref_name == 'beta'
run: bun run electron-vite build
working-directory: packages/desktop
env:
NODE_OPTIONS: --max-old-space-size=4096
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_CHANNEL: beta
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ vars.SENTRY_ORG }}
SENTRY_PROJECT: ${{ vars.WEB_SENTRY_PROJECT }}
SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
VITE_SENTRY_DSN: ${{ vars.WEB_SENTRY_DSN }}
VITE_SENTRY_ENVIRONMENT: beta
VITE_SENTRY_RELEASE: desktop@${{ needs.version.outputs.version }}
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Wait for the CLI (beta)
if: github.ref_name == 'beta'
run: bun ./script/wait-for-artifact.ts opencode-cli-unsigned-cli-${{ matrix.settings.cli }} "build-cli-beta (${{ matrix.settings.cli }})"
env:
GITHUB_TOKEN: ${{ github.token }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
name: opencode-cli-unsigned-cli-${{ matrix.settings.cli }}
path: packages/cli/dist/cli-${{ matrix.settings.cli }}
- name: Prepare
run: bun ./scripts/prepare.ts
working-directory: packages/desktop
@@ -451,6 +575,7 @@ jobs:
OPENCODE_CLI_DIST: ${{ github.workspace }}/packages/cli/dist
- name: Build
if: github.ref_name != 'beta'
run: bun run build
working-directory: packages/desktop
env:
@@ -491,7 +616,8 @@ jobs:
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
- name: Create macOS .app.tar.gz
if: runner.os == 'macOS' && needs.version.outputs.release
# Only the retired Tauri updater feed read these archives.
if: runner.os == 'macOS' && needs.version.outputs.release && github.ref_name != 'beta'
working-directory: packages/desktop/dist
run: |
if [[ "${{ matrix.settings.target }}" == "x86_64-apple-darwin" ]]; then
@@ -528,10 +654,29 @@ jobs:
}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name != 'beta'
with:
name: opencode-desktop-${{ matrix.settings.target }}
path: packages/desktop/dist/*
# Beta uploads its installers to R2 here instead of passing them through an artifact to publish, which only
# records the release from the returned file list.
- name: Upload to R2 (beta)
if: github.ref_name == 'beta'
run: bun ./scripts/upload.ts dist dist/uploaded-${{ matrix.settings.target }}.json
working-directory: packages/desktop
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: github.ref_name == 'beta'
with:
name: desktop-uploaded-${{ matrix.settings.target }}
path: packages/desktop/dist/uploaded-${{ matrix.settings.target }}.json
if-no-files-found: error
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
if: needs.version.outputs.release
with:
@@ -542,6 +687,7 @@ jobs:
needs:
- version
- build-cli
- build-cli-beta
- sign-cli-macos
- build-node-app-archive
- build-node-cli
@@ -554,7 +700,9 @@ jobs:
- uses: ./.github/actions/setup-bun
# Beta only records the desktop release, so it skips the Docker, git and AUR setup.
- name: Login to GitHub Container Registry
if: github.ref_name != 'beta'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
with:
registry: ghcr.io
@@ -562,9 +710,11 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Set up QEMU
if: github.ref_name != 'beta'
uses: docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130 # v3.7.0
- name: Set up Docker Buildx
if: github.ref_name != 'beta'
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -591,6 +741,7 @@ jobs:
path: packages/opencode/dist
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name != 'beta'
with:
name: opencode-preview-cli
path: packages/cli/dist
@@ -609,20 +760,29 @@ jobs:
path: /tmp/latest-yml
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: needs.version.outputs.release
if: needs.version.outputs.release && github.ref_name != 'beta'
with:
pattern: opencode-desktop-*
path: /tmp/desktop
merge-multiple: true
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
if: github.ref_name == 'beta'
with:
pattern: desktop-uploaded-*
path: /tmp/desktop-uploaded
merge-multiple: true
- name: Setup git committer
id: committer
if: github.ref_name != 'beta'
uses: ./.github/actions/setup-git-committer
with:
opencode-app-id: ${{ vars.OPENCODE_APP_ID }}
opencode-app-secret: ${{ secrets.OPENCODE_APP_SECRET }}
- name: Cache apt packages (AUR)
if: github.ref_name != 'beta'
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: /var/cache/apt/archives
@@ -631,6 +791,7 @@ jobs:
${{ runner.os }}-apt-aur-
- name: Setup SSH for AUR
if: github.ref_name != 'beta'
run: |
sudo apt-get update
sudo apt-get install -y pacman-package-manager
@@ -654,6 +815,7 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
OPENCODE_DESKTOP_DIST: /tmp/desktop
OPENCODE_DESKTOP_UPLOADED: ${{ (github.ref_name == 'beta' && '/tmp/desktop-uploaded') || '' }}
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+119 -58
View File
@@ -25,13 +25,14 @@ jobs:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
e2e: ${{ steps.packages.outputs.app == 'true' && github.ref_name != 'dev' && github.head_ref != 'dev' }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
@@ -48,7 +49,8 @@ jobs:
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --output=json > affected.json
# Storybook hosts the component tests, but no app package depends on it.
bun x turbo@2.10.2 ls --affected --filter=@opencode/app --filter=@opencode/storybook --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
@@ -70,7 +72,8 @@ jobs:
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
- name: Setup Node
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
@@ -93,15 +96,6 @@ jobs:
git config --global user.email "bot@opencode.ai"
git config --global user.name "opencode"
- name: Cache Turbo
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: node_modules/.cache/turbo
key: turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-${{ github.sha }}
restore-keys: |
turbo-${{ runner.os }}-${{ hashFiles('turbo.json', '**/package.json') }}-
turbo-${{ runner.os }}-
- name: Run unit tests
timeout-minutes: 20
run: |
@@ -134,32 +128,6 @@ jobs:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Verify compiled service lifecycle
if: always()
timeout-minutes: 10
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
- name: Setup Node build runtime
if: always()
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: always()
timeout-minutes: 15
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
- name: Check generated client
if: runner.os == 'Linux'
working-directory: packages/client
@@ -170,9 +138,8 @@ jobs:
working-directory: services/www
run: bun run check:generated
e2e:
name: e2e (${{ matrix.settings.name }})
needs: affected
build:
name: build (${{ matrix.settings.name }})
strategy:
fail-fast: false
matrix:
@@ -182,39 +149,112 @@ jobs:
- name: windows
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
defaults:
run:
shell: bash
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
# Pull requests diff against the merge's first parent; pushes diff against the previous dev head.
fetch-depth: ${{ github.event_name == 'pull_request' && 2 || 0 }}
- name: Setup Bun
uses: ./.github/actions/setup-bun
- name: Verify compiled service lifecycle
timeout-minutes: 10
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build.ts --single --skip-install
bun run script/service-smoke.ts
# Unit tests import workspace sources and never build the libraries they use, so this checks those builds. The
# CLI and app closures hold every library a tested package depends on. The builds are tsc and Bun.build with no
# platform-specific steps, and the libraries publish from Linux, so Windows skips them.
- name: Build workspace libraries
if: ${{ !cancelled() && runner.os == 'Linux' }}
timeout-minutes: 10
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
bun turbo build --concurrency=3 --filter='@opencode/cli^...' --filter='@opencode/app^...'
exit 0
fi
bun turbo build --affected --concurrency=3 --filter='@opencode/cli^...' --filter='@opencode/app^...'
env:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Setup Node build runtime
if: ${{ !cancelled() }}
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: "26.4.0"
- name: Verify Node build
if: ${{ !cancelled() }}
timeout-minutes: 15
working-directory: packages/cli
env:
NODE_OPTIONS: ${{ runner.os == 'Windows' && '--max-old-space-size=4096' || '' }}
run: |
bun run script/build-node.ts --single --skip-install --outdir=dist/node
bun run script/service-smoke.ts --node
e2e-suite:
name: e2e (${{ matrix.settings.name }}, ${{ matrix.suite.id }})
needs: affected
if: needs.affected.outputs.e2e == 'true'
strategy:
fail-fast: false
matrix:
settings:
- name: linux
host: blacksmith-4vcpu-ubuntu-2404
- name: windows
host: blacksmith-4vcpu-windows-2025
# Playwright splits whole spec files across shards by test count, not duration. Every app-N
# entry must use the same total, and the entries must cover 1..total. The service worker suite
# reuses shard 2's production build. Each component suite runs once per OS in its own leg, so the
# two suites' Storybook servers and test time are not added up on one runner.
suite:
- id: app-1
shard: 1/2
- id: app-2
shard: 2/2
- id: session-ui-components
- id: app-components
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'dev' && github.head_ref != 'dev' }}
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.playwright-browsers
defaults:
run:
shell: bash
steps:
- name: Checkout repository
if: env.E2E_ENABLED == 'true'
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node
if: env.E2E_ENABLED == 'true'
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
# Playwright 1.59 hangs while extracting Chromium with Node 24.16.
node-version: "24.15"
- name: Setup Bun
if: env.E2E_ENABLED == 'true'
uses: ./.github/actions/setup-bun
- name: Read Playwright version
if: env.E2E_ENABLED == 'true'
id: playwright-version
run: |
version=$(node -e 'console.log(require("./package.json").workspaces.catalog["@playwright/test"])')
echo "version=$version" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
if: env.E2E_ENABLED == 'true'
id: playwright-cache
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
@@ -222,47 +262,47 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
if: runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
if: steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests against production build
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:built
if: matrix.suite.shard
run: bun --cwd packages/app test:e2e:built --shard=${{ matrix.suite.shard }}
env:
CI: true
timeout-minutes: 30
timeout-minutes: 15
- name: Verify service worker precaching and upgrades
if: env.E2E_ENABLED == 'true'
if: matrix.suite.shard == '2/2'
working-directory: packages/app
run: bunx playwright test --config e2e/service-worker/playwright.config.ts
timeout-minutes: 5
- name: Run session UI component tests
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
if: matrix.suite.id == 'session-ui-components'
run: bun --cwd packages/session-ui test:components
env:
CI: true
timeout-minutes: 15
- name: Run app component tests
if: ${{ !cancelled() && env.E2E_ENABLED == 'true' }}
if: matrix.suite.id == 'app-components'
run: bun --cwd packages/app test:components
env:
CI: true
timeout-minutes: 15
- name: Upload Playwright artifacts
if: always() && env.E2E_ENABLED == 'true'
if: always()
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
name: playwright-${{ matrix.settings.name }}-${{ matrix.suite.id }}-${{ github.run_attempt }}
if-no-files-found: ignore
retention-days: 7
path: |
@@ -272,3 +312,24 @@ jobs:
packages/session-ui/component-tests/playwright-report
packages/app/component-tests/test-results
packages/app/component-tests/playwright-report
# Branch rules require one e2e check per OS. These keep those names and pass only when every
# leg above passed, or when no leg was needed; a failing leg on either OS fails both checks.
e2e:
name: e2e (${{ matrix.os }})
needs: [affected, e2e-suite]
if: ${{ !cancelled() }}
strategy:
fail-fast: false
matrix:
os: [linux, windows]
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Check browser test results
env:
AFFECTED: ${{ needs.affected.result }}
SUITES: ${{ needs.e2e-suite.result }}
EXPECTED: ${{ needs.affected.outputs.e2e == 'true' && 'success' || 'skipped' }}
run: |
echo "affected packages: $AFFECTED, browser test legs: $SUITES (expected $EXPECTED)"
test "$AFFECTED" = success && test "$SUITES" = "$EXPECTED"
+138
View File
@@ -0,0 +1,138 @@
---
name: app-e2e-tests
description: Use when writing, changing, reviewing, or debugging Playwright e2e tests in packages/app/e2e (regression keepers and user stories). Covers the mocked opencode server harness and the Bun static server, per-test isolation, page-load and route cost, the suite-wide service-worker block, races with first mount, hover, measurement, clock, dialogs and shortcuts, projects, shards and worker scheduling, rehearsing Windows timing locally, and diagnosing a CI flake from its artifacts. Not for Storybook component tests or e2e/performance benchmarks.
---
# App e2e tests
Read `packages/app/e2e/AGENTS.md` and the Tests section of `packages/app/AGENTS.md` first. They define what to test, which locators to use, and how to assert. `packages/app/README.md` (E2E Testing) documents run modes and env vars. This skill covers what makes these tests slow or flaky in CI, which runs the suite as two shards per OS on 4-vCPU runners (`.github/workflows/test.yml:207-229`). Most races below were found on Windows.
All paths below are relative to `packages/app` unless they start with `packages/`, `services/` or `.github/`.
## What a test runs against
- **No opencode backend.** Each shard leg runs `bun --cwd packages/app test:e2e:built --shard=<n>/2` (`test.yml:274-279`). It builds once and starts one Bun static server, `e2e/utils/serve.ts`, on `127.0.0.1:${PLAYWRIGHT_PORT ?? 3000}`. All 5 workers share it (`playwright.config.ts:23-27`).
- `serve.ts` keeps vite preview's SPA fallback rules. Do not move built e2e back to vite preview. On case-insensitive file systems it synchronously reads the whole `dist/_assets` directory (about 3,100 files) for every asset request, so every worker's page loads wait in line. The `e2e/performance` configs still use it to keep their baselines comparable.
- **The API is an in-memory mock per page,** running in the test worker. `mockOpenCodeServer` (`e2e/utils/mock-server.ts:202`):
- answers `page.route("**/api/**")` with an Effect HttpApi handler;
- fakes `/api/event` SSE with an init-script `fetch` override;
- serves PTY sockets through `routeWebSocket`.
- **Unmocked routes fail the test** with a 501 and `Unmocked API request` (`mock-server.ts:403-411`). Add the route to `mock-server.ts` once instead of routing around it in a spec.
- **Build pages with the helpers:**
- `mockWorkspace`, `openSession`, `openDraft`, `openWorktreeDraft`, `mockRemoteServer`, `openSettings` (`e2e/utils/workspace.ts`);
- `setupTimeline` (`e2e/utils/timeline.ts:209`) for streamed timelines, `mockServers` (`mock-server.ts:495`) for several origins;
- `seed` (`e2e/utils/app.ts:61`) for storage, `holdRoute` (`app.ts:207`) to hold requests until you release them.
- **Deliver events** with the mock's `push`, which buffers until the app connects, or with `installSseTransport` (`e2e/utils/sse-transport.ts:60`), whose `send` resolves on delivery. After `page.reload()`, await `transport.waitForConnection()` before sending (`e2e/regression/timeline-stream.spec.ts:1700-1702`).
- **Windows-only failures are races, not Windows code paths.** Mock directories are `C:/OpenCode/<name>` on every OS (`workspace.ts:33`), and nothing touches the disk. Lazy chunks, stylesheets and responses finish out of order more often on the Windows runners.
## Isolation
- **No shared state between tests.** Each test gets a fresh context, mock handler and init scripts. Outside `e2e/service-worker`, which runs under its own config and shares builds through a worker fixture (`e2e/service-worker/cache.spec.ts:21`, `:74`), app e2e has no `beforeAll`, serial mode, `test.extend` or worker fixtures. Keep it that way so `-g` and `PLAYWRIGHT_FULLY_PARALLEL=1` keep working.
- **Handlers mutate the objects you pass.** Creating a session pushes into `sessions` (`mock-server.ts:839`). Renaming writes `session.title` (`:1340`), and VCS init writes `project.vcs` (`:1019`).
- Call `session()`/`project()` inside the test.
- `mockWorkspace` and `openSession` copy what you pass (`workspace.ts:36-48`).
- `mockOpenCodeServer` and `mockServers` use your objects as they are. Module-level fixtures like `multi-server.spec.ts:10-19` are therefore shared by every test in the file.
- **Timeline builders keep state per test attempt** (`timeline.ts:165-198`). Call stateful builders such as `assistantMessage` or `partUpdated` inside the test. Only pure ones such as `toolPart` belong at module scope.
- **Seed before the first `goto`.** `seed` writes storage once per tab behind a `sessionStorage` marker, so reloads keep what the app stored (`app.ts:59-90`). It cannot reset state mid-test.
## Keep it fast
- **Navigate once.** Every `goto` or `reload` is a cold load of the production bundle: about 150 requests per session open, about 125 of them static files. Each test has a new context, and any `page.route` disables the HTTP cache. Reach later states through the UI or through pushed events. Reload only when surviving a reload is the contract.
- **Service workers are already blocked suite-wide** (`serviceWorkers: "block"`, `playwright.config.ts:66-67`). Do not add a per-file block; the 11 in `e2e/regression` are redundant.
- Production registers `/sw.js` on load (`src/entry.tsx:54-55`) and precaches about 1,600 files (`globPatterns: ["**/*"]`, `vite.pwa.ts:34`). `e2e/service-worker/` covers it under its own config. The main config ignores that directory (`playwright.config.ts:33-36`), and CI runs it after shard 2 (`test.yml:281-285`).
- Opt back in with `test.use({ serviceWorkers: "allow" })` only when the worker is the contract. It never controls the first load (`clientsClaim: false`, `vite.pwa.ts:26-28`). After a reload it can control the page, and page routes may then miss its requests.
- Every built-mode page load logs Playwright's block warning. Console collectors must ignore it, as `captureConsoleWarnings` does (`e2e/utils/command-palette.ts:15-26`).
- **Match new routes with a RegExp or a query-aware glob, not a function.**
- A single function matcher makes Playwright intercept `**/*` for the page. Every static asset then round-trips to the test worker (250 continued requests in one Windows test).
- Existing `holdRoute` calls such as `review.spec.ts:51` and `mockMissingLocation` (`mock-server.ts:480`) still use functions and pay this. Converting one route gains nothing while another function route is registered on the page.
- Globs match the whole URL, so `**/api/vcs/init` misses `/api/vcs/init?location...`. Write `/\/api\/vcs\/init\?/` or `"**/api/session?*"` (`command-palette.spec.ts:10`, `:28`).
- **Use the stress fixture only when scale is the contract.** `fixture`, `mockStressTimeline` and `installStressSessionTabs` (`e2e/utils/session-fixture.ts:336`, `:436`, `:448`) serve a long timeline with 300-line diffs.
- Its cold mount reveals rows only after worker-rendered markdown arrives. Mounting one of its diffs blocked the renderer for 4-11 s on CI.
- The 14 regression files that use it held about half of the e2e flakes CI found.
- Prefer `openSession` or `setupTimeline` with the few messages the test needs.
- **Assert inside loops only when each step is the contract.** A web-first assertion that fails its first check retries after 100, 250, 500, then 1,000 ms. The 10-step loop at `mobile-timeline-scroll.spec.ts:688-693` takes about 8 s, mostly backoff.
- **Real-time waits cost their full length on every run and retry** (`settings-wsl.spec.ts:268-276` waits out a 10 s health check). Prefer `page.clock`:
- install it before the `goto` or `reload` whose timers you need (`timeline-stream.spec.ts:1700`);
- keep fast-forwards under the client's 45 s event-stream idle timeout (`packages/client/src/solid/connection.ts:35`).
- **Leave benchmark pacing out of regression tests.** The `sendAll` delays and `messageDelay` exist for benchmarks (`timeline.ts:306`, `mock-server.ts:82`). Hold message pages with `beforeMessagesResponse` instead.
## Wait for state, not time: races CI found
Most of these rarely reproduce on an idle local machine.
- **First mount happens in stages.** `openSession` and `expectSessionTitle` return once the composer is editable and a title is visible. Later the loading header's `<h1>` is replaced, and a cold timeline keeps its rows hidden while it scrolls itself to the bottom. Before you measure or hover in the session view, wait for something that only the final mount renders, such as the last `[data-timeline-row="UserMessage"]` or the row you click next.
- **Hovering during a scroll loses the tooltip.** A tooltip closes when its trigger scrolls, and Playwright's mouse never moves again. `session-summary.spec.ts:21-26` waits for the last row before it hovers; without that wait it needed a retry in 6 of 36 Windows runs.
- **One-shot measurements can hit a detached node.** `locator.evaluate` resolves the element, then evaluates in a second round trip. If the node is replaced in between, `getBoundingClientRect` returns an all-zero rect and `boundingBox` returns `null`. The subagent title-anchor test failed with "Expected: 0" in 11 runs until it waited for a timeline-only button (`subagent-child-navigation.spec.ts:60-64`). Take reference measurements once the final structure is in place. Polled reads re-resolve and are safe.
- **`goto` returns late, so a modal may already be open.**
- `goto` waits for `load`, which can fire after the app is already interactive.
- An open dialog sets `aria-hidden` on the app root, which hides everything behind it from role locators.
- What's New opens 500 ms after the changelog response, and it raced the Settings check in 11 of 36 Windows runs. Hold the request that opens a modal until readiness is asserted, as `startUpgraded` does (`whats-new.spec.ts:227-234`).
- **`pauseAt(new Date())` right after `install()` can throw** "Cannot fast-forward to the past", because the installed clock starts slightly ahead of the runner's clock. Install at an explicit time and pause at an offset from it (`tabs.spec.ts:443-445`).
- **A shortcut that does nothing is usually a product guard, not timing.** The global handler ignores keys while `dialog.active` (`src/shell/commands/command.tsx:489`). `active` is the top dialog that has not started closing (`packages/ui/src/context/dialog.tsx:52`, `:310-311`). A dialog that has started closing does not block keys during its 100 ms exit (`dialog.tsx:87`), but any open dialog does, including one you did not expect. Check that guard before you add a wait.
- **Shortcut tests fail on local macOS; that is not a regression.**
- The Desktop Chrome profile reports a Windows UA and `navigator.platform = "Win32"`, so the app (`IS_MAC`, `command.tsx:14`) expects Ctrl. On macOS, `ControlOrMeta` sends Meta.
- Every test that opens the palette through `openCommandPalette` (`e2e/utils/command-palette.ts:50`) fails locally and passes in CI, as do `tabs.spec.ts:835` and `timeline-cache.spec.ts:163`. Verify on Linux or in CI, or press `Control` in a throwaway copy. Do not edit tests for macOS.
- A new test can pick the modifier from the page, as `session-summary.spec.ts:29-30` does.
- **Font probes mislead.** `document.fonts.check()` can stay false after the font has loaded: when a lazy `@layer` stylesheet activates late, Chromium rebuilds font faces as unloaded. The code-font test failed outright in 9 of 36 Windows runs that way. Polling a probe that lays out text can make itself true. Await `document.fonts.ready`, lay out new text, and then check once (`timeline-cache.spec.ts:281-293`).
- **Use the harness's signals.** `holdRoute(...).arrived` resolves when the request reaches the route, and `transport.send` resolves after delivery. `setupTimeline(...).waitForPart` resolves once a part renders (`timeline.ts:328`).
- **Decide which side is wrong.** Three of eight investigated e2e flakes were product bugs, each since fixed in the product:
- a stale scroll offset restored when a scroll settles (`src/session/timeline/observe-element-offset.ts`);
- a video double-click that also entered native fullscreen (`packages/gui-extensions/src/file/artifact-view.tsx:617`);
- shortcuts dropped while a dialog closed.
If a user could hit the interleaving, fix the product and keep the assertion.
## Scheduling
- **Two projects split the suite** (`playwright.config.ts:69-86`). `chromium-touch-scroll` runs only `mobile-timeline-scroll.spec.ts`, which the `touchScroll` regex (`:29`) matches, with `fullyParallel: true`.
- Projects queue in declaration order, so its 37 tests start after every `chromium` file and spread across all workers. As one serial group, the file took 3-5 minutes in CI.
- A rename that the regex misses quietly puts the file back in `chromium` as that serial group.
- `--project chromium`, UI mode and the VS Code extension's default project skip the file.
- **In `chromium`, files are the scheduling unit.** `fullyParallel` is off unless `PLAYWRIGHT_FULLY_PARALLEL=1` (`:42`), so a file's tests run in order on one worker. A long file sets a floor and leaves an idle tail. New regressions go into the area's keeper file (`e2e/AGENTS.md`), so keep each case cheap.
- **Shards split whole files by test count, not duration** (`test.yml:219-222`). The touch project queues last, so its tests always fall in shard 2. To reproduce a leg's schedule, pass the same `--shard`.
- **Worker-scoped options fork a worker.** `launchOptions`, `channel`, `headless`, `video`, `trace`, `screenshot` and `defaultBrowserType` give a file its own worker hash.
- Playwright groups a project's files by worker hash in order of first appearance, so such a file runs after every default file in its project.
- Use test-scoped options instead: `viewport`, `deviceScaleFactor`, `hasTouch`, `isMobile`, `userAgent`, `colorScheme`, `serviceWorkers`, `locale`.
- Use `launchOptions` only when a Chromium flag is the contract, with a comment (`patch-border.spec.ts:5-9`).
- **Never spread `devices[...]` into `test.use`.** iPhone entries set `defaultBrowserType: "webkit"`, which forks a worker and needs a browser that CI never installs (`test.yml:269-272`). Copy the fields you need, as `mobile-timeline-scroll.spec.ts:23-28` does.
- **Retries are not free.** CI retries twice (`playwright.config.ts:44`). Each failure restarts the worker's browser and costs 10-30 s of cleanup on Windows. The first retry then reruns with a trace and a video (`:62-65`).
- **Timeouts are not a fix.** The limits are 60 s per test and 10 s per assertion (`playwright.config.ts:38-41`). `APP_READY_TIMEOUT` (30 s, `e2e/utils/waits.ts:4`) covers only first readiness.
- When one wait is genuinely long, scope the timeout to that assertion and say what takes the time. `settings-wsl.spec.ts:268-276` does this with `{ timeout: 20_000 }` for a 10 s health check.
- `test.setTimeout` does not lengthen assertions. `timeline-history.spec.ts:73` sets 240 s without a comment and still fails at the 10 s poll at `:127`.
## Before you push
Run from `packages/app`:
- [ ] Run in built mode, as CI does: `bun run test:e2e:built -- e2e/regression/<file>.spec.ts`. Dev mode serves unbundled modules, with the API on another origin (`playwright.config.ts:19-21`).
- [ ] Use a free port when another worktree or agent may hold 3000: `PLAYWRIGHT_PORT=4310 bun run test:e2e:built -- ...`. Built runs never reuse a server, and `serve.ts` fails on a busy port. Stay below 49152 on Windows: it reserves ports from there up and hands them out for outgoing connections (`services/www/src/docs/content/cli/web.mdx:43`, `packages/client/test/fixture/service-fixture.ts:108-109`).
- [ ] To repeat without rebuilding:
- build once with `VITE_OPENCODE_TEST_FIXTURES=1 bun run build`;
- keep `bun e2e/utils/serve.ts --host 127.0.0.1 --port 4310` running;
- run `PLAYWRIGHT_BUILD=1 PLAYWRIGHT_BASE_URL=http://127.0.0.1:4310 bun run test:e2e -- ...`.
- [ ] Repeat new or changed tests at CI concurrency without retries: `--repeat-each=10 --workers=5 --retries=0 -g "<title>"`. Run the file once with `PLAYWRIGHT_FULLY_PARALLEL=1` to prove that order does not matter.
- [ ] Rehearse Windows timing by delaying assets, not only the CPU. CPU throttling at 4x and 6x did not reproduce the tooltip or title-anchor races, but delayed responses did.
- In a throwaway copy, hold the suspect lazy chunk, stylesheet or API response with `holdRoute`, and release it 0.6-1.5 s after `arrived`.
- Route worker scripts with `page.context().route`.
- A race fix must fail before the fix and pass after it (`packages/app/AGENTS.md`, Tests).
- [ ] Check the cost on a noisy machine. Other agents add load, and local A/Bs at a load average of 20-35 were inconclusive.
- Compare the medians of interleaved before/after runs at `--workers=5`, or the step durations in the report (`bun run test:e2e:report`).
- Count every `goto` and `reload` you added.
- [ ] `bun run typecheck:e2e` reports 4 existing errors: `timeline-stream.spec.ts:1822`, `:1825`, and two `e2e/performance` probes. Add none. If you changed `playwright.config.ts`, update `test-browser/playwright-config.test.ts`.
## When CI flakes
- **Find it.** e2e runs on pull requests that affect `@opencode/app` or `@opencode/storybook`, including their workspace dependencies, and on every manual dispatch (`.github/workflows/test.yml:42-54`). It never runs on dev (`:28`).
- Each OS has the legs `app-1` and `app-2` (the shards), `session-ui-components` and `app-components`.
- The required `e2e (linux)` and `e2e (windows)` checks only aggregate those legs (`:316-335`).
- A pass on retry leaves a leg green. Check each Windows shard report's Flaky list, and make sure your new tests are not on it.
- **Download:** `gh run download <run-id> -n playwright-windows-app-2-1 -D <dir>`. Artifacts are named `playwright-<os>-<leg>-<run attempt>` (`test.yml:301-314`). App e2e results are in `<dir>/app/e2e/`.
- **In CI, the failing first attempt has no trace and no video** (`playwright.config.ts:62-65`).
- `test-results/<test>-<project>/` holds `error-context.md` (the aria snapshot at the failure) and `test-failed-1.png`.
- `<test>-<project>-retry1/` holds `trace.zip` and `video.webm` whether the retry passed or failed, plus failure files only if it failed. A second retry records neither.
- **Read `error-context.md` first.** An open dialog or an element with no accessible name explains most "element(s) not found" errors.
- **Get timings from the retry trace:** `bunx playwright show-trace <dir>/app/e2e/test-results/<test>-<project>-retry1/trace.zip`. To see which side won, compare when a screencast frame changed with when the response landed.
- **Get the transient state from a failing video,** sampled frame by frame (`ffmpeg -i video.webm -vf fps=4 frame-%03d.png`). That is how the tooltip race showed the tooltip closing in the same frame the timeline rows appeared.
- In CI, retry1's video shows the failure only if the retry failed too.
- Locally, a failing test keeps its video (`retain-on-failure`), so you can record the race with `--repeat-each` and delayed assets.
- **Open the report:** `bunx playwright show-report <dir>/app/e2e/playwright-report`. It shows step durations for every attempt, including passing ones.
+117
View File
@@ -0,0 +1,117 @@
---
name: component-tests
description: Use when writing, changing, debugging, or reviewing Storybook-based Playwright component tests. That covers specs in packages/app/component-tests or packages/session-ui/component-tests, `*.fixture.tsx` files there or beside source (such as packages/gui-extensions/src/browser/panel.fixture.tsx), the stories they mount, and the shared harness in packages/storybook (playwright/config.ts, playwright/story.ts, .storybook/main.ts, preview.tsx, mocks). Also use when a component test is flaky, passed on retry, or fails with "Execution context was destroyed", "Failed to fetch dynamically imported module", or `#storybook-root` hidden. Covers mount args, source modules imported by URL, Vite dependency pre-bundling and reloads, cold vs warm Storybook, mobile drawers, geometry, and checks before pushing.
---
# Component tests
Each package suite starts one Storybook/Vite dev server, and every worker shares it (`packages/storybook/playwright/config.ts:17-24`; CI uses 2 workers, `config.ts:15`). With `fullyParallel` (`config.ts:12`), the tests of one file run on several workers in any order. There is no backend: server, SDK and sync modules are in-page mocks. Every test gets a fresh context, and `mount` is a full iframe navigation (`packages/storybook/playwright/story.ts:37`). Slow or flaky component tests in CI have come from five sources: the shared server's dependency optimizer, module resolution that depended on the install layout, a cold first story, a product race (the mobile drawer), or an action that ran before the UI reached the state it needed.
Read these first rather than repeating them:
- `packages/storybook/playwright/README.md` covers layout, run commands and "Adding a test".
- `packages/app/AGENTS.md` "Tests" decides whether a test should exist, when theme or direction cases are allowed, and that a regression test must fail before the fix.
- `packages/app/e2e/AGENTS.md` "Test Hygiene" applies here unchanged.
## Stories and `mount`
- **Location.** Put the story next to the production component under `src/` of `ui`, `session-ui`, `app` or `gui-extensions`. Storybook indexes only the `stories` globs in `packages/storybook/.storybook/main.ts:44-49`.
- **Story ID.** It is the meta `id` if one is set (`packages/session-ui/src/components/session-review.stories.tsx:25`). Otherwise it is the kebab-cased `title`, then `--`, then the kebab-cased export. Check it at `http://127.0.0.1:6006/?path=/story/<id>`. A wrong ID never renders, so `mount` waits out its full 30 s root wait (`story.ts:39`) and then fails.
- **URL args can be dropped.** `mount(id, { args, globals })` writes them into the URL (`story.ts:20-36`). Storybook drops an arg when the story declares no `args`/`argTypes` entry for its key. It also drops one whose value is not `[A-Za-z0-9 _-]*`, a number, a hex color or `rgb()`/`hsl()`, which rules out paths, URLs and file names with dots. The only trace is the browser console warning "Omitted potentially unsafe URL args.", and the test then passes on the default scenario.
- Give every arg a typed default. With `args: { split: false }` (`session-review.stories.tsx:129`), `split:false` arrives as a boolean.
- Pick punctuated values inside the story, from a `scenario`-style arg.
- **Globals** are `theme`, `direction` and `locale` (`packages/storybook/.storybook/preview.tsx:106-122`). `mount` also sets `a11y.manual` (`story.ts:29-36`); caller globals override it.
- **Portaled UI.** `mount` returns `#storybook-root`. Dialogs, menus and drawers render outside it, so locate them from `page` (`packages/app/component-tests/composer.spec.ts:101-107`).
- **Server state** comes from the `resolve.alias` list in `viteFinal` (`main.ts:72-94`), which points app server, SDK and sync modules at `packages/storybook/.storybook/mocks/app/**`. Extend a mock rather than adding a server. Restore mock state in `onCleanup` (`mockProviderAuth`, `packages/app/src/providers/connect/dialog.stories.tsx:23`) so that switching stories in the Storybook UI does not inherit it. Tests reset it on every mount.
- **Requests the mocks do not answer.** Route them with `page.route`. That includes server endpoints such as `/api/fs/read` (`packages/session-ui/component-tests/read-image.spec.ts:20`) and external URLs such as favicons (`packages/session-ui/component-tests/markdown.spec.ts:217`).
## Fixtures imported by URL
Use these when the contract is not visible in a story, such as construction counts, unmount races or cache state.
- **Fixture file.** Put a `*.fixture.tsx` file in `component-tests/` or beside the source it drives (`packages/gui-extensions/src/browser/panel.fixture.tsx`). It exports a `mountX()` that renders into a host with a `data-testid` (`packages/session-ui/component-tests/basic-tool.fixture.tsx:7-46`). Never end the name in `.spec` or `.test`.
- **Mount it in three steps.**
1. Mount a light story first, so that the app styles load.
2. Import the fixture and call it inside `page.evaluate`.
3. Locate it with `page.getByTestId` (`packages/session-ui/component-tests/basic-tool.spec.ts:30-36`).
It renders outside the preview decorators, so it must supply its own providers (`packages/app/component-tests/browser-pane-restore.spec.ts:69-83`).
- **Module URLs.** Build each one with `sourceURL(new URL("<relative path with extension>", import.meta.url))` (`story.ts:10`), written as a string literal in the spec (`browser-pane-restore.spec.ts:6-24`). Any other spelling either fails to load or loads a second module instance whose contexts the page never provides; a hand-written `/@fs//...` is the documented second-instance case (`story.ts:6-9`). The literal also matters for pre-bundling (next section).
- **Aliases.** A module imported by URL is the real file. App code that reaches the same module through a `main.ts` alias gets the mock (`browser-pane-restore.spec.ts:95-96`). Decide which of the two the contract needs.
- **Spec imports.** Import source modules into the spec with `import type` only (`browser-pane-restore.spec.ts:2-4`). A value import runs browser code in the Node runner.
- **Async order.** Let the test decide the order through deferreds the fixture exposes (`mountExtensionHost`, `packages/app/component-tests/extension-host.fixture.tsx:135`). Await the outcome they lead to (`extension-host.spec.ts:46-77`), or poll in-page state with `until` (`extension-host.fixture.tsx:124-132`). Do not copy the fixed in-page `setTimeout` waits that other cases in `extension-host.spec.ts` (`:38`, `:88-90`, `:108-119`) use to prove a negative.
## Vite pre-bundling and reloads
`viteFinal` drops `storybook:optimize-deps-plugin` (`main.ts:105-108`) and sets `optimizeDeps` itself (`main.ts:56-67`). Its `entries` are every `*.stories.tsx` and `*.worker.ts` under the four `src/` trees, every module a component spec names by a `./` or `../` string literal ending in `.ts`/`.tsx` (collected at `main.ts:15-29`), and `preview.tsx`; `include` adds the Storybook preview runtime. Vite pre-bundles their bare imports before the first page loads. A dependency the scan misses is found on first import instead. If that changes chunks already served, Vite logs `✨ optimized dependencies changed. reloading` and sends `full-reload` with path `*` to every page in every worker, and requests with a stale `?v=` hash get a 504 "Outdated Optimize Dep".
- **The bar is zero reloads.** A cold run of either suite prints neither `new dependencies optimized` nor `optimized dependencies changed`; neither did the Linux and Windows CI legs. Dropping `preview.tsx` and `include` from `main.ts` brings one reload back, so these lines are the signal to watch.
- **Keep new code reachable by the scan.** A module path built at runtime, in a template literal, or without its extension is not collected, and neither is a spec named other than `*.spec.ts`. Name stories `*.stories.tsx`: Storybook indexes other extensions, but the scan reads only that one. Name workers `*.worker.ts`, since the scan does not follow `new Worker(new URL(…))`. Add an `optimizeDeps.include` entry only for a dependency the scan cannot reach. Vite names it in `✨ new dependencies optimized: …`, and `DEBUG=vite:deps` replaces that line with scan and discovery detail.
- **Symptoms.** "page.evaluate: Execution context was destroyed", "Failed to fetch dynamically imported module …/@fs/…", or fixture state that is gone right after `mount` ("Registrations: 1" not found, an empty tab strip).
- **Only `mount()` survives a reload,** because it navigates back to the same URL. A reload loses everything set up after it: `page.evaluate` imports, mounted fixtures, state from earlier clicks, and the elements your locators pointed at. Another worker's discovery can trigger it at any moment.
- **Fix reloads in the scan, never with retries, timeouts or import order.** The import-everything-before-mount `Promise.all` in `browser-pane-restore.spec.ts:43-58` (comment at `:56`) only narrowed the window. Before the entries existed, that file's `:123` and `:150` were retried in 31 CI jobs each, and failed attempts cost about 39-47 s (Linux) and 34 s (Windows) of worker time per run.
- **Clear the cache whenever entries matter.** Vite's `getConfigHash` covers `optimizeDeps.include`, `resolve` and plugin names, but not `entries`, and a valid `_metadata.json` skips the scan. A warm cache therefore never scans a new story or fixture, so it shows neither the reloads nor the absence of reloads that CI's cold scan would. Delete `packages/storybook/node_modules/.cache` before judging.
- **Resolve through package resolution, not paths into `node_modules`.** `main.ts:69-71` dedupes `@tanstack/solid-query` instead of aliasing it into `app/node_modules`, a path that exists only under bun's isolated linker. While Windows CI installed hoisted, that alias made every app story fail there, and 23 of 30 runs hit the step's 15-minute cap. CI now runs the same `bun install --frozen-lockfile` on every OS (`.github/actions/setup-bun/action.yml:66-68`), so such an alias would pass CI and still break a hoisted local install.
## Cold vs warm, and what a mount costs
- **CI is always cold.** Each suite runs in its own leg on a fresh runner (`test.yml:228-229`), so every leg starts Storybook with no cache and runs the full scan.
- **Do not count the first test.** In one CI run of this harness, the first test per worker took 5.7-7.0 s on Linux and 14-15 s on Windows. Warm tests had a median of 1.5 s (app) and 2.0 s (session-ui) on Linux and 2.4-2.6 s on Windows. A warm `mount` (navigation plus root wait) had a median of 1.0 s (app) and 1.5 s (session-ui) on Linux and 1.7-1.9 s on Windows, which is most of a typical test. Judge cost by warm attempts.
- **Local reuse.** Playwright reuses whatever answers on the port (`config.ts:20`; default 6006, `config.ts:3`). That hides cold cost and reloads, and with several worktrees another checkout's Storybook can serve your tests its own code. Set `PLAYWRIGHT_STORYBOOK_PORT` or `PLAYWRIGHT_STORYBOOK_URL` (`config.ts:3-4`). On Windows pick a port below 49152: the dynamic range starts there, and Hyper-V and WSL reserve blocks inside it (`netsh int ipv4 show excludedportrange protocol=tcp`). Two Storybooks in one checkout share, and race on, `packages/storybook/node_modules/.cache/storybook/<version>/<hash>/sb-vite`, so a separate port is not enough: use a separate worktree.
- **Keep mounts few.** Put one scenario in each test, and resize in place with `page.setViewportSize` (`composer.spec.ts:28-29`) instead of mounting again.
- `a11y.manual` makes addon-a11y skip its axe run after each render, so changing `a11y.test` in `preview.tsx:133-135` is not enforced by these tests. Assert accessibility in the spec when it is the contract.
- CI records video only on the first retry, next to the trace; local runs keep `retain-on-failure` (`config.ts:27-30`).
## Mobile, touch and drawers
- **Touch.** Use `story.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } })` inside a `story.describe` (`packages/session-ui/component-tests/session-timeline-mobile.spec.ts:3-4`). These options are test-scoped, so the shared workers stay. Prove that emulation took effect with `matchMedia("(hover: none)")` (`composer.spec.ts:244`), and interact with `tap()` or `page.touchscreen.tap` (`session-timeline-mobile.spec.ts:41-42`).
- **No `launchOptions`.** They are worker-scoped, so the file gets its own worker hash and its own workers. App e2e had to move `packages/app/e2e/regression/mobile-timeline-scroll.spec.ts:18` into its own `fullyParallel` project (`packages/app/playwright.config.ts:69-86`) because it otherwise formed one long serial group running alone on a single worker.
- **Drawers.** Below 768 px, pickers open in a bottom drawer (`composer.spec.ts:100-107`).
- @corvu/drawer 0.2.4 lost a close requested in the first frames after opening: focus stayed trapped in an off-screen sheet and `body` kept `pointer-events: none`. Before the fix, this caused most first-attempt failures of `composer.spec.ts:5`, in 17 of 31 Linux runs.
- `patches/@corvu%2Fdrawer@0.2.4.patch` (root `package.json` `patchedDependencies`) fixes it, so tests need no wait before pressing Escape or tapping outside.
- `composer.spec.ts:375-429` guards the patch. It dismisses in-page, before and as `data-opening` appears, because Playwright input cannot target those frames. Upgrading @corvu/drawer drops the patch, so rerun those tests.
- Before measuring or screenshotting a drawer, wait until it is fully in the viewport and has no `data-transitioning` (`packages/app/e2e/regression/mobile-model-pickers.spec.ts:25-26`).
- A stuck overlay is a product bug. Fix the product and guard it in-page, as the patch did, rather than waiting it out.
## Geometry, platforms, known failures
- **Fonts.** Inter's `@font-face` has no `font-display` (`packages/app/src/index.css:13-18`), so a story can lay out with fallback metrics and then shift. Windows CI saw 6.5 px in the composer overflow measurement (`composer.spec.ts:93-95`), which now waits first (`:25-26`). Before measuring, run `await page.evaluate(() => document.fonts.ready.then(() => undefined))` (`markdown.spec.ts:247`). The `.then` keeps the `FontFaceSet` from being returned to Node.
- **Page scroll.** Outside fullscreen, the preview frame is `calc(100vh - 2rem)` to fit Storybook's 1rem padding (`preview.tsx:82-84`). With `100vh`, every such story scrolled by 32 px, and any scroll-into-view shifted boxes (`composer.spec.ts:116` saw y go from 300 to 268). Do not give a story root `100vh`. A story taller than the viewport still scrolls, so measure relative to a container captured at the same moment, or use `parameters: { layout: "fullscreen" }`.
- **Shortcuts.** Write them as `ControlOrMeta+…` (`browser-pane-restore.spec.ts:134`). Derive expected labels from `navigator.platform` (`packages/app/component-tests/session-question-dock.spec.ts:121`).
- **Known local failure.** On macOS, `browser-pane-restore.spec.ts:123` fails after `ControlOrMeta+w` (Meta+W, `:134-135`), while CI (Control+W) passes. It is not your regression.
- **`story.fail`.** It waits out the full 10 s expect timeout on every run: `packages/session-ui/component-tests/file-tool.spec.ts:49-50` took 12.6 s on Linux CI and 14.8 s on Windows CI. Fix the product instead, or make the expected failure an immediate check once the state has settled.
## Before you push
From the owning package (`packages/session-ui` or `packages/app`):
1. **Repeat** at CI's worker count. Locally retries are already 0 (`config.ts:14`). Every attempt must pass: `bun run test:components -- component-tests/<file>.spec.ts --workers=2 --repeat-each=10`.
2. **Read durations** in `bunx playwright show-report component-tests/playwright-report`. Compare warm tests with the CI medians above. Anything much slower needs a reason in the work the test does.
3. **Rehearse load.** Late font swaps and focus restored after a menu closes (`composer.spec.ts:114-115`) race the test under load. In a throwaway copy of the spec, throttle before `mount` with `const cdp = await page.context().newCDPSession(page)` and then `await cdp.send("Emulation.setCPUThrottlingRate", { rate: 4 })`. Alternatively, repeat while the other suite runs.
4. **Run each suite cold** whenever you add a story, fixture, dependency, worker or lazy import, or change `packages/storybook`. From the repo root, once per suite, as CI runs each in its own leg. Playwright pipes Storybook's output into its own (`config.ts:22-23`), and a free port makes it start a fresh server:
```sh
rm -rf packages/storybook/node_modules/.cache
PLAYWRIGHT_STORYBOOK_PORT=6216 bun run --cwd packages/app test:components -- --workers=2 2>&1 | tee /tmp/app-components.log
grep -E "new dependencies optimized|optimized dependencies changed" /tmp/app-components.log
```
The `grep` must print nothing. Count optimizer events, not durations: under load, the same config ran anywhere from 127 to 189 s.
5. **Windows.** Check platform keys and labels and font-dependent geometry. The Windows component legs are the authoritative check.
- Do not skip on Windows, and do not raise `config.ts` timeouts, retries or the `mount` wait.
- If one test does genuinely expensive work, call `story.setTimeout(...)` in that test, with a comment that names the work.
6. **Confirm CI ran the component legs.** E2E never runs on pushes to dev (`test.yml:28`), so no post-merge run catches a regression.
- The `affected` gate filters on `@opencode/app` and `@opencode/storybook` (`test.yml:52-53`). `ui`, `session-ui` and `gui-extensions` count through app's dependencies. `workflow_dispatch` forces the legs on (`test.yml:48-51`).
- `test.yml` is listed in `globalDependencies` (`turbo.json:8`), so editing it marks every package affected.
- The required `e2e (linux)` and `e2e (windows)` checks also pass when no leg was needed (`test.yml:316-335`). Look for `e2e (<os>, session-ui-components)` and `e2e (<os>, app-components)`.
- If CI skipped them, report your local cold run in the PR.
## When CI flakes
- **Download the artifact.** Run `gh run download <run-id> -n playwright-<linux|windows>-<session-ui|app>-components-<attempt> -D <dir>` (`test.yml:301-314`). Results are in `<dir>/session-ui/component-tests/` or `<dir>/app/component-tests/`. Only the first retry has a trace and, in CI, a video: `test-results/<test>-components-retry1/`.
- **Find reloads.** Storybook's output reaches the job log with a `[WebServer]` prefix, so grep the log for `optimized dependencies changed`. In the retry trace, a reload shows up as any of:
- a second `iframe.html` load and a second `[vite] connecting...`;
- the deps `?v=` hash changing between requests;
- 504 "Outdated Optimize Dep" on `deps/*.js`.
+82
View File
@@ -0,0 +1,82 @@
---
name: tui-tests
description: Use when writing, fixing, or reviewing tests that render OpenTUI (testRender, createTestRenderer, createAppFixture) in packages/tui or packages/cli. Covers frame waits (waitForFrame, waitFor, waitForVisualIdle, flush), started vs non-started renderers, injected clocks, mock keyboard and mouse input, terminal sizes, cleanup and shared paths, and the 30s budget. Also use when a TUI test fails with "Timed out waiting for frame predicate after N passes", or when a tui test file is slow. For cli subprocess and service tests, use the unit-tests skill.
---
# TUI tests
packages/tui runs about 170 test files in one `bun test` process. CI runs it next to up to two other suites on 4-vCPU runners (`bun turbo test --affected --concurrency=3` in `.github/workflows/test.yml`), where it has taken 136-146s on Linux and 147-171s on Windows, most of it idle on frame pacing, production timers and sleeps. Most flakes come from waiting on the wrong signal and then running out of frames on a busy runner.
Read the root `AGENTS.md` Testing section (no `globalThis` mocks, run from the package directory) and its TUI Stories section. To drive the real TUI, use the `opencode-dev` skill. For CLI subprocesses, service deadlines, Effect `TestClock` and turbo test tasks, use the `unit-tests` skill.
## Frame budgets are not time
- `waitForFrame`, `waitFor` and `flush` give up after `maxPasses` (default 20). `waitForVisualIdle` gives up after `maxFrames` (default 20). One pass is one rendered frame (`DEFAULT_MAX_PASSES` in `@opentui/core/testing`). "Timed out waiting for frame predicate after 20 passes" comes from this budget, not from bun's timeout.
- **Started renderer** (`setup.renderer.start()`, as `createAppFixture` does at `packages/tui/test/fixture/app.ts:28`): frames tick at OpenTUI's default 30 fps, so 20 passes are about 0.66s. Production renders at `targetFps: 60` (`packages/tui/src/app.tsx:241`), so test frames are twice as slow as the real app.
- **Not started** (the default for `testRender` and `createTestRenderer`): `waitFor` and `waitForFrame` fail within milliseconds when no render is scheduled, and the error shows `isRunning: false`. In the same state `flush()` and `waitForVisualIdle()` return immediately without error; they do not wait for fetches or timers. Await the signal, then `renderOnce()`.
- Wait on frames only for something a frame produces. For anything else, wait on the signal itself:
- **Focus set on a timer.** `DialogSelect` focuses its filter with `setTimeout(..., 1)` (`packages/tui/src/ui/dialog-select.tsx:702`), which takes about 15ms on Windows. `typeText` sends keys to whatever is focused, so early keys are dropped and Enter then picks the default option. Put the focus in the predicate, as both dialog waits in `packages/tui/test/command-selection.test.tsx` do (`:68-70` for the model, `:75-80` for the variant): `setup.renderer.currentFocusedRenderable instanceof InputRenderable`. Before the variant wait had it, the test flaked in 5 CI runs in 20 hours, including a dev push, and a bigger budget did not help: it still failed with `maxPasses: 150`, because the keys were lost, not late. If a wait fails with the focus in the predicate, look for a state race, not the budget.
- **Markdown and code text.** `<markdown>` (`packages/tui/src/routes/session/message-parts.tsx:182`) draws no text until the shared tree-sitter worker replies, while frames keep ticking, so a plain `waitForFrame` can spend all 20 passes on a loaded runner with the text still pending. Use `waitForHighlightedFrame(setup, predicate, options?)` from `packages/tui/test/fixture/frame.ts`. When the predicate fails, it awaits every in-flight `CodeRenderable.highlightingDone` inside the frame predicate, and awaiting there does not use up passes. It accepts any setup with `renderer` and `waitForFrame`; app-lifecycle, session-group-navigation and session-home wait on transcript text this way. If the code under test accepts a client, inject `new MockTreeSitterClient({ autoResolveTimeout: 0 })` instead (`packages/tui/test/mini/scrollback.surface.test.ts:87`).
- **Requests and other state.** Await the deferred response your fetch handler returns. With no signal to await, poll with a wall-clock deadline instead of raising `maxPasses`: the file-local `waitForFrame(setup, matches)` at `command-selection.test.tsx:117` checks `captureCharFrame()` every 10ms for up to 2s. `wait` in `packages/tui/test/cli/tui/data.test.tsx:37` and `waitForModel` in `packages/tui/test/fixture/local.tsx:109` do the same for state.
- `waitForVisualIdle` returns on the first frame with `cellsUpdated === 0` by default. A frame waiting on a highlight is also quiet, so wait for the text before you measure layout or assert on it: `session-home.test.tsx:227` waits for "History message 0001" with `waitForHighlightedFrame` after `waitForVisualIdle()` instead of reading the frame directly.
- Settle steps cost frames. Each `waitForVisualIdle({ quietFrames: 3 })` takes at least 3 frames, about 100ms at 30 fps. `session-group-navigation.test.tsx` has 14 of them. When you know what you are waiting for, wait for that frame instead.
- Do not use `maxPasses` as a clock. Several waits do, and each assumes 33ms frames:
- `app-lifecycle.test.tsx:792`: 120 passes to cover the 1s reconnect delay after `setup.events.disconnect()` at `:784`;
- `app-lifecycle.test.tsx:1721-1723`: 200 passes per retry-countdown second;
- `component/dialog-shell-output.test.tsx:144`: 100 passes for a 1s poll, plus `Bun.sleep(1100)` at `:163` and `:182`;
- `session-panel.test.tsx:590`: 120 passes in `ready()` for the initial load.
With test frame rates scaled 4x, dialog-shell-output (3 tests) and session-activity-anchor (2 tests) failed. The same budgets are why tests cannot yet run at production fps. A larger budget is fine only for a long run of frame-driven work, such as scrolling 50 rows.
## Pick the renderer mode and clock
- **Component logic:** `testRender(() => <X />, { width, height })` without `start()`. Change state, `await app.renderOnce()`, then assert on `captureCharFrame()`. One frame, deterministic.
- **Time-driven renderables:** pass `clock: new ManualClock()`, call `renderer.pause()`, then advance the clock and `renderOnce()` (`fixture()` in `packages/tui/test/component/retry-provider.test.tsx:8`). `pause()` also stops `requestLive()` (spinners, animations) from starting the frame loop.
- **Clock order:** inject a clock first (OpenTUI `ManualClock`; Effect `TestClock` for Effect code, see `unit-tests`). Use `jest.useFakeTimers()` only when nothing is injectable: it patches the global timers of a process that runs every tui file, so restore them with `jest.useRealTimers()` in `finally` (`packages/tui/test/util/delayed-presence.test.ts:24-26`).
- **Whole app:** `createAppFixture` (`packages/tui/test/fixture/app.ts`) gives a started renderer, an in-process `Bun.serve` and `kittyKeyboard: true`. Call `await setup.ready` before the first wait. Passing `config` replaces the default `{ animations: false }`, so include `animations: false` yourself. The first use in a process pays `await import("../../src/app")`: about 1s idle, 12-25s under heavy load. Use it only when the behavior spans routes or data.
- **Server behavior:** use the existing `createFetch` and `createEventStream` fixture (`packages/tui/test/fixture/tui-client.ts:15`, `:69`) rather than spawning a server. The persistent PTY, for example, is faked as `/api/experimental/persistent-pty/...` routes plus a `Bun.serve` websocket (`packages/tui/test/session-wheel-focus.test.tsx:52`); real PTY behavior is tested in `packages/server/test/persistent-pty.test.ts`. Every fixture server listens on `port: 0` (`fixture/app.ts:32`) and is reached through `server.url`; never hard-code a port. Windows hands out ephemeral ports from 49152 up, so a fixed port in that range can already be taken by another connection.
## Keyboard and mouse
- Use `setup.mockInput`: `pressKey`, `typeText`, `pressEnter`, `pressEscape`, `pressArrow`, `pasteBracketedText`.
- Create renderers with `kittyKeyboard: true`. Without it, `mockInput` silently sends something else (OpenTUI 0.5.17):
| Input | Without kitty mode | With kitty mode |
| -------------------------------------------- | ----------------------------------------- | --------------- |
| `pressKey("u", { ctrl: true, shift: true })` | `ctrl+u` | `ctrl+shift+u` |
| `pressKey("2", { ctrl: true })` | `2` | `ctrl+2` |
| `pressEnter({ shift: true })` | `return` | `shift+return` |
| `pressEscape()` then `pressKey("a")` | `meta+a` (a lone Escape is held for 20ms) | `escape`, `a` |
- A second click on the same renderable within 500ms (renderer clock) and within 1 cell becomes a word or line selection. Do not sleep past that window: `Bun.sleep(550)` at `packages/tui/test/session-activity-anchor.test.tsx:120` costs 4.4s per file. Call `setup.renderer.clearSelection()` first, which resets the click history, or click at least 2 cells away. In a `ManualClock` renderer driven by `renderOnce()`, `clock.advance(501)` also works.
## Terminal sizes
- Always pass `width` and `height`. Otherwise the renderer uses `process.stdout.columns`: your terminal locally, 80 in CI.
- Cover layout at a narrow and a wide width, for example `test.each([100, 44])` (`packages/tui/test/app-lifecycle.test.tsx:13`). After `setup.resize(w, h)`, `await setup.renderOnce()` or `flush()` before asserting. Run any real-time sequence at one width only.
- Measure work by counting, not timing: mounted rows (`packages/tui/test/session-mount-budget.test.tsx`), rendered diff lines (`packages/tui/test/cli/tui/diff-viewer.test.tsx:2198`). `performance.now()` thresholds fail on slow Windows runners.
## Timers, cleanup and shared state
- A TUI test that takes over a second is usually waiting on a production constant; make it injectable instead of waiting it out. Two such constants: the shell output poll `setTimeout(poll, more ? 0 : 1_000)` (`packages/tui/src/component/dialog-shell-output.tsx:73`; 6 tests, 18.8s, 95% idle) and `reconnectDelay = 1_000` (`packages/client/src/solid/connection.ts:33`; about 1s per reconnect test). connection.ts already takes `idleTimeout` and `flushInterval` options, so add a sibling.
- Clean up with `await using` or `finally`: destroy the renderer and stop the fixture server. Cancel timers first, or they throw "TextBuffer is destroyed" into the next test.
- Remove the tmpdir last. Destroy the renderer and await the app task first, as `createAppFixture`'s `[Symbol.asyncDispose]` does (`fixture/app.ts:63`). Removing it under a pending write logs "Failed to persist" and flock "lock is compromised".
- `TestTuiContexts` (`packages/tui/test/fixture/tui-environment.tsx:32-35`) defaults `home` and `state` to fixed `/tmp/opencode/...` paths. When the tree mounts a provider that writes under `paths.state` (`StorageProvider` in `src/context/storage.tsx:145`, `LocalProvider`, or the prompt history, frecency and stash providers), pass `paths={{ state, home }}` built from `tmpdir()` (`test/fixture/fixture.ts:8`). Process-shared paths are what keep tui from parallel workers: `--parallel=2 --no-isolate` halved local wall time (162.9s to 80.7s). Isolated mode re-imports `src/app` in each of the 18 test files that load it, at 6x the CPU.
- Bun does not cancel a timed-out test body. It keeps running and can fail the next test or rerun as "Unhandled error between tests". Triage the first failure in a file; the ones after it may be leftovers.
- Import another package's test helpers by relative path only from a package in tui's workspace dependency closure. `@opencode/tui#test` depends on `transit` in `turbo.json`, which folds tui's whole workspace dependency closure into the test hash, so `data.test.tsx:7` reaching `../../../../core/test` is covered because tui depends on `@opencode/core`. A package outside that closure is not hashed, and a change there can replay a stale pass (see `unit-tests`, Turbo test tasks).
## Timeouts and failures
- The tui and cli test scripts run `bun test --timeout 30000`; Bun's default is 5s. In tui, that budget exists for the cold `src/app` import, not for slow waits.
- A frame-budget failure is not a timeout problem. Raising the test timeout does nothing for it, and raising `maxPasses` only hides the wrong wait.
- A per-test timeout below 30s, like the 15000 at `session-group-navigation.test.tsx:249`, must still cover the cold import when the file runs alone. New per-test timeouts name their work in a comment (see `unit-tests`, Know the budget).
- A frame-budget failure that reproduces on every run of one branch is a regression in that change, not a flake. dialog-shell-output failed "after 100 passes" in 21 of 29 tui runs on one PR stack and in 0 of about 102 elsewhere. Do not answer it with a bigger budget.
- Do not add `skipIf(process.platform === "win32")` to hide a timing flake; fix the wait. Windows timers have about 15ms resolution, so a 1ms `setTimeout` race that never shows on macOS fails there.
## Before you push
- From `packages/tui`: `bun test --timeout 30000 test/<file>`. This matches CI's budget and prints per-test milliseconds, which the package script's `--only-failures` hides.
- `bun test --timeout 30000 test/<file> --rerun-each 10`: all 10 must pass. The first iteration pays the cold import, so a timeout on iteration 1 under heavy load is that import, not your wait. Repeat while one other suite runs (for example `bun run test` in `packages/core`), not on an overloaded machine. Before its focus wait, the command-selection race failed 5 of 8 local runs under load.
- Force the race you suspect: temporarily slow the timer or reply (for example the focus `setTimeout` to 150ms). The test must still pass. Revert afterwards.
- Negative control: make the predicate impossible. The test must fail fast with a frame dump, not hang until the timeout.
- Windows-sensitive behavior to check for: timers under 16ms, renaming a file another process holds, process kill, and paths shown in frames. On pushes and pull requests the unit job runs `--affected`, so `@opencode/tui:test` is scheduled only when tui, a workspace package it depends on, or a `globalDependencies` file in `turbo.json` changed. In the `unit (windows)` log, confirm the lines prefixed `@opencode/tui:test:` are there with `cache miss, executing` and a pass, and compare its `Ran N tests across M files. [Xs]` line with a recent run that included tui. Per-file timing is only available locally.
+140
View File
@@ -0,0 +1,140 @@
---
name: unit-tests
description: Use when writing, reviewing, speeding up, or fixing a flaky `bun test` unit test in any package (core, cli, server, ai, codemode, util, client, sdk, plugin, http-recorder, merman, app src and test-browser, desktop, gui-extensions, ui, session-ui, latex, ...), except OpenTUI renderer tests (see tui-tests). Also use when a test "timed out after 5000ms" on Windows CI, fails only on Windows, when the unit job is slow, when adding cli subprocess or service tests, or when adding a package's test task to turbo.json. Covers controlling time instead of sleeping, per-test timeouts, process and git spawn cost on Windows, Windows-only failures, temp dirs and process state, leaks between files, and reading CI timeouts.
---
# Fast, Reliable Unit Tests
CI runs `bun turbo test --affected --concurrency=3` on 4-vCPU Linux and Windows runners (unit job in `.github/workflows/test.yml`), so each suite shares the CPU with two others, and Windows is the slower leg. Most slow or flaky unit tests here wait on real time, spawn too many processes, or assert wall-clock speed.
Read these first; this skill does not repeat them:
- Root `AGENTS.md`: the Testing and Style Guide sections.
- `packages/app/AGENTS.md` Tests (`packages/desktop` and `packages/gui-extensions` follow it): one owner per contract, and no production seams for tests.
- `packages/ai/AGENTS.md`: cassettes. The `effect` skill: `it.effect` and `it.live`.
Sibling skills: `tui-tests` (OpenTUI renderer tests in tui and cli), `app-e2e-tests` (Playwright in `packages/app/e2e`), `component-tests` (Storybook Playwright). This skill owns cli subprocess and service tests.
## Know the budget
- Bun's default per-test timeout is 5000ms. Check the package's `test` script:
- 30s: ai, cli, desktop, http-recorder, latex, merman, simulation, theme and tui pass `--timeout 30000`; core's `script/test.ts` passes `--timeout=30000`.
- 5s: client, httpapi-codegen and sdk pass `--timeout 5000`; app, codemode, function, gui-extensions, plugin, plugin-browser, script, server, session-ui, ui and util use the default.
- Size Windows risk by counting spawns, not by local wall time.
- npm.test.ts "installs a Git package from an npm ::path: subdirectory at a branch" takes 0.4s locally and hit 5000ms on Windows CI. Each Npm.add Git case makes about 75 git process events: 3 `clone --recurse-submodules`, which run the git-submodule shell script.
- Assume roughly 40ms per Git-for-Windows spawn (estimated from an earlier git.test run on Windows CI: ~24s for 564 spawns). Some core file groups run 5-9x slower on Windows than locally.
- Those `test.each` cases (`npm.test.ts:223`, `:251`) hit 5000ms in 13 of 45 Windows core runs; that is why core's runner now defaults to 30s (comment at `packages/core/script/test.ts:38`).
- A timeout loosens the budget only when it exceeds the package default.
- In a 5s package, work that legitimately needs longer gets a per-test timeout and a comment naming the work: `packages/server/test/fetch.test.ts:406-407` ("Real retries wait 18 \* 200 ms ...", `{ timeout: 10_000 }`). `test.each(table)(name, fn, 30_000)` takes it as the third argument. Use file-wide `setDefaultTimeout` only when every test in the file does such work.
- In core, the four file-level `setDefaultTimeout(30_000)` calls only restate the runner default, and a per-test `{ timeout: 15_000 }` (still in `tool-shell.test.ts`, `session-generate.test.ts` and others) is stricter than it. Do not add values at or below 30s there.
- Never absorb a sleep, poll or production interval with a larger timeout, `--retry`, or `skipIf(win32)`; remove the wait. Skip on Windows only when the platform lacks the capability, with a comment (`npm.test.ts:313`, symlinks).
## Control time; do not wait for it
- **Effect code.** `it.effect` from `packages/core/test/lib/effect.ts` runs on `TestClock`; `testEffect(layer)` adds your layer. server and cli import it from `../../core/test/lib/effect`; ai has `packages/ai/test/lib/effect.ts`.
- **Fork, then adjust.** Fork with `Effect.forkChild({ startImmediately: true })` (`packages/ai/test/http-timeout.test.ts:58`), or await a Deferred the code completes and then `Effect.yieldNow` (`:80-81`). Otherwise `TestClock.adjust` can run before the sleep is registered, and the join hangs until Bun's timeout.
- **Debounced streams and watchers.** `advance(condition)` and `drain` in `packages/core/test/lib/clock.ts` alternate one real 1ms macrotask with 500ms adjusts, because debounces register sleeps across fiber hops. Use `drain` (4 such steps) before a "nothing happened" assertion instead of `Bun.sleep(25)`. `advance`'s 100-step bound is right under TestClock, but a condition waiting on slow real work (a child, a large write) can run out of steps under load.
- **Real I/O between virtual steps.** `TestClock.withLive(Effect.sleep("10 millis"))`. `registered()` at `packages/cli/test/service.test.ts:740` tests the 5s registration watch with real file I/O under `it.effect`. This departs from the effect skill's "filesystem → `it.live`" on purpose: the timer is the contract.
- **Real events need `it.live`.** Under `it.effect` the clock moves only when the test adjusts it, so an Effect timeout on a path driven by real processes, sockets or file events never fires and a missing event hangs instead of failing. The AppProcess tests (`packages/core/test/process/process.test.ts:13-14`) use `it.live` because the spawner's 1-second post-exit capture deadline (`packages/util/src/cross-spawn-spawner.ts:322`) never fired under TestClock.
- **Give a racing component its own clock.** When a real-time deadline races slow setup, provide that component a `Clock` that only the test moves past the deadline. `packages/core/test/browser-idle.test.ts:24-38` wraps the live clock for `LocationActivity` (`:49-51`) and adds the TTL at `:84`, once the browser state is attached. Before, it waited out a real 2s TTL and failed on Windows CI with "RPC is unavailable: experimental.browser" when plugin activation was slow. Shortening a real TTL makes that race flakier.
- **Product Effect code must stay on the caller's clock**, which is what makes both patterns above possible. Read time through `Clock`, wait with `Effect.sleep` or `Schedule`, and run nested effects with the caller's context.
- A bare `Effect.runPromise` runs on the default real clock. McpStdio does this (`packages/core/src/mcp/stdio.ts:66`, `:82`, `:109`), so `packages/core/test/mcp.test.ts:832` waits the real 2s `CLOSE_GRACE` on every OS.
- Capture `yield* Effect.context()` and use `Effect.runPromiseWith(context)`, as `packages/cli/src/acp/agent.ts:27` does.
- **Code outside Effect** (app, desktop, ui, session-ui): `jest.useFakeTimers()` and `setSystemTime` from `bun:test`, restored in `afterEach` (`packages/desktop/src/main/extension/lifecycle.test.ts:27-32`, `packages/desktop/src/main/storage/drafts.test.ts:15-17`).
- **Advance fake timers to the next timer** (`jest.advanceTimersToNextTimer()`, `jest.runOnlyPendingTimers()`), not in small fixed steps. The 10ms-step `advance` at `lifecycle.test.ts:37` turns the 1,000-seed fuzz test (`:607`) into about 430k event-loop turns: 2.8-3.8s on Windows, 0.56s on Linux.
## Real-time waits: deadlines, not attempt counts
- Bound a real-time poll in milliseconds, generously, and return as soon as it holds: `until` at `packages/cli/test/service-lifecycle.test.ts:267` (30s), `eventually` at `packages/core/test/integration.test.ts:37` (10s).
- Attempt-count loops hide a small budget that shrinks under load. Do not copy `waitUntil` at `packages/core/test/config/reload.test.ts:371` (100 × 10ms; the skills test at `:57` timed out after 1.2s on a Linux dev push) or `packages/core/test/plugin/provider-azure.test.ts:219` (3000 × 1ms).
- Poll for every state you assert afterwards: the reload poll at `reload.test.ts:301-317` includes the skills that `:319-320` assert.
- Let the test, not a timer, release a child's next step. The login command in `integration.test.ts:230-254` prints its secret only after the test, having seen the pending status, writes a release file; it used to print after a fixed `Bun.sleep(50)`.
- Small timer gaps race too: `packages/core/test/process/process.test.ts:59-67` ordered stdout and stderr with 10ms and 20ms child timers and failed on Windows CI (`out 2` read before `err 1`); it now uses 100ms and 200ms.
## When the wait happens in another process
TestClock cannot reach a spawned server or worker. These production waits already cost real time:
- cli (about 60s of its roughly 2-minute suite): the 15s bind-retry deadline (`Date.now() + 15_000` in `packages/cli/src/server-process.ts`), the 5s registration watch (`packages/cli/src/services/service-registration.ts:63`), and fixed sleeps (`Bun.sleep(8_000)` at `service.test.ts:620`, `Bun.sleep(5_500)` at `service-lifecycle.test.ts:237`).
- core (about 8s locally): McpStdio `CLOSE_GRACE` 2s + `FORCE_KILL_AFTER` 2s (`stdio.ts:10-13`), Shell `forceKillAfter` 3s (`packages/core/src/shell.ts:306`, `:401`), and the 1s capture deadline in cross-spawn-spawner.
To avoid adding more:
- Test the timing logic in-process under TestClock (the `registered()` tests at `service.test.ts:270-299`). Keep real processes for exit codes, signals, stdio and port binding, with one test per escalation path. Do not add tests that wait out these constants.
- Shorten a production duration through an option, not by waiting it out. Precedent: real product options (`Flock.Options` in `packages/util/src/flock.ts:41-47`, `idleTimeout`/`flushInterval` in `packages/client/src/solid/connection.ts`) and client's hidden seam `withEnsureTiming` (`packages/client/src/service-timing.ts:31`, used by `packages/client/test/fixture/service-timing.ts`).
- For a spawned process the value must reach the child, as `timeoutMs` does through the worker payload at `packages/core/test/util/flock.test.ts:50-58`.
- server-process.ts has no override today, so its 15s tests cannot be shortened from the test side.
- app, desktop and gui-extensions forbid such seams (their AGENTS.md Tests sections); core, cli, util, client and tui have no such ban.
- Retry timing belongs in core under TestClock. `packages/server/test/fetch.test.ts:363-408` spends about 5.8s of server's 16s in real 200ms bind retries (`packages/core/src/plugin/provider/openai.ts:21`). The fixed ports 1455/1457 are the contract there; new server tests cover routing and error mapping only. Elsewhere bind port 0 (`packages/app/src/runtime/server/global-sync/bootstrap.test.ts:81`).
## Do not assert wall-clock speed
- `expect(performance.now() - startedAt).toBeLessThan(500)` failed on Windows at 510ms (`packages/ui/src/context/marked-parser.test.ts:84`); `packages/core/test/shell-scan/adversarial.test.ts:77` has the same shape. merman's layout audit no longer asserts its elapsed time.
- Count operations, or pick an input where a regression is catastrophic and assert only the result, so it trips the test timeout: `packages/codemode/test/openapi.test.ts:638` (depth 30 is 2^30 work without memoization). A ratio of two timings still fails on a load spike; if you keep one, use a generous factor and say it is timing-sensitive.
## Spawn less
- **Process start-up.** Each `bun run src/index.ts` CLI boot costs about 1.3s of CPU (the entry loads TUI `.tsx` through the preload in `packages/cli/bunfig.toml`). Test parsing and validation in-process; keep one or two real subprocess tests per file. Each cold bun child that imports the MCP SDK cost 1-3s; the MCP lifecycle tests, which spawned several, hit 5000ms on Windows until they moved to the in-process `resourceServer()` (`packages/core/test/mcp.test.ts:90`).
- **Spawned CLIs** use `isolatedEnv(root)` from `packages/cli/test/fixture/environment.ts`: isolated HOME, XDG and database, shared transpiler cache. `packages/cli/test/upgrade.test.ts:98` and `debug-paths.test.ts:24` set a private `XDG_CACHE_HOME` without `BUN_RUNTIME_TRANSPILER_CACHE_PATH`, so every spawn transpiles again; do not copy them.
- **Git.** Before the shared fixture below, a PATH shim counted about 2,850 git spawns per core run, and git.test took about 24s on Windows CI and 7s locally.
- Create repos with `packages/core/test/fixture/git.ts`. `initGit(directory, ...initArgs)` (`:24-27`) runs one `git init` and appends the fsmonitor, gpgsign and identity settings to `.git/config` in one write; `initRepo` (`:64-67`) adds an empty root commit (2 spawns; with one `git config` per key it took 6), and `gitRemote` builds on `initGit`.
- Do not write per-file `git init` plus `git config` copies. server still has two (`packages/server/test/vcs.test.ts:215-218`, `packages/server/test/worktree.test.ts:131-133`); server already imports core fixtures such as `tmpdir`.
- Use `-c user.name=… -c user.email=…` only for commits the test makes itself (`npm.test.ts:53`). Repos where production code commits later (snapshot, worktree, vcs) need repo-local identity.
- Keep identity repo-local. The unit job's "Configure git identity" step sets a global identity, but core's `script/test.ts` and cli's `isolatedEnv` replace HOME, so it is invisible there and visible to server and tui. That HOME isolation keeps core's missing-identity coverage: do not depend on CI's identity, and do not add a global gitconfig in core.
- **Independent children** start together (`Promise.all`, `packages/ai/test/exports.test.ts:63`). `packages/app/test-browser/playwright-config.test.ts` runs sequential `Bun.spawnSync` (3.5s on Windows, 1.4s on Linux); `packages/desktop/scripts/windows-installer.test.ts` makes 6 sequential cold `pwsh` starts for ACL reads, which one `pwsh` call could batch.
- **Size contention and thresholds to the claim.** `flock.test.ts:46` starts 16 bun workers; use only as many as still exceed the 4 CI vCPUs. A hard-coded threshold forces huge fixtures: `packages/core/test/git.test.ts:354` writes 2,100 files to cross `looseObjectLimit = 2048` (`packages/core/src/git.ts:50`), 2.7s locally and slower on Windows. Make the threshold an option with the production default, cross a small value in the test, and assert the default once.
## Windows-only failures
- Renaming onto a file another process holds open fails. cli `service.test.ts:462` failed on a dev push with `FileSystem.rename (...service-local.json.<uuid>.tmp)` from the temp-then-rename at `service-registration.ts:39`. Close handles the test holds; when two product processes race, fix the product, not the test.
- SIGTERM cannot be caught: `process.kill` ends the target at once with exit code 1, so a shutdown handler runs only on POSIX. Assert per platform with `catchesSigterm`, `stopSignal` and `stoppedExitCode` (`packages/client/test/fixture/service-fixture.ts:7-11`).
- Do not prove a stopped server released its port by binding it again. Windows hands out ephemeral ports from 49152 up, and any process, including suites running alongside, may take the port for an outgoing connection; a re-bind failed there with "Failed to start server. Is port 65518 in use?". Assert that nothing accepts connections (`expectNotListening`, `service-fixture.ts:108-117`).
- `os.tmpdir()` reads `TEMP` on Windows and `TMPDIR` on POSIX; set both for a child (`packages/util/test/global.test.ts:22-24`). Compare canonical paths with `fs.realpathSync.native`, which expands 8.3 short names such as `RUNNER~1` (`:84-86`).
- Reset a test socket once the request has arrived (`data`), not on `open`. Resetting on accept races the client's write, and on Windows the lost race is a write or connect error instead of a reset: `packages/util/src/retry.test.ts:25-30` recorded 2 of its 3 expected failures there. `packages/app/src/runtime/server/global-sync/bootstrap.test.ts:83-88` still resets on `open` and failed 3 of about 19 loaded local runs with `write EPIPE`, which `transientMessages` in `packages/util/src/retry.ts` does not list.
## Temp dirs, files, process state
- core's `script/test.ts` points HOME, XDG\_\* and TMPDIR (plus USERPROFILE, TMP and TEMP on Windows) at a fresh directory per run. Per-test dirs: `tmpdir()` (`await using`), `tmpdirScoped` or `withTempDir` from `packages/core/test/fixture/tmpdir.ts`.
- Close watchers, databases and children before the dir is removed. On Windows an open handle fails removal with `EBUSY`; `remove` in `tmpdir.ts` then forces a GC and retries every 100ms, up to 30 times.
- Restore process state: env with `withEnv` (`packages/core/test/fixture/env.ts`) in Effect tests and in `afterEach` elsewhere, real timers, and `setSystemTime()`.
- `CI=true` in the unit job, and Turbo passes it through. Code that reads `CI` takes another path there, so a test of the non-CI path unsets it itself: `packages/http-recorder/test/websocket.test.ts:132` wraps its record-mode case in `withEnvironment("CI", undefined, ...)`, because in CI that case replayed and waited forever for "open".
## Keep files independent without `--isolate`
- Except app, each suite runs all its files in one process with one module registry (core: 262 files), so state leaks between files.
- Do not use `mock.module`; it leaks across files.
- It is why app's scripts pass `--isolate`: app unit went from 2.4s to 9.9s locally, and unit plus browser from 5.3s to 12-20s on CI.
- `packages/cli/test/mini-config.test.ts:25` and `packages/core/test/plugin/provider-gitlab.test.ts:74` already leak in non-isolated suites; do not copy them.
- In Effect packages, provide the dependency through a test layer. In app, desktop and gui-extensions, drive the real module and entry point, and inject only through a parameter the product already has.
- Never add `--isolate` or bare `--parallel` (which implies `--isolate`) to a test script: ai went from 7.6s to 26.3s with `--parallel=2`.
- `--parallel=N --no-isolate` keeps the shared registry and measured 0.47-0.68x serial wall time at about the same CPU in core. It also exposed load-sensitive tests (browser-idle and integration's `eventually`, both since fixed).
- Adopting it is a runner decision for maintainers once CI passes on both OSes, not a fix for one slow test.
- Runtime imports run in every file that loads the module. `packages/desktop/src/main/storage/store.ts:3` imports `electron` only for `getStore`, so `src/main/extension/storage.test.ts` and `src/main/storage/store.test.ts` download the ~100MB Electron binary on a fresh install. On a first local run, storage.test.ts took 5.3s of file time for 0.1s of test bodies. Use `import type` for types (`packages/desktop/src/main/ipc-transport.test.ts:4`).
## Turbo test tasks
- `bun turbo test` runs only packages with an `@opencode/<pkg>#test` entry in `turbo.json`; all 24 packages with a `test` script have one. A new suite copies its neighbors (`"dependsOn": ["transit"], "outputs": []`); check with `bun turbo test --dry=json`. Suites without an entry rotted: client, util, sdk, http-recorder and merman needed fixes before CI could run them.
- `transit` runs nothing and depends on `^transit`, so a test hash covers the package's whole workspace closure. The global dependencies add root `package.json` (whose `packageManager` pins Bun), `bunfig.toml`, `patches/**`, `.npmrc`, `.github/actions/setup-bun/**` and `test.yml`.
- Tests do not wait for builds: workspace packages export their sources (`"./*": "./src/*.ts"` in `packages/core/package.json`), so a test must not read a dependency's `dist`. The build job's "Build workspace libraries" step checks the library builds on Linux.
- A package that compiles `src` with tsc keeps colocated tests out of the build (`"exclude": ["src/**/*.test.ts"]` in `packages/util/tsconfig.build.json`). Otherwise `bun test` also runs the compiled copies, as Windows CI did with `packages/util/dist/retry.test.js`.
## When CI flakes
- Most suites print only failures (`--only-failures`): `(fail) <name> [5000ms]` and `^ this test timed out after 5000ms`.
- When `killed 1 dangling process` and `# Unhandled error between tests` follow (for example `NpmInstallFailedError` with `SIGTERM` from a `git clone`), the test was still doing real work, not hanging.
- The unhandled error is the abandoned Effect failing after Bun killed its child, not a second bug. Count the work (see Before you push) before choosing a fix.
- Check whether the test also fails on dev pushes: `gh run list --workflow test.yml --branch dev --event push --status failure`. In one census before core's 30s default, 4 of 25 dev pushes went red, all on Windows unit tests (3 of them Npm.add 5s timeouts).
- Read whole job logs with `gh run view --log --job <id>`; `--log-failed` omits green jobs, whose `Ran N tests across M files. [Xs]` lines you need for comparison.
- Assert a child's stderr before its exit codes. `packages/core/test/util/effect-flock.test.ts:327-328` checks codes first, so its one CI failure never showed the cause.
## Before you push
Run from the package directory with the flags from its `test` script (app needs `--conditions` and `--preload`). In core use `bun run script/test.ts <file> …`, which forwards flags, isolates HOME and applies the 30s timeout.
- [ ] `bun test <file> --rerun-each 10` passes every time. Add `--randomize` for order dependence; reproduce a failure with `--seed=<n>`.
- [ ] Per-test durations: `--reporter=junit --reporter-outfile=<abs path>` (works through `script/test.ts`). Any test over about 1s does real work you can name.
- [ ] Windows: count spawns. `GIT_TRACE=<abs file>` logs roughly one `trace: built-in:` or `trace: exec:` line per git process; a logging `git` shim first on PATH also works. At about 40ms per spawn, compare with the package timeout.
- [ ] Rerun the file while the full package suite runs in another terminal; CI runs three suites on four vCPUs.
- [ ] Grep the diff for `Bun.sleep(`, `setTimeout(`, `Date.now() +`, `performance.now()`, `Effect.runPromise(` in product code, `mock.module`, `git config`, and literal port numbers.
- [ ] After CI: with `--only-failures`, the `unit (windows)` log hides passing durations. Compare your package's `Ran N tests across M files. [Xs]` line with a recent dev run's.
- [ ] New package suite: `bun turbo test --dry=json` lists `@opencode/<pkg>#test`.
+1
View File
@@ -166,6 +166,7 @@ const table = sqliteTable("session", {
- Avoid mocks as much as possible, you shouldn't be using globalThis.\* at all unless it's the only option.
- Test actual implementation, do not duplicate logic into tests
- Tests cannot run from repo root (guard: `do-not-run-tests-from-root`); run from package directories such as `packages/core`.
- Before writing or changing tests, read the matching skill in `.opencode/skills`: `unit-tests` (bun test), `tui-tests` (OpenTUI renderer tests), `app-e2e-tests` (Playwright in `packages/app/e2e`) or `component-tests` (Storybook Playwright). They cover what has made tests slow or flaky in CI.
## Checks
+23
View File
@@ -290,6 +290,7 @@
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-genui": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/util": "workspace:*",
"@parcel/watcher": "2.5.1",
@@ -561,6 +562,22 @@
"typescript": "catalog:",
},
},
"packages/plugin-genui": {
"name": "@opencode/plugin-genui",
"version": "2.0.27",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
"effect": "catalog:",
},
"devDependencies": {
"@tsconfig/bun": "catalog:",
"@types/bun": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
},
},
"packages/plugins/figma": {
"name": "@opencode/plugin-figma",
"version": "2.0.30",
@@ -680,6 +697,7 @@
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
"@opencode/plugin-genui": "workspace:*",
"@opencode/ui": "workspace:*",
"@opencode/util": "workspace:*",
"@pierre/diffs": "catalog:",
@@ -1037,6 +1055,7 @@
"patchedDependencies": {
"@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch",
"@tanstack/virtual-core@3.17.8": "patches/@tanstack%2Fvirtual-core@3.17.8.patch",
"electron-updater@6.8.9": "patches/electron-updater@6.8.9.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
@@ -1045,6 +1064,8 @@
"solid-js@1.9.15": "patches/solid-js@1.9.15.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch",
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"app-builder-lib@26.15.7": "patches/app-builder-lib@26.15.7.patch",
"@corvu/drawer@0.2.4": "patches/@corvu%2Fdrawer@0.2.4.patch",
"@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch",
"@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch",
},
@@ -2052,6 +2073,8 @@
"@opencode/plugin-figma": ["@opencode/plugin-figma@workspace:packages/plugins/figma"],
"@opencode/plugin-genui": ["@opencode/plugin-genui@workspace:packages/plugin-genui"],
"@opencode/posts": ["@opencode/posts@workspace:packages/posts"],
"@opencode/protocol": ["@opencode/protocol@workspace:packages/protocol"],
+3 -1
View File
@@ -1,5 +1,7 @@
export function createWebApp(domain: string) {
return new sst.cloudflare.StaticSite("WebApp", {
// Workers Static Assets uploads files as-is, up to 25 MiB each. StaticSite stored them base64-encoded in KV, which
// pushed the 20 MB Word preview engine past KV's 25 MiB value limit.
return new sst.cloudflare.StaticSiteV2("WebApp", {
domain,
path: "packages/app",
environment:
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-wmSDJ8NZOJyfSaBD8hnAAtbYrp3qnMS4eJzgFdyrhFc=",
"aarch64-linux": "sha256-6WE4BHW11/gAQ75ktPbWU2Aw7uqivueMrElDAqu7QdE=",
"aarch64-darwin": "sha256-6aAic4o1Zx6QiMk8IFU6lV7typClc3yXoWH7Qiq+hFs="
"x86_64-linux": "sha256-foIqeQoc/G6w/9bd7OqC6mIxyQlCgbkPQWl3aHtb1l8=",
"aarch64-linux": "sha256-wiiiSjLyLhn0OFnh1Ug2CdAfwtpkncaUPLUI0TIucdA=",
"aarch64-darwin": "sha256-zQ26uuI0p+2tf7KYzcKLnPF+ERTumGz4uqezO9pObNg="
}
}
+4 -1
View File
@@ -184,6 +184,9 @@
"@ff-labs/fff-bun@0.10.5": "patches/@ff-labs%2Ffff-bun@0.10.5.patch",
"ghostty-web@github:anomalyco/ghostty-web#83c0a07": "patches/ghostty-web@0.3.0.patch",
"vite@8.2.2": "patches/vite@8.2.2.patch",
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch"
"@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch",
"@corvu/drawer@0.2.4": "patches/@corvu%2Fdrawer@0.2.4.patch",
"app-builder-lib@26.15.7": "patches/app-builder-lib@26.15.7.patch",
"electron-updater@6.8.9": "patches/electron-updater@6.8.9.patch"
}
}
+11 -12
View File
@@ -39,7 +39,7 @@ bun run test:e2e:local
bun run test:e2e:local -- --grep "settings"
```
CI builds the app once and runs the same suite against Vite preview, serving production assets from `dist`. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
CI builds the app once and runs the same suite against a static Bun server (`e2e/utils/serve.ts`) that serves the production assets in `dist` with Vite preview's SPA fallback rules. Managed built runs never reuse an existing server, so a running dev server cannot silently replace the production build. To run this mode locally:
```bash
bun run test:e2e:built
@@ -64,24 +64,23 @@ Compiled CLI startup and service lifecycle coverage runs separately in CI via `p
Environment options:
- `PLAYWRIGHT_BUILD=1` (build and preview locally; always enabled when `CI` is set)
- `PLAYWRIGHT_BUILD=1` (build and serve `dist` locally; always enabled when `CI` is set)
- `PLAYWRIGHT_SERVER_HOST` / `PLAYWRIGHT_SERVER_PORT` (dev fixture API address, default: `127.0.0.1:4096`; built runs use the app's origin, matching production)
- `PLAYWRIGHT_PORT` (managed dev or preview server port, default: `3000`)
- `PLAYWRIGHT_PORT` (managed dev or static server port, default: `3000`)
- `PLAYWRIGHT_BASE_URL` (use an externally managed app instead of starting a server; otherwise defaults to `http://127.0.0.1:<PLAYWRIGHT_PORT>`)
## Deployment
The `deploy` GitHub Actions workflow uses SST to deploy the web app from these branches in `anomalyco/opencode`:
The `deploy` GitHub Actions workflow uses SST to deploy the web app to `beta.opencode.ai` on every push to the
`beta` branch in `anomalyco/opencode`. The `dev` and `production` stages do not deploy the web app.
| Branch | Site |
| ------------ | --------------------- |
| `dev` | `app.dev.opencode.ai` |
| `production` | `app.opencode.ai` |
| `beta` | `beta.opencode.ai` |
`beta` is the team's dogfooding branch: `dev` merged in by hand, plus commits pushed directly to it. Do not rebase or
force-push it. Each push also runs the `publish` workflow, which ships the same commit as the OpenCode Beta desktop app
(see `packages/desktop/README.md`).
Changes merged into `v2` reach the beta site when they are promoted to `beta`. The beta SST stage deploys
only the web app, using the same `WebApp` StaticSite definition as production. It sets the build channel
and Sentry environment to `beta` without deploying the API, console, database, or billing infrastructure.
The beta SST stage deploys only the web app, using the `WebApp` StaticSiteV2 definition in `infra/webapp.ts`. It sets
the build channel and Sentry environment to `beta` without deploying the API, console, database, or billing
infrastructure.
`VITE_OPENCODE_SERVER_MODE` controls which server the web build provides at startup:
@@ -22,6 +22,8 @@ for (const direction of ["ltr", "rtl"]) {
await expect(component.locator('[data-action="composer-alternate-delivery"]')).toHaveCount(
alternate === "none" ? 0 : 1,
)
// A late Inter swap resizes the delivery keybind and shifts the toolbar; measure after fonts settle.
await page.evaluate(() => document.fonts.ready.then(() => undefined))
for (const width of [1024, 360]) {
await page.setViewportSize({ width, height: 720 })
@@ -110,6 +112,7 @@ for (const direction of ["ltr", "rtl"]) {
await add.click()
await expect(page.getByRole("menuitem", { name: "Images and files" })).toBeVisible()
await page.keyboard.press("Escape")
await expect(add).toBeFocused()
expect(await add.boundingBox()).toEqual(fixedAdd)
}
@@ -368,3 +371,59 @@ story("keeps default thinking accessible by keyboard without composer hover", as
await expect(control).not.toBeFocused()
await expect(control).toHaveCSS("opacity", "0")
})
story.describe("mobile variant drawer", () => {
story.use({ viewport: { width: 360, height: 720 } })
// Guards patches/@corvu%2Fdrawer@0.2.4.patch. The drawer starts its opening transition two frames after it opens.
// Dismissing it before then used to slide it open anyway, and dismissing it in that frame left it closing off screen
// for good, holding focus and disabling pointer input on the page. Playwright input cannot target those frames, so
// the page dismisses it.
for (const moment of ["before", "as"] as const) {
story(`closes and restores focus when dismissed ${moment} it starts opening`, async ({ mount, page }) => {
const component = await mount("opencode-composer-flow--model-and-variant")
const variant = component.getByRole("button", { name: "Choose model variant" })
const drawer = page.getByRole("dialog", { name: "Choose model variant", exact: true })
const high = drawer.getByRole("button", { name: "high", exact: true })
// Load the lazy drawer first so the dismissal below races only the drawer's own transition.
await variant.click()
await expect(high).toBeInViewport()
const content = await drawer.elementHandle()
if (!content) throw new Error("Missing variant drawer")
await page.keyboard.press("Escape")
await expect(drawer).toBeHidden()
await expect(variant).toBeFocused()
await variant.evaluate(
(trigger: HTMLElement, race) =>
new Promise<void>((resolve) => {
const dismiss = () => {
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }))
resolve()
}
// Mutation records arrive before the browser renders the opening transition's first frame.
const observer = new MutationObserver(() => {
if (!race.content.hasAttribute("data-opening")) return
observer.disconnect()
dismiss()
})
if (race.moment === "as") observer.observe(race.content, { attributeFilter: ["data-opening"] })
trigger.click()
if (race.moment === "before") dismiss()
}),
{ content, moment },
)
await expect(drawer).toBeHidden()
await expect(variant).toBeFocused()
await expect(page.locator("body")).not.toHaveCSS("pointer-events", "none")
await variant.click()
await expect(high).toBeInViewport()
})
}
})
@@ -8,7 +8,7 @@ story("maps grouped and collapsed switches to the timeline settings", async ({ m
const collapsed = shell.getByRole("switch", { name: "Shell collapsed", exact: true })
const value = component.locator('[data-slot="timeline-detail-fixture-value"]')
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.getByRole("switch")).toHaveCount(10)
await expect(component.getByText("Activity", { exact: true })).toHaveCount(0)
await expect(grouped).toBeChecked()
await expect(collapsed).toBeChecked()
@@ -138,7 +138,8 @@ story("keeps visibility and switches in sync with the preset slider", async ({ m
await slider.focus()
await slider.press("Home")
await expect(slider).toHaveAttribute("aria-valuetext", "Messages only")
await expect(component.getByRole("switch")).toHaveCount(0)
// Messages only hides every activity but keeps commentary, which is text.
await expect(component.getByRole("switch")).toHaveAccessibleName("Commentary grouped")
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(9)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="false"]')).toHaveCount(6)
await component.getByRole("button", { name: "Shell visibility" }).click()
@@ -147,9 +148,9 @@ story("keeps visibility and switches in sync with the preset slider", async ({ m
await slider.focus()
await slider.press("End")
await expect(slider).toHaveAttribute("aria-valuetext", "Everything")
await expect(component.getByRole("switch")).toHaveCount(9)
await expect(component.getByRole("switch")).toHaveCount(10)
await expect(component.locator('[data-slot="timeline-detail-unavailable"]')).toHaveCount(0)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(6)
await expect(component.locator('[data-action="timeline-detail-visibility"][aria-pressed="true"]')).toHaveCount(7)
await expect(component.getByRole("switch", { checked: true })).toHaveCount(0)
})
@@ -48,6 +48,15 @@ test("lists recent sessions before a query is entered", async ({ page }) => {
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
})
test("reopens from its shortcut as soon as it closes", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
await input.press("Escape")
await expect(dialog).toHaveCount(0)
// A closed dialog stops blocking shortcuts when it leaves the screen, not when it is disposed afterwards.
await page.keyboard.press("ControlOrMeta+Shift+P")
await expect(dialog.getByRole("textbox")).toBeFocused()
})
test("appends search results without resetting the selected command", async ({ page }) => {
const { dialog, input } = await openCommandPalette(page)
const files = Promise.withResolvers<void>()
+28 -4
View File
@@ -537,7 +537,9 @@ test("a restored file tab keeps the file tree on Files Changed", async ({ page }
})
test("rereads a file each time an artifact link opens it", async ({ page }) => {
const file = { content: "first draft" }
// Enough lines to scroll the code view, ending in the draft's name.
const draft = (name: string) => [...Array.from({ length: 200 }, (_, index) => `item ${index + 1}`), name].join("\n")
const file = { content: draft("first draft") }
const messages = [
{ id: "msg_prompt", type: "user", text: "Write the shopping list", time: { created: 1 } },
@@ -565,13 +567,29 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
await expect(tab).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("first draft", { exact: true })).toBeVisible()
file.content = "second draft"
// Rereading the open file updates it in place: the code view keeps its node and never jumps back to the top.
const code = panel.locator(".scroll-view__viewport", { has: page.locator('[data-component="file"]') })
await code.hover()
await page.mouse.wheel(0, 100_000)
await expect
.poll(() => code.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
const scrolled = await code.evaluate((element) => element.scrollTop)
expect(scrolled).toBeGreaterThan(0)
await code.evaluate((element) => void (element.dataset.e2eProbe = "original"))
file.content = draft("second draft")
await link.click()
await expect(panel.getByText("second draft", { exact: true })).toBeVisible()
expect(await code.evaluate((element) => element.dataset.e2eProbe)).toBe("original")
expect(await code.evaluate((element) => element.scrollTop)).toBe(scrolled)
file.content = draft("third draft")
await tab.locator("..").getByRole("button", { name: "Close tab" }).click()
await expect(tab).toHaveCount(0)
await link.click()
await expect(tab).toHaveAttribute("aria-selected", "true")
await expect(panel.getByText("second draft", { exact: true })).toBeVisible()
await expect(panel.getByText("first draft", { exact: true })).toHaveCount(0)
await expect(panel.getByText("third draft", { exact: true })).toBeVisible()
await expect(panel.getByText("second draft", { exact: true })).toHaveCount(0)
// A restored file tab whose file was deleted shows its inline not-found state without a toast; clicking a broken link
// when no tab is open for it shows the toast.
@@ -890,6 +908,12 @@ test("video files over 25 MB play in the viewer, expand into the theatre overlay
await page.mouse.click(8, 8)
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
// Chromium's controls also answer a double click with native fullscreen, which covers the backdrop. Opting out with
// controlslist="nofullscreen" would end their click-to-play as well.
await video.click()
await expect.poll(() => video.evaluate((element: HTMLVideoElement) => element.currentTime)).toBeGreaterThan(0)
expect(await page.evaluate(() => document.fullscreenElement)).toBeNull()
})
test("restores review state and the side-panel tab per session", async ({ page }) => {
@@ -18,6 +18,11 @@ for (const custom of [false, true]) {
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(trigger).toBeEnabled()
// The trigger scrolls with the timeline under its sticky header, and Kobalte closes a tooltip whenever its
// trigger scrolls. A cold timeline keeps scrolling to its bottom until it reveals its rows, so hover after that.
await expect(
page.locator(`[data-timeline-row="UserMessage"][data-message-id="${fixture.expected.sourceMessageIDs.at(-1)}"]`),
).toBeVisible()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
await expect(tooltip).toContainText("Summary")
+52 -10
View File
@@ -109,7 +109,7 @@ test("terminal stacks under review by default and spans the bottom when configur
),
]
await openSession(page, { name: "SideStack", pty: {}, vcsDiff: diffs })
const { session } = await openSession(page, { name: "SideStack", pty: {}, vcsDiff: diffs })
const toggle = page.getByRole("button", { name: "Toggle review" })
const reviewPanel = page.locator("#review-panel")
const terminalPanel = page.locator("#terminal-panel")
@@ -163,13 +163,30 @@ test("terminal stacks under review by default and spans the bottom when configur
expect(Math.abs(closing.terminalBottom - stacked.terminalBottom)).toBeLessThanOrEqual(1)
expect(closing.anchor).toBeLessThanOrEqual(8)
expect(closing.terminalGap).toBeLessThanOrEqual(1)
expect(closing.reviewOverlap).toBeLessThanOrEqual(1)
await toggle.click()
await expect(reviewPanel).toBeVisible()
await expectMarked(review)
await expectTreeSentinel(page)
// Hiding the terminal mid-motion: both surfaces fill their regions and the clipped terminal keeps its size.
// Panels restored by a reload skip their entrance animation, and must still move together when the terminal hides.
await expect
.poll(() =>
page.evaluate(
(key) => JSON.parse(localStorage.getItem("opencode.window.browser.dat:tabs.panes") ?? "{}")[key],
tabKey(session.id),
),
)
.toMatchObject({ review: true, terminal: true })
await page.reload()
await expect(reviewPanel).toBeVisible()
await expect(terminal).toBeVisible()
await expectTreeSentinel(page)
await mark(terminal)
// Hiding the terminal mid-motion: both surfaces fill their regions, the review never runs under the gap, and the
// clipped terminal keeps its size.
const open = await stackGeometry(page)
await holdTransitions(page)
await page.keyboard.press("Control+Backquote")
@@ -180,6 +197,7 @@ test("terminal stacks under review by default and spans the bottom when configur
expect(hiding.terminalRegion).toBeLessThan(open.terminalRegion - 1)
expect(hiding.terminalRegion).toBeGreaterThan(1)
expect(hiding.reviewGap).toBeLessThanOrEqual(1)
expect(hiding.reviewOverlap).toBeLessThanOrEqual(1)
expect(hiding.terminalGap).toBeLessThanOrEqual(1)
expect(Math.abs(hiding.content.width - open.content.width)).toBeLessThanOrEqual(1)
expect(Math.abs(hiding.content.height - open.content.height)).toBeLessThanOrEqual(1)
@@ -293,6 +311,23 @@ for (const direction of ["ltr", "rtl"] as const) {
return actions.y + actions.height / 2 - (button.y + button.height / 2)
})
.toBe(0)
// Toggling the terminal under the review leaves the review header and the toggle in place.
const header = async () => {
const box = await page.locator('[data-slot="session-side-panel-actions"]').boundingBox()
return box && { y: box.y, height: box.height }
}
const stacked = await header()
for (const visible of [false, true]) {
await page.keyboard.press("Control+Backquote")
await expect(terminal).toBeVisible({ visible })
await expect.poll(header).toEqual(stacked)
await expect.poll(() => toggle.boundingBox()).toEqual(position)
}
await toggle.press("Enter")
await expect(toggle).toHaveAttribute("aria-expanded", "false")
await expect(toggle).toBeFocused()
@@ -321,6 +356,7 @@ async function expectTreeSentinel(page: Page) {
// Pauses each CSS transition as it starts, so a real toggle can be measured mid-motion.
function holdTransitions(page: Page) {
return page.evaluate(() => {
// SAFETY: only these two helpers write this optional global, in the declared shape.
const host = window as Window & { e2eHold?: { active: boolean } }
if (!host.e2eHold) {
@@ -341,7 +377,9 @@ function holdTransitions(page: Page) {
function releaseTransitions(page: Page) {
return page.evaluate(() => {
;(window as Window & { e2eHold?: { active: boolean } }).e2eHold!.active = false
// SAFETY: `holdTransitions` installed this global, in this shape, before any release.
const host = window as Window & { e2eHold?: { active: boolean } }
host.e2eHold!.active = false
document
.getAnimations()
.filter((item) => item instanceof CSSTransition && item.playState === "paused")
@@ -359,7 +397,7 @@ async function stackGeometry(page: Page, progress?: number) {
(item) =>
item instanceof CSSTransition && item.playState === "paused" && item.transitionProperty === "height",
)
.map((item) => ((item.effect as KeyframeEffect).target as Element).getAttribute("data-slot")),
.map((item) => (item.effect instanceof KeyframeEffect ? item.effect.target?.getAttribute("data-slot") : null)),
)
if (progress !== undefined) {
@@ -371,9 +409,10 @@ async function stackGeometry(page: Page, progress?: number) {
.getAnimations()
.filter((item): item is CSSTransition => item instanceof CSSTransition && item.playState === "paused")
const region = transitions.find(
(item) => ((item.effect as KeyframeEffect).target as Element).getAttribute("data-slot") === "session-side-region",
)
const slot = (item: CSSTransition) =>
item.effect instanceof KeyframeEffect ? item.effect.target?.getAttribute("data-slot") : null
const region = transitions.find((item) => slot(item) === "session-side-region")
if (progress !== undefined) {
const time = Number(region!.effect!.getTiming().duration) * progress
@@ -382,17 +421,20 @@ async function stackGeometry(page: Page, progress?: number) {
const box = (selector: string) => document.querySelector(selector)?.getBoundingClientRect()
const reviewRegion = box('[data-slot="session-side-region"]')!
const gap = box('[data-slot="session-side-panel-gap"]')!
const terminalRegion = box('[data-slot="session-side-terminal-region"]')!
const review = box("#review-panel")
const terminal = box("#terminal-panel")!
const content = box('[data-slot="terminal-panel-content"]')!
return {
moving: transitions.map((item) => ((item.effect as KeyframeEffect).target as Element).getAttribute("data-slot")),
moving: transitions.map(slot),
terminalRegion: terminalRegion.height,
terminalBottom: terminal.bottom,
anchor: Math.abs(terminal.top - content.top),
reviewGap: review ? Math.abs(reviewRegion.height - review.height) : 0,
// The gap paints over the review region, so any overlap hides the review's bottom corners.
reviewOverlap: reviewRegion.bottom - gap.top,
terminalGap: Math.abs(terminalRegion.height - terminal.height),
content: { width: content.width, height: content.height },
}
@@ -413,8 +455,8 @@ async function expectTerminalControlsAligned(terminal: Locator, toggle: Locator)
.poll(async () => {
const centers = await Promise.all(
[terminal.getByRole("button", { name: "New terminal", exact: true }), toggle].map((button) =>
button.locator("svg").evaluate((element) => {
const svg = element as SVGSVGElement
button.locator("svg").evaluate((svg) => {
if (!(svg instanceof SVGGraphicsElement)) return Number.NaN
const path = svg.getBBox()
return new DOMPoint(path.x + path.width / 2, path.y + path.height / 2).matrixTransform(svg.getScreenCTM()!)
@@ -57,9 +57,13 @@ test("keeps the parent title anchored when opening a subagent", async ({ page })
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
await expectSessionTitle(page, parentTitle)
// The title first renders in the loading header, which the timeline replaces once messages arrive.
// Measuring before then can read the detached heading, whose empty bounds put the start at 0.
const agents = page.getByRole("button", { name: "Used 1 Agent", exact: true })
await expect(agents).toBeVisible()
const start = await titleInlineStart(page.locator("[data-session-title]").getByRole("heading", { name: parentTitle }))
await page.getByRole("button", { name: "Used 1 Agent", exact: true }).click()
await agents.click()
await page.locator(`a[href="${sessionHref(childID)}"]`).click()
await expectSessionTitle(page, taskDescription)
+6 -2
View File
@@ -437,8 +437,12 @@ test("tab preview opens cold, switches immediately between tabs, and closes outs
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
const preview = page.locator('[data-component="session-tab-popover"]')
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title, d.title])
await page.clock.install()
await page.clock.pauseAt(new Date())
// Until it is paused the installed clock follows real time, and it starts slightly ahead of the runner's wall clock,
// so `pauseAt(new Date())` can name an instant the page has already passed. Pause at a fixed offset from an explicit
// install time instead; the offset only has to outlast the real time between the two calls.
const start = Date.now()
await page.clock.install({ time: start })
await page.clock.pauseAt(start + 10_000)
await tabB.hover()
await page.clock.runFor(700)
await expect(preview).toHaveCount(0)
@@ -30,7 +30,7 @@ for (const width of [1440, 390]) {
const spacer = content.querySelector('[data-timeline-row="bottom-spacer"]')
;(window as Window & { __coldReveal?: Reveal }).__coldReveal = {
window.__coldReveal = {
pending: content.querySelectorAll('[data-component="markdown"]:not([data-markdown-ready])').length,
clipped: [...content.querySelectorAll<HTMLElement>("[data-timeline-key]")].flatMap((row) =>
(row.firstElementChild?.getBoundingClientRect().height ?? 0) > row.getBoundingClientRect().height + 1
@@ -55,7 +55,7 @@ for (const width of [1440, 390]) {
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "hidden")
release.resolve()
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
const reveal = await page.evaluate(() => (window as Window & { __coldReveal?: Reveal }).__coldReveal)
const reveal = await page.evaluate(() => window.__coldReveal)
expect(reveal).toMatchObject({ pending: 0, clipped: [], tables: 1, codeBlocks: 4 })
expect(Math.abs(reveal?.bottomError ?? Infinity)).toBeLessThanOrEqual(1)
// The gap above the composer is part of the tail.
@@ -274,7 +274,23 @@ test("loads the transcript code font before opening rich history", async ({ page
await page.goto(sessionHref(fixture.sourceID))
await expect(page.getByText("A transcript with no code", { exact: true })).toBeVisible()
expect((await font).ok()).toBe(true)
await expect.poll(() => page.evaluate(() => document.fonts.check('440 13px "IBM Plex Mono"'))).toBe(true)
// Chromium recreates every @font-face as "unloaded", keeping its downloaded file, whenever a lazily loaded
// stylesheet with @layer rules activates ahead of one already applied, so the preloaded face's status alone is
// not stable. Once fonts settle, the first layout of new code must take that file at once, which marks the
// current face loaded; a face that still has to fetch its file reports "loading" here instead.
expect(
await page.evaluate(async () => {
await document.fonts.ready
const code = document.body.appendChild(document.createElement("code"))
code.style.font = "440 13px var(--font-family-mono)"
code.textContent = "code"
code.getBoundingClientRect()
const loaded = document.fonts.check('440 13px "IBM Plex Mono"')
code.remove()
return loaded
}),
).toBe(true)
})
test("waits for the requested session's history before constructing its cold timeline", async ({ page }) => {
@@ -464,6 +480,13 @@ async function expectAtTail(page: Page, lastUserID: string) {
type TabPaint = { first?: { last: boolean; bottomError?: number }; settled: boolean; removed: number; stop(): void }
declare global {
interface Window {
__coldReveal?: Reveal
__tabPaint?: TabPaint
}
}
// Samples painted frames of the timeline showing `sessionID`: the first frame that shows any of its messages, and
// every node of the first settled frame (at the tail, Markdown rendered) that is later removed.
async function sampleTabPaint(page: Page, sessionID: string) {
@@ -482,7 +505,7 @@ async function sampleTabPaint(page: Page, sessionID: string) {
},
}
;(window as Window & { __tabPaint?: TabPaint }).__tabPaint = state
window.__tabPaint = state
new MutationObserver((records) => {
if (!state.settled || !running) return
records.forEach((record) =>
@@ -520,10 +543,11 @@ async function sampleTabPaint(page: Page, sessionID: string) {
)
}
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")]
.filter(inView)
.map((element) => element.dataset.messageId!)
.filter((id) => destination.has(id))
const visible = [...root.querySelectorAll<HTMLElement>("[data-message-id]")].flatMap((element) => {
const id = element.dataset.messageId
return id && inView(element) && destination.has(id) ? [id] : []
})
const spacer = root.querySelector('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
const bottomError = spacer ? spacer.bottom - view.bottom : undefined
@@ -556,20 +580,20 @@ async function sampleTabPaint(page: Page, sessionID: string) {
last: history.findLast((message) => message.type === "user")!.id,
},
)
const read = () => page.evaluate(() => (window as Window & { __tabPaint?: TabPaint }).__tabPaint!.first)
const read = () => page.evaluate(() => window.__tabPaint!.first)
return {
async firstPaint() {
await expect.poll(read).toBeDefined()
await expect
.poll(() => page.evaluate(() => (window as Window & { __tabPaint?: TabPaint }).__tabPaint!.settled))
.poll(() => page.evaluate(() => window.__tabPaint!.settled))
.toBe(true)
return (await read())!
},
stop: () =>
page.evaluate(() => {
const state = (window as Window & { __tabPaint?: TabPaint }).__tabPaint!
const state = window.__tabPaint!
state.stop()
return state.removed
+15 -4
View File
@@ -68,8 +68,7 @@ const upgraded = { storage: { "highlights.v1": { version: "1.9.1" } } }
test("What's New shows the highlights since the version seen, pages to Get started, and is seen once shown", async ({
page,
}) => {
const requests = await serveChangelog(page)
await start(page, upgraded)
const requests = await startUpgraded(page)
const dialog = page.getByRole("dialog")
const title = dialog.getByRole("heading", { level: 1 })
const next = dialog.getByRole("button", { name: "Next", exact: true })
@@ -102,8 +101,7 @@ test("What's New shows the highlights since the version seen, pages to Get start
})
test("Don't show these in the future turns release notes off and closes What's New", async ({ page }) => {
await serveChangelog(page)
await start(page, upgraded)
await startUpgraded(page)
const dialog = page.getByRole("dialog")
await expect(dialog.getByRole("heading", { level: 1 })).toHaveText("Split panes")
await dialog.getByRole("button", { name: "Don't show these in the future", exact: true }).click()
@@ -222,6 +220,19 @@ async function start(page: Page, seed: SeedInput) {
await expectAppVisible(page.getByRole("button", { name: "Settings", exact: true }))
}
/**
* Starts as a user who last saw 1.9.1 and serves the changelog only once Home renders. What's New is a modal that hides
* Home from assistive technology, so opening it first would hide the Settings button `start` waits for.
*/
async function startUpgraded(page: Page) {
const held = Promise.withResolvers<void>()
const requests = await serveChangelog(page, changelog, held.promise)
await start(page, upgraded)
held.resolve()
return requests
}
function stored(page: Page, key: string) {
return page.evaluate((name) => localStorage.getItem(name), key).then((raw) => JSON.parse(raw ?? "null"))
}
+65
View File
@@ -0,0 +1,65 @@
import { stat } from "node:fs/promises"
import path from "node:path"
import { parseArgs } from "node:util"
// Serves the e2e build with vite preview's routing rules. vite preview checks the case of every requested
// path with a synchronous directory read on case-insensitive file systems (Windows, macOS), and the
// 3000-file _assets directory makes that the bottleneck for every page load across all workers.
const options = parseArgs({ options: { host: { type: "string" }, port: { type: "string" } } }).values
const dist = path.resolve(import.meta.dirname, "../../dist")
// Sent as text like vite's index.html middleware, so a media Range request that falls back gets a plain 200.
const index = await Bun.file(path.join(dist, "index.html")).text()
Bun.serve({
hostname: options.host,
port: Number(options.port),
async fetch(request) {
const pathname = decode(new URL(request.url).pathname)
if (pathname === undefined) return new Response(null, { status: 404 })
const target = path.resolve(dist, `.${pathname}`)
// Like vite's existsSync, a name the file system rejects (too long, NUL) is treated as missing.
const stats = target.startsWith(dist + path.sep) ? await stat(target).catch(() => undefined) : undefined
if (stats?.isFile()) {
const file = Bun.file(target)
return new Response(file, {
// Bun names .aac audio/x-aac, and only keeps the type on HEAD responses when it is set explicitly.
headers: { "content-type": path.extname(target) === ".aac" ? "audio/aac" : file.type },
})
}
if (fallback(request, pathname))
return new Response(index, { headers: { "content-type": "text/html;charset=utf-8" } })
return new Response(null, { status: 404 })
},
})
function decode(pathname: string) {
try {
return decodeURIComponent(pathname)
} catch {
return undefined
}
}
// Mirrors vite's htmlFallbackMiddleware: only page loads get index.html, so a missing script or a fetch
// asking for JSON still sees a 404.
function fallback(request: Request, pathname: string) {
if (request.method !== "GET" && request.method !== "HEAD") return false
if (pathname === "/favicon.ico") return false
// vite's index.html middleware never answers a script load with the page, so a missing chunk stays a 404.
if (request.headers.get("sec-fetch-dest") === "script") return false
const accept = request.headers.get("accept")
return !accept || accept.includes("text/html") || accept.includes("*/*")
}
+20 -3
View File
@@ -10,7 +10,7 @@ if (url.protocol !== "http:") throw new Error("E2E fixtures require an http:// a
const built = !!process.env.CI || process.env.PLAYWRIGHT_BUILD === "1"
// Production connects to its own origin, so fixture URLs must match the preview server.
// Production connects to its own origin, so fixture URLs must match the static server.
if (built) {
process.env.PLAYWRIGHT_SERVER_HOST = url.hostname
process.env.PLAYWRIGHT_SERVER_PORT = url.port || "80"
@@ -21,11 +21,13 @@ const serverHost = process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"
const serverPort = process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"
const command = built
? `bun run build && bun run serve -- --host 127.0.0.1 --port ${port} --strictPort`
? `bun run build && bun e2e/utils/serve.ts --host 127.0.0.1 --port ${port}`
: `bun run dev -- --host 127.0.0.1 --port ${port} --strictPort`
const workers = Number(process.env.PLAYWRIGHT_WORKERS ?? (process.env.CI ? 5 : 0)) || undefined
const touchScroll = /mobile-timeline-scroll\.spec\.ts/
export default defineConfig({
testDir: "./e2e",
testIgnore: [
@@ -59,12 +61,27 @@ export default defineConfig({
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: "retain-on-failure",
// CI retries failures, so the retry records the video; local runs have no retries.
video: process.env.CI ? "on-first-retry" : "retain-on-failure",
// The PWA worker precaches the whole build in every test; e2e/service-worker covers it.
serviceWorkers: "block",
},
// The touch-scroll project exists only for scheduling: in the chromium project, mobile-timeline-scroll
// is one long serial group that ends up running alone on a single worker. Projects queue in
// declaration order, so this one runs the file's tests after every chromium group, spread across all
// workers. `--project chromium`, UI mode and the VS Code extension's default first-project selection
// therefore skip the file.
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
grepInvert: touchScroll,
},
{
name: "chromium-touch-scroll",
use: { ...devices["Desktop Chrome"] },
grep: touchScroll,
fullyParallel: true,
},
],
})
+3
View File
@@ -10,6 +10,7 @@ import { selectionFromLines, type SelectedLineRange, useFile } from "@/workspace
import { useComments } from "@/composer/comments"
import { useCommand } from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { useExtensionHost } from "@/runtime/extension/host"
import { useExtensionAttachment } from "@/runtime/extension/host-apis"
import { usePlatform } from "@/runtime/platform/platform"
@@ -52,6 +53,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
const dialog = useDialog()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const platform = usePlatform()
const prompt = adapter.state
let editor: HTMLDivElement | undefined
@@ -274,6 +276,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
return (alternate ? queue.alternate() : queue.delivery()) ?? "steer"
},
genui: settings.general.generatedUI,
notify: {
missingSelection: () =>
showToast({
+2
View File
@@ -89,6 +89,7 @@ function submitInput(
commands,
clientCommand,
delivery: () => delivery,
genui: () => true,
editor: () => undefined,
queueScroll() {},
addToHistory: (prompt) =>
@@ -237,6 +238,7 @@ describe("Composer submission", () => {
displayText: "ship it",
agent: "build",
model: { providerID: "provider-1", modelID: "model-1", variant: "balanced" },
genui: true,
})
// A file chip goes with this prompt only, like the TUI's mentionless files.
expect(request.files).toMatchObject([{ name: "notes.md", description: "the failing version" }])
+5
View File
@@ -25,6 +25,7 @@ type ComposerSubmission = {
images: ImageAttachmentPart[]
selection: ComposerSelection
delivery: ComposerDelivery
genui: boolean
}
type ComposerSubmitInput = {
@@ -39,6 +40,8 @@ type ComposerSubmitInput = {
setMode: (mode: "normal" | "shell") => void
closePopover: () => void
delivery?: (alternate: boolean) => ComposerDelivery
/** Whether the app shows generated UI; tells the server it may offer the agent genui_render. Defaults to false. */
genui?: Accessor<boolean>
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined
notify: {
missingSelection: () => void
@@ -319,6 +322,7 @@ function readSubmission(
images,
selection,
delivery: input.delivery?.(alternate) ?? "steer",
genui: input.genui?.() === true,
}
}
@@ -538,6 +542,7 @@ async function sendPrompt(
attachments: request.attachments,
agent: value.selection.agent,
model: selectionModel(value.selection),
genui: value.genui,
},
prepare: staged ? undefined : () => (committed ??= commit()),
}
+14
View File
@@ -107,6 +107,11 @@
animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
/* A collapsing frame's elevation outlives its height as a flat line over the panel beside it, so it fades out. */
[data-slot="session-side-region-presence"][data-opened="false"] #review-panel {
animation: side-panel-frame-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
}
[data-slot="terminal-panel-presence"][data-opened="true"] {
animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1);
}
@@ -137,6 +142,7 @@
[data-slot="session-side-panel-presence"],
[data-slot="session-review-content"],
[data-slot="session-side-region-presence"],
[data-slot="session-side-region-presence"] #review-panel,
[data-component="terminal-panel"] {
animation: none !important;
}
@@ -173,6 +179,7 @@
}
}
/* Like `side-panel-frame-out`, the collapsing terminal frame drops its elevation. */
@keyframes side-terminal-panel-presence-out {
from {
opacity: 1;
@@ -180,6 +187,13 @@
to {
opacity: 0.999999;
visibility: hidden;
box-shadow: none;
}
}
@keyframes side-panel-frame-out {
to {
box-shadow: none;
}
}
@@ -159,12 +159,7 @@ export function SideRegion(props: {
props.region.select(value)
}}
>
{/* Tabs and actions share the review toggle's row: the session header's 48px, or the 51px
above a side dock's divider. */}
<div
class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center"
style={{ "--tabs-bar-height": props.stacked ? "51px" : "48px" }}
>
<div class="session-review-v2-tabs-bar sticky top-0 shrink-0 flex items-center">
<Tabs.List
ref={(el: HTMLDivElement) => {
tabList = el
+3
View File
@@ -674,6 +674,8 @@ export const dict = {
"settings.tab.extensions": "Extensions",
"settings.tab.experimental": "Experimental",
"settings.experimental.description": "Try experimental features",
"settings.experimental.row.generatedUI.title": "Generated UI",
"settings.experimental.row.generatedUI.description": "Let agents show dashboards, charts and forms in the timeline",
"settings.guiExtensions.title": "Extensions",
"settings.guiExtensions.description": "Built-in GUI extensions in this window. Development builds only.",
"settings.guiExtensions.reload": "Reload",
@@ -813,6 +815,7 @@ export const dict = {
"settings.timeline.category.subagents": "Subagents",
"settings.timeline.category.notices": "Notices",
"settings.timeline.category.tools": "Other tools",
"settings.timeline.category.commentary": "Commentary",
"settings.general.row.language.title": "Language",
"settings.general.row.language.description": "Change the display language for OpenCode",
+2 -13
View File
@@ -1,4 +1,4 @@
import { createEffect, createMemo, type Accessor } from "solid-js"
import { createMemo, type Accessor } from "solid-js"
import { createStore } from "solid-js/store"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLayout } from "@/shell/state/layout"
@@ -74,14 +74,6 @@ export function createSessionScreenLayout(
stacked: tabsOpen() && dockSideOpen(),
}))
const [motion, setMotion] = createStore({ gap: panelLayout().stacked, closing: false })
createEffect((previous) => {
const stacked = panelLayout().stacked
if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked })
return stacked
}, panelLayout().stacked)
const sideRegionOpen = createMemo(() => tabsOpen() || fileTreeOpen())
const dockRegion = createMemo(() =>
@@ -124,10 +116,7 @@ export function createSessionScreenLayout(
},
side: {
contentWidth: sideContentWidth,
gap: {
closing: () => motion.closing,
height: createMemo(() => (motion.gap ? "8px" : "0px")),
},
gap: { height: createMemo(() => (panelLayout().stacked ? "8px" : "0px")) },
layout: panelLayout,
region: {
height: createMemo(() => {
+9 -7
View File
@@ -172,6 +172,9 @@ function SessionScreenContent(props: {
if (bottomDockVisible()) setStore("bottomDockCached", true)
if (!sideVisible()) setStore("sideHeightMotion", false)
// A side panel restored without its entrance animation never fires the animationend that enables height motion.
if (sideVisible() && !sidePresence.animate()) setStore("sideHeightMotion", true)
})
createEffect(() => {
if (!isDesktop() || screen.dock.bottom()) setStore("sideDockPresent", false)
@@ -368,11 +371,10 @@ function SessionScreenContent(props: {
<>
<div class="flex-1 min-h-0 flex flex-col gap-2 px-[var(--shell-inline-inset,8px)] pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
<div ref={screen.panel.ref} class="relative flex-1 min-h-0 flex flex-col md:flex-row gap-2">
{/* Keep the control outside panel animations; a side dock's 52px header includes a 1px divider. */}
{/* Keep the control outside panel animations. */}
<Show when={isDesktop() && messagesReady() && session.identity.params.id}>
<div
class="absolute end-3 top-0 z-30 flex items-center"
classList={{ "h-[51px]": sideDockVisible(), "h-12": !sideDockVisible() }}
class="absolute end-3 top-0 z-30 h-12 flex items-center"
data-slot="session-review-toggle"
onPointerDown={hideTimelineScrollbar}
onClick={hideTimelineScrollbar}
@@ -487,13 +489,13 @@ function SessionScreenContent(props: {
</Show>
</div>
<div class="absolute start-0 bottom-0 flex flex-col" style={{ width: screen.side.contentWidth() }}>
{/* The gap moves with both regions, so together they always fill the column and the review's
bottom corners are never covered. */}
<div
data-slot="session-side-panel-gap"
classList={{
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[40ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"delay-0": !screen.side.gap.closing(),
"delay-[200ms]": screen.side.gap.closing(),
"transition-none": !regionAnimating(),
"relative z-0 shrink-0 overflow-visible bg-v2-background-bg-deep transition-[height] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] motion-reduce:transition-none": true,
"transition-none": screen.size.active() || !store.sideHeightMotion || !regionAnimating(),
}}
style={{ height: screen.side.gap.height() }}
onPointerDown={() => screen.size.start()}
@@ -158,6 +158,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
subagents: { ...detail.subagents },
notices: { ...detail.notices },
tools: { ...detail.tools },
commentary: { ...detail.commentary },
}
})
@@ -1,19 +1,13 @@
import { expect, test } from "bun:test"
import { type Virtualizer } from "@tanstack/solid-virtual"
import { Node, Window } from "happy-dom"
import { Virtualizer } from "@tanstack/solid-virtual"
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
test("restores a view observed before its first attachment", async () => {
const targetWindow = new Window()
const frame = openFrame()
const targetWindow = frame.targetWindow
const mutations = controlledMutations(targetWindow)
const viewport = targetWindow.document.createElement("div")
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 240,
options: { horizontal: false, isRtl: false, isScrollingResetDelay: 0, useScrollendEvent: false },
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 240, isScrollingResetDelay: 0 })
const connections: boolean[] = []
@@ -32,12 +26,13 @@ test("restores a view observed before its first attachment", async () => {
expect(instance.scrollOffset).toBe(0)
} finally {
cleanup()
await targetWindow.happyDOM.close()
frame.close()
}
})
test("reports a divergent native offset once and ignores equal offsets and unrelated mutations", async () => {
const targetWindow = new Window()
const frame = openFrame()
const targetWindow = frame.targetWindow
const mutations = controlledMutations(targetWindow)
const route = targetWindow.document.createElement("section")
const viewport = targetWindow.document.createElement("div")
@@ -45,17 +40,7 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
route.append(viewport)
targetWindow.document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 0,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 79_400, isScrollingResetDelay: 0 })
const calls: [number, boolean][] = []
@@ -80,12 +65,13 @@ test("reports a divergent native offset once and ignores equal offsets and unrel
expect(calls).toEqual([[0, false]])
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
frame.close()
}
})
test("keeps checking until stale reset-delay callbacks can no longer win", async () => {
const targetWindow = new Window()
const frame = openFrame()
const targetWindow = frame.targetWindow
const mutations = controlledMutations(targetWindow)
const animation = controlledAnimationFrames(targetWindow)
const route = targetWindow.document.createElement("section")
@@ -93,17 +79,7 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
route.append(viewport)
targetWindow.document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow,
scrollOffset: 79_400,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 20,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 79_400, isScrollingResetDelay: 20 })
const calls: number[] = []
@@ -127,7 +103,39 @@ test("keeps checking until stale reset-delay callbacks can no longer win", async
expect(animation.pending()).toBe(0)
} finally {
cleanup?.()
await targetWindow.happyDOM.close()
frame.close()
}
})
test("settles on the live offset after a write whose scroll event is still pending", async () => {
const route = document.createElement("section")
const viewport = document.createElement("div")
route.append(viewport)
document.body.append(route)
const instance = attachedVirtualizer(viewport, { scrollOffset: 0, isScrollingResetDelay: 10 })
const calls: [number, boolean][] = []
const cleanup = observeElementOffsetReconnectAware(instance, (offset, isScrolling) => {
calls.push([offset, isScrolling])
instance.scrollOffset = offset
})
try {
viewport.scrollTop = 100
viewport.dispatchEvent(new Event("scroll"))
// A prepend anchor write lands before the reset delay; the browser has not dispatched its scroll event.
viewport.scrollTop = 2_500
await new Promise((resolve) => setTimeout(resolve, 25))
expect(calls).toEqual([
[100, true],
[2_500, false],
])
} finally {
cleanup?.()
route.remove()
}
})
@@ -137,17 +145,7 @@ test("cleanup suppresses queued delegated callbacks, reconnect checks, and later
route.append(viewport)
document.body.append(route)
const instance = {
scrollElement: viewport,
targetWindow: window,
scrollOffset: 0,
options: {
horizontal: false,
isRtl: false,
isScrollingResetDelay: 10,
useScrollendEvent: false,
},
} as unknown as Virtualizer<HTMLDivElement, HTMLDivElement>
const instance = attachedVirtualizer(viewport, { scrollOffset: 0, isScrollingResetDelay: 10 })
const calls: [number, boolean][] = []
@@ -170,49 +168,71 @@ test("cleanup suppresses queued delegated callbacks, reconnect checks, and later
route.remove()
})
type FrameWindow = {
requestAnimationFrame(callback: () => void): unknown
performance: { now(): number }
// A real virtualizer attached to the viewport the way its mount does (scroll element plus the window that owns it),
// without starting its own rect and offset observers, so each test drives the observer under test directly.
function attachedVirtualizer(viewport: HTMLDivElement, state: { scrollOffset: number; isScrollingResetDelay: number }) {
const instance = new Virtualizer<HTMLDivElement, HTMLDivElement>({
count: 0,
getScrollElement: () => viewport,
estimateSize: () => 0,
scrollToFn: () => {},
observeElementRect: () => {},
observeElementOffset: () => {},
isScrollingResetDelay: state.isScrollingResetDelay,
})
instance.scrollElement = viewport
instance.targetWindow = viewport.ownerDocument.defaultView
instance.scrollOffset = state.scrollOffset
return instance
}
async function frames(count: number, targetWindow: FrameWindow = window) {
// A separate window whose MutationObserver and animation frames a test can replace without touching the global
// window. happy-dom loads a srcdoc frame synchronously, so its window is ready once the frame is attached.
function openFrame() {
const frame = document.createElement("iframe")
frame.srcdoc = ""
document.body.append(frame)
const targetWindow = frame.contentDocument?.defaultView
if (!targetWindow) throw new Error("Frame window did not load")
return { targetWindow, close: () => frame.remove() }
}
async function frames(count: number, targetWindow: Window = window) {
for (let index = 0; index < count; index++) {
await new Promise<void>((resolve) => targetWindow.requestAnimationFrame(() => resolve()))
}
}
function controlledMutations(targetWindow: Window) {
let emit: (record: MutationRecord) => void = () => {
function controlledMutations(targetWindow: Window & typeof globalThis) {
let flush: () => void = () => {
throw new Error("Mutation observer is not active")
}
class ControlledMutationObserver {
// Delivers each operation's records as soon as it happens instead of at the next microtask.
class ControlledMutationObserver extends targetWindow.MutationObserver {
constructor(callback: MutationCallback) {
emit = (record) => callback([record], this as unknown as MutationObserver)
}
observe() {}
disconnect() {}
takeRecords() {
return []
super(callback)
flush = () => callback(this.takeRecords(), this)
}
}
Object.defineProperty(targetWindow, "MutationObserver", { value: ControlledMutationObserver })
const record = (target: Node, addedNodes: Node[], removedNodes: Node[]) =>
({ type: "childList", target, addedNodes, removedNodes }) as unknown as MutationRecord
return {
append(parent: Node, node: Node) {
parent.appendChild(node)
emit(record(parent, [node], []))
flush()
},
remove(node: Node) {
const parent = node.parentNode
if (!parent) throw new Error("Mutation target has no parent")
parent.removeChild(node)
emit(record(parent, [], [node]))
flush()
},
}
}
@@ -6,14 +6,18 @@ export function observeElementOffsetReconnectAware<TScrollElement extends Elemen
onReconnect?: () => void,
) {
let active = true
const element = instance.scrollElement
// TanStack resets isScrolling on a timer that reports the offset of the last scroll event. A virtualizer
// write since then (prepend anchoring, size compensation) may not have dispatched its scroll event yet, and
// compensating from that stale offset would undo the write, so a settled scroll reads the live scrollTop
// (vertical only, like the reconnect check below).
const deliver = (offset: number, isScrolling: boolean) => {
if (!active) return
callback(offset, isScrolling)
callback(isScrolling || !element?.isConnected ? offset : element.scrollTop, isScrolling)
}
const cleanupOffset = observeElementOffset(instance, deliver)
const element = instance.scrollElement
const targetWindow = instance.targetWindow
const root = element?.closest("main") ?? element?.ownerDocument.body
@@ -1,11 +1,16 @@
import { Component } from "solid-js"
import { Switch } from "@opencode/ui/switch"
import { useLanguage } from "@/runtime/i18n/language"
import { SettingsList } from "@/settings/list"
import { useSettings } from "@/settings/model"
import { SettingsRow } from "@/settings/row"
import "@/settings/settings.css"
export const experimentalSettingsAvailable = false
export const experimentalSettingsAvailable = true
export const SettingsExperimental: Component = () => {
const language = useLanguage()
const settings = useSettings()
return (
<>
@@ -19,6 +24,24 @@ export const SettingsExperimental: Component = () => {
</div>
</div>
</div>
<div class="settings-tab-body">
<div class="settings-section">
<SettingsList>
<SettingsRow
title={language.t("settings.experimental.row.generatedUI.title")}
description={language.t("settings.experimental.row.generatedUI.description")}
>
<div data-action="settings-generated-ui">
<Switch
checked={settings.general.generatedUI()}
onChange={(checked) => settings.general.setGeneratedUI(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</div>
</div>
</>
)
}
+1
View File
@@ -94,6 +94,7 @@ describe("settings schema", () => {
timelineDetail: timelinePresets[2].value,
showCustomAgents: false,
showTurnDiffs: false,
generatedUI: true,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
+8
View File
@@ -114,9 +114,12 @@ const generalSchema = Persistence.struct({
subagents: placementOnlySchema,
notices: placementOnlySchema,
tools: placementOnlySchema,
commentary: placementOnlySchema,
}),
showCustomAgents: Schema.Boolean,
showTurnDiffs: Schema.Boolean,
// Lets agents render generated UI in the timeline; an experiment users can turn off.
generatedUI: Schema.Boolean,
mobileTitlebarPosition: Schema.Literals(["top", "bottom"]),
// Owned by the review extension, which copies it out once; kept so settings rewrites cannot drop it first.
mobileDiffWrap: Persistence.optional(Schema.Boolean),
@@ -280,6 +283,7 @@ export const defaultSettings: Settings = {
timelineDetail: { ...timelinePresets[2].value },
showCustomAgents: false,
showTurnDiffs: false,
generatedUI: true,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
@@ -357,6 +361,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowTurnDiffs(value: boolean) {
setStore("general", "showTurnDiffs", value)
},
generatedUI: withFallback(() => store.general?.generatedUI, defaultSettings.general.generatedUI),
setGeneratedUI(value: boolean) {
setStore("general", "generatedUI", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
@@ -21,6 +21,7 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
{ tab: "notifications", label: "settings.tab.notifications" },
{ tab: "shortcuts", label: "settings.shortcuts.title", keywords: "keybind keyboard hotkey" },
{ tab: "about", label: "settings.tab.about", keywords: "version license credits" },
{ tab: "experimental", label: "settings.tab.experimental", keywords: "experiments preview beta" },
{ tab: "general", label: "settings.general.row.language.title", target: "settings-language" },
{
tab: "general",
@@ -79,6 +80,13 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
target: "settings-show-custom-agents",
section: "settings.general.section.general",
},
{
tab: "experimental",
label: "settings.experimental.row.generatedUI.title",
target: "settings-generated-ui",
description: "settings.experimental.row.generatedUI.description",
keywords: "genui generative interface dashboards charts forms",
},
{
tab: "appearance",
label: "settings.general.row.colorScheme.title",
@@ -13,6 +13,7 @@ import { readLocalImage } from "@/runtime/server/image"
import { useLanguage } from "@/runtime/i18n/language"
import { showToast } from "@/shell/notifications/toast"
import { createTurnDiffCache } from "@/session/timeline/turn-diffs"
import { useSettings } from "@/settings/model"
export function SessionUIProvider(
props: ParentProps<{
@@ -26,6 +27,7 @@ export function SessionUIProvider(
const serverSDK = useServerSDK()
const tabs = useTabs()
const language = useLanguage()
const settings = useSettings()
const directory = () => props.directory
const readImage = createMemo<ReadMarkdownImage>(() => {
@@ -112,6 +114,9 @@ export function SessionUIProvider(
}}
onNavigateToSession={navigateToSession}
onSessionHref={href}
onPrompt={(input) =>
data.session.prompt({ ...input, metadata: { genui: settings.general.generatedUI() } }).then(() => undefined)
}
>
<MarkdownProvider readImage={readImage()} openSession={openReferencedSession}>
<LocalProvider>{props.children}</LocalProvider>
@@ -27,7 +27,7 @@ test.each([
expect(config.webServer.url).toBe(config.use.baseURL)
expect(config.webServer.command).toBe(
built
? "bun run build && bun run serve -- --host 127.0.0.1 --port 4321 --strictPort"
? "bun run build && bun e2e/utils/serve.ts --host 127.0.0.1 --port 4321"
: "bun run dev -- --host 127.0.0.1 --port 4321 --strictPort",
)
expect(config.webServer.reuseExistingServer).toBe(!built)
+8 -4
View File
@@ -20,16 +20,20 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b
.map(async (key) => {
const body = Buffer.from(await Bun.file(path.join(root, "dist", key)).arrayBuffer())
// Independent entries let the server materialize only assets the browser requests.
return [key, await compress(body)] as const
return [key, await compress(body, channel)] as const
}),
),
)
}
// The assets compress once per release, so they take brotli's best quality: about 11% smaller than quality 6 for the
// web UI. Each runs on the thread pool, which keeps the build to about a minute.
function compress(body: Buffer) {
// web UI. Each runs on the thread pool, which keeps the build to about a minute. Beta builds on every push and only
// bundles the CLI into the desktop app, so it keeps quality 6, which takes a few seconds.
function compress(body: Buffer, channel: string) {
return promisify(brotliCompress)(body, {
params: { [constants.BROTLI_PARAM_QUALITY]: 11, [constants.BROTLI_PARAM_SIZE_HINT]: body.length },
params: {
[constants.BROTLI_PARAM_QUALITY]: channel === "beta" ? 6 : 11,
[constants.BROTLI_PARAM_SIZE_HINT]: body.length,
},
})
}
+5 -1
View File
@@ -266,7 +266,11 @@ async function compileExecutable(item: (typeof allTargets)[number]) {
function compileReleaseAssets(release: string) {
const existing = releaseAssets.get(release)
if (existing) return existing
const pending = fetch(`https://api.github.com/repos/oven-sh/bun/releases/tags/${release}?cache=${Date.now()}`)
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN
// Beta builds each CLI in its own job; authenticated requests avoid the per-IP limit on shared runners.
const pending = fetch(`https://api.github.com/repos/oven-sh/bun/releases/tags/${release}?cache=${Date.now()}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then(async (response) => {
if (!response.ok) throw new Error(`Failed to resolve Bun release ${release}: ${response.status}`)
const data: unknown = await response.json()
@@ -2308,7 +2308,6 @@ export type ConfigEntry =
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
location_idle_timeout?: string
policies?: Array<{
action: "provider.use" | "tool.use" | "integration.use"
resource: string
+4 -1
View File
@@ -249,7 +249,10 @@ test("session methods retain decoded Effect inputs and outputs", async () => {
const created = yield* client.session.create({
location: Location.Ref.make({ directory: AbsolutePath.make("/tmp/project") }),
})
yield* client.session.view({ sessionID: Session.ID.make("ses_test"), idle: session.data.time.idle })
yield* client.session.view({
sessionID: Session.ID.make("ses_test"),
idle: DateTime.makeUnsafe(session.data.time.idle),
})
yield* client.session.switchAgent({ sessionID: Session.ID.make("ses_test"), agent: Agent.ID.make("build") })
yield* client.session.switchModel({
sessionID: Session.ID.make("ses_test"),
@@ -1,8 +1,15 @@
import { expect } from "bun:test"
import { mkdtemp, rename, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { waitForExit } from "./service-timing"
// Windows has no catchable SIGTERM: process.kill ends the target at once with exit code 1, so the
// service's SIGTERM handler (record the signal, unregister a lingering server, exit 0) runs only on POSIX.
export const catchesSigterm = process.platform !== "win32"
export const stopSignal = catchesSigterm ? "SIGTERM" : undefined
export const stoppedExitCode = catchesSigterm ? 0 : 1
export async function serviceFixture() {
const directory = await mkdtemp(join(tmpdir(), "opencode-client-service-"))
const registration = join(directory, "service.json")
@@ -64,6 +71,12 @@ export async function serviceFixture() {
track(pid: number) {
pids.add(pid)
},
// The signal the service recorded while shutting down, if its handler ran.
signal() {
return Bun.file(registration + ".signal")
.text()
.catch(() => undefined)
},
async waitForFile(file = registration) {
for (let attempt = 0; attempt < 600; attempt++) {
if (await Bun.file(file).exists()) return
@@ -92,7 +105,13 @@ export async function serviceFixture() {
}
}
export async function expectPortAvailable(url: string) {
const server = Bun.serve({ port: Number(new URL(url).port), fetch: () => new Response() })
await server.stop(true)
// The service listens on an ephemeral port. Once it stops, any process may reuse that port for an outgoing connection
// (Windows hands them out from 49152 up, and other suites run alongside), so binding it again proves nothing. What the
// tests need is that nothing accepts connections there anymore.
export async function expectNotListening(url: string) {
const outcome = await fetch(url).then(
(response) => `listening (${response.status})`,
() => "no response",
)
expect(outcome).toBe("no response")
}
@@ -39,7 +39,6 @@ describe("public import boundaries", () => {
const effectService = await bundleInputs("@opencode/client/effect/service", "bun")
expect(within(effectService.eager, effect).length).toBeGreaterThan(0)
expect(within(effectService.eager, protocol).length).toBeGreaterThan(0)
expect(within(effectService.all, core)).toEqual([])
expect(within(effectService.all, server)).toEqual([])
})
+46 -35
View File
@@ -1,6 +1,12 @@
import { expect, test } from "bun:test"
import { Service, type EnsureReason } from "../src/promise/service"
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
import {
catchesSigterm,
expectNotListening,
serviceFixture,
stopSignal,
stoppedExitCode,
} from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const ensure = accelerate(Service.ensure)
@@ -213,7 +219,7 @@ test("evicts an unresponsive registered service before starting its replacement"
fixture.track(replacement.pid)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
})
@@ -231,7 +237,7 @@ test("recovers when the starting service it waits for is stopped", async () => {
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await starting.exited).toBe(0)
expect(await starting.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(starting.pid)
expect(endpoint.url).toBe(replacement.url)
})
@@ -267,7 +273,7 @@ test("stops the registered service even when terminal handoff fails", async () =
await Service.stop({ file: registration, pty: "handoff" })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(registration).exists()).toBe(false)
expect((await Bun.file(registration + ".pty-handoff").json()).handoff).toBeNull()
})
@@ -280,7 +286,7 @@ test("signals the registered service process", async () => {
await Service.stop({ file: registration })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(registration).exists()).toBe(false)
})
@@ -293,9 +299,9 @@ test("stop escalates when the registration disappears before the process exits",
await stop({ file: registration })
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
await expectNotListening(original.url)
expect(await Bun.file(registration).exists()).toBe(false)
await existing.exited
}, 15_000)
@@ -315,34 +321,39 @@ test.skipIf(process.platform === "win32")(
15_000,
)
test("stop waits for the original process while preserving a newly registered successor", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = Service.stop({ file: registration })
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
// On Windows the original process cannot outlive SIGTERM, so no successor can register before it exits.
test.skipIf(!catchesSigterm)(
"stop waits for the original process while preserving a newly registered successor",
async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = Service.stop({ file: registration })
await stopping
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await fetch(new URL("/api/info", replacement.url)).then((response) => response.json())).toMatchObject({
pid: successor.pid,
})
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
}, 20_000)
await stopping
expect(() => process.kill(original.pid, 0)).toThrow()
await expectNotListening(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await fetch(new URL("/api/info", replacement.url)).then((response) => response.json())).toMatchObject({
pid: successor.pid,
})
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
},
20_000,
)
+6 -6
View File
@@ -41,17 +41,17 @@ test("exposes every standard HTTP API group", () => {
expect(Object.keys(client.message)).toEqual(["list"])
expect(Object.keys(client.integration)).toEqual(["list", "get", "wellknown", "connect", "oauth", "command"])
expect(Object.keys(client.integration.wellknown)).toEqual(["add"])
expect(Object.keys(client.integration.connect)).toEqual(["key"])
expect(Object.keys(client.integration.connect)).toEqual(["key", "external"])
expect(Object.keys(client.integration.oauth)).toEqual(["connect", "status", "complete", "cancel"])
expect(Object.keys(client.integration.command)).toEqual(["connect", "status", "cancel"])
expect(Object.keys(client.websearch)).toEqual(["providers", "query"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find"])
expect(Object.keys(client.vcs)).toEqual(["get", "base", "status", "branches", "diff"])
expect(Object.keys(client.file)).toEqual(["read", "list", "find", "write"])
expect(Object.keys(client.vcs)).toEqual(["init", "get", "base", "status", "branch", "diff"])
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove", "connect"])
expect(Object.keys(client.pty.connect)).toEqual(["token"])
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
expect(client.experimental.persistentPty.read).toBeFunction()
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
expect(Object.keys(client.project)).toEqual(["list", "update"])
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
})
@@ -971,7 +971,7 @@ test("session methods use the public HTTP contract", async () => {
const context = await client.session.context({ sessionID: "ses_test" })
const log = []
for await (const item of client.session.log({ sessionID: "ses_test", after: 0 })) log.push(item)
const interrupted = await client.session.interrupt({ sessionID: "ses_test", continue: true })
const interrupted = await client.session.interrupt({ sessionID: "ses_test", resume: true })
const message = await client.session.message.get({ sessionID: "ses_test", messageID: "msg_model" })
expect(page.cursor.next).toBe("next")
@@ -999,7 +999,7 @@ test("session methods use the public HTTP contract", async () => {
["POST", "http://localhost:3000/api/experimental/session/ses_test/wait"],
["GET", "http://localhost:3000/api/session/ses_test/context"],
["GET", "http://localhost:3000/api/experimental/session/ses_test/log?after=0"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?continue=true"],
["POST", "http://localhost:3000/api/session/ses_test/interrupt?resume=true"],
["GET", "http://localhost:3000/api/session/ses_test/message/msg_model"],
])
const viewBody = requests.find((request) => request.url.endsWith("/api/session/ses_test/view"))?.init?.body
@@ -4,7 +4,7 @@ import { Effect } from "effect"
import { Service } from "../src/effect/service"
import { ensure, stop } from "../src/promise/service"
import type { EnsureOptions, EnsureReason, StopOptions } from "../src/service"
import { serviceFixture } from "./fixture/service-fixture"
import { serviceFixture, stopSignal, stoppedExitCode } from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const effect = accelerate(Service.ensure)
@@ -71,8 +71,8 @@ for (const client of clients) {
const replacement = await Bun.file(fixture.registration).json()
fixture.track(replacement.pid)
expect(await owner.exited).toBe(0)
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
expect(await owner.exited).toBe(stoppedExitCode)
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(fixture.registration + ".handoff-request").exists()).toBe(true)
expect(starts).toEqual(["version-mismatch"])
expect(replacement.pid).not.toBe(owner.pid)
@@ -87,8 +87,8 @@ for (const client of clients) {
await Bun.write(fixture.registration + ".missing-health", "")
await client.stop({ file: fixture.registration })
expect(await owner.exited).toBe(0)
expect(await Bun.file(fixture.registration + ".signal").text()).toBe("SIGTERM")
expect(await owner.exited).toBe(stoppedExitCode)
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(fixture.registration).exists()).toBe(false)
})
}
+45 -34
View File
@@ -3,7 +3,13 @@ import { expect, test } from "bun:test"
import { Effect, FileSystem } from "effect"
import { writeFile } from "node:fs/promises"
import { Service, type EnsureReason } from "../src/effect/service"
import { expectPortAvailable, serviceFixture } from "./fixture/service-fixture"
import {
catchesSigterm,
expectNotListening,
serviceFixture,
stopSignal,
stoppedExitCode,
} from "./fixture/service-fixture"
import { accelerate } from "./fixture/service-timing"
const ensure = accelerate(Service.ensure)
@@ -95,7 +101,7 @@ test("replaces an incompatible registered service", async () => {
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(replacement.version).toBe("2.1.0-next.1")
expect(endpoint.url).toBe(replacement.url)
expect(starts).toEqual(["version-mismatch"])
@@ -127,7 +133,7 @@ test("recovers when the starting service it waits for is stopped", async () => {
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await starting.exited).toBe(0)
expect(await starting.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(starting.pid)
expect(endpoint.url).toBe(replacement.url)
})
@@ -187,7 +193,7 @@ test("evicts an unresponsive registered service before starting its replacement"
fixture.track(replacement.pid)
expect((await Bun.file(registration + ".requests").text()).trim().split("\n")).toHaveLength(3)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(replacement.pid).not.toBe(original.pid)
expect(endpoint.url).toBe(replacement.url)
expect(await status(endpoint.url)).toMatchObject({ version: "test", pid: replacement.pid })
@@ -201,7 +207,7 @@ test("signals an unresponsive registered service process", async () => {
await run(Service.stop({ file: registration }))
await process.exited
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(await Bun.file(registration).exists()).toBe(false)
})
@@ -214,9 +220,9 @@ test("stop escalates when the registration disappears before the process exits",
await run(stop({ file: registration }))
expect(await Bun.file(registration + ".signal").text()).toBe("SIGTERM")
expect(await fixture.signal()).toBe(stopSignal)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
await expectNotListening(original.url)
expect(await Bun.file(registration).exists()).toBe(false)
await existing.exited
}, 15_000)
@@ -236,35 +242,40 @@ test.skipIf(process.platform === "win32")(
15_000,
)
test("stop waits for the original process while preserving a newly registered successor", async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = run(Service.stop({ file: registration }))
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
// On Windows the original process cannot outlive SIGTERM, so no successor can register before it exits.
test.skipIf(!catchesSigterm)(
"stop waits for the original process while preserving a newly registered successor",
async () => {
await using fixture = await serviceFixture()
const registration = fixture.registration
const existing = fixture.spawn("lingering", "15000")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
const original = await Bun.file(registration).json()
// Default timing keeps the grace period open long enough for the successor to register first.
const stopping = run(Service.stop({ file: registration }))
await stopping
try {
await fixture.waitForFile(registration + ".unregistered")
const successor = fixture.spawn("graceful")
await fixture.waitForFile()
const replacement = await Bun.file(registration).json()
expect(existing.exitCode).toBe(null)
expect(replacement.pid).toBe(successor.pid)
expect(() => process.kill(original.pid, 0)).toThrow()
await expectPortAvailable(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await status(replacement.url)).toMatchObject({ pid: successor.pid })
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
}, 20_000)
await stopping
expect(() => process.kill(original.pid, 0)).toThrow()
await expectNotListening(original.url)
expect(await Bun.file(registration).json()).toEqual(replacement)
expect(await status(replacement.url)).toMatchObject({ pid: successor.pid })
expect(successor.exitCode).toBe(null)
} finally {
existing.kill("SIGKILL")
await stopping
}
},
20_000,
)
test("signals an incompatible service before starting its replacement", async () => {
await using fixture = await serviceFixture()
@@ -281,7 +292,7 @@ test("signals an incompatible service before starting its replacement", async ()
const replacement = await Bun.file(registration).json()
fixture.track(replacement.pid)
expect(await existing.exited).toBe(0)
expect(await existing.exited).toBe(stoppedExitCode)
expect(endpoint.url).toBe(replacement.url)
})
+1
View File
@@ -111,6 +111,7 @@
"@opencode/codemode": "workspace:*",
"@opencode/plugin": "workspace:*",
"@opencode/plugin-browser": "workspace:*",
"@opencode/plugin-genui": "workspace:*",
"@opencode/schema": "workspace:*",
"@opencode/util": "workspace:*",
"@parcel/watcher": "2.5.1",
+2 -1
View File
@@ -35,7 +35,8 @@ const environment = {
}
const child = Bun.spawn({
cmd: [process.execPath, "test", "--only-failures", ...process.argv.slice(2)],
// The 30s default timeout matches cli and tui; npm.test's Git package installs ran past Bun's 5s default on Windows.
cmd: [process.execPath, "test", "--only-failures", "--timeout=30000", ...process.argv.slice(2)],
env: environment,
stdin: "inherit",
stdout: "inherit",
-9
View File
@@ -476,15 +476,6 @@ function normalizeExperimental(
)
if (value !== undefined) result.subagent_depth = value
}
if (own(experimental, "location_idle_timeout")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.location_idle_timeout,
experimental.location_idle_timeout,
["experimental", "location_idle_timeout"],
diagnostics,
)
if (value !== undefined) result.location_idle_timeout = value
}
}
}
if (Object.keys(result).length || (isRecord(input.experimental) && !Object.keys(input.experimental).length))
@@ -90,6 +90,14 @@ export const Plugin = define({
}
next.set(item, { providerID, models: definitions })
}
for (const [item, definition] of next) {
for (const [id, config] of Object.entries(item.models ?? {})) {
if (config.limit === undefined) continue
providers.models.update(definition.providerID, id, (model) => {
model.limit = { ...model.limit, ...config.limit }
})
}
}
sources.defaultModel = Config.latest(loaded.entries, "model")
sources.models = next
})
+5 -36
View File
@@ -1,12 +1,9 @@
export * as LocationActivity from "./location-activity.js"
import { Clock, Context, Duration, Effect, Layer, Option, RcMap, Schema } from "effect"
import { Clock, Context, Duration, Effect, Layer, RcMap, Schema } from "effect"
import { Bus } from "./bus.js"
import { Config } from "./config.js"
import { Form } from "./form.js"
import { Location } from "./location.js"
import { LocationServiceMap } from "./location-service-map.js"
import { Permission } from "./permission.js"
import { SessionEvent } from "./session/event.js"
import { SessionExecution } from "./session/execution.js"
import { SessionStore } from "./session/store.js"
@@ -25,12 +22,12 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
const locations = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.Service
const sessions = yield* SessionStore.Service
const timeToLive = Duration.fromInputUnsafe(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; touchedAt: number }>()
const timeToLive = Duration.toMillis(options.timeToLive ?? "60 minutes")
const entries = new Map<string, { readonly ref: Location.Ref; expiresAt: number }>()
const key = (ref: Location.Ref) => `${LocationServiceMap.canonical(ref).directory}\0${ref.workspaceID ?? ""}`
const touch = (ref: Location.Ref) =>
Effect.sync(() => {
entries.set(key(ref), { ref, touchedAt: clock.currentTimeMillisUnsafe() })
entries.set(key(ref), { ref, expiresAt: clock.currentTimeMillisUnsafe() + timeToLive })
})
const unsubscribe = yield* bus.listen((event) => {
@@ -51,35 +48,7 @@ export function layer(options: { readonly timeToLive?: Duration.Input; readonly
if (!cached.has(id)) entries.delete(id)
}
const now = clock.currentTimeMillisUnsafe()
const expired = yield* Effect.filter(Array.from(entries.values()), (entry) =>
locations.contextEffectOption(entry.ref).pipe(
Effect.flatMap(
Option.match({
onNone: () => Effect.succeed(false),
onSome: (context) =>
Effect.gen(function* () {
const config = Context.get(context, Config.Service)
const configured = (yield* config.entries())
.flatMap((item) =>
item.type === "document" && item.info.experimental?.location_idle_timeout
? [item.info.experimental.location_idle_timeout]
: [],
)
.at(-1)
if (entry.touchedAt + Duration.toMillis(configured ?? timeToLive) > now) return false
// A pending question or permission prompt is waiting on a human, not idle.
const forms = Context.get(context, Form.Service)
const permission = Context.get(context, Permission.Service)
if ((yield* forms.list()).length === 0 && (yield* permission.list()).length === 0) return true
yield* touch(entry.ref)
return false
}),
}),
),
Effect.scoped,
Effect.orElseSucceed(() => false),
),
)
const expired = Array.from(entries.values()).filter((entry) => entry.expiresAt <= now)
if (expired.length === 0) return
const active = yield* Effect.forEach(yield* execution.active, (sessionID) => sessions.get(sessionID))
yield* Effect.forEach(
+1 -1
View File
@@ -1 +1 @@
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"anthropic/claude-fable-5":{"id":"anthropic/claude-fable-5","name":"Claude Fable 5","description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","attachment":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"tool_call":true,"structured_output":true,"temperature":false,"knowledge":"2026-01-31","release_date":"2026-06-09","last_updated":"2026-06-09","modalities":{"input":["text","image","pdf"],"output":["text"]},"open_weights":false,"limit":{"context":1000000,"output":128000},"cost":{"input":10,"output":50},"canonical_model_id":"anthropic/claude-fable-5"},"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"status":"deprecated","cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MLine truncated
{"deepinfra":{"id":"deepinfra","env":["DEEPINFRA_API_KEY"],"npm":"@ai-sdk/deepinfra","name":"Deep Infra","doc":"https://deepinfra.com/models","models":{"anthropic/claude-fable-5":{"id":"anthropic/claude-fable-5","name":"Claude Fable 5","description":"Claude model for creative writing, analysis, and controlled agent workflows","family":"claude-fable","attachment":true,"reasoning":true,"reasoning_options":[{"type":"effort","values":["low","medium","high","xhigh","max"]}],"tool_call":true,"structured_output":true,"temperature":false,"knowledge":"2026-01-31","release_date":"2026-06-09","last_updated":"2026-06-09","modalities":{"input":["text","image","pdf"],"output":["text"]},"open_weights":false,"limit":{"context":1000000,"output":128000},"cost":{"input":10,"output":50},"canonical_model_id":"anthropic/claude-fable-5"},"tencent/Hy3":{"id":"tencent/Hy3","name":"Hy3","description":"Tencent Hy reasoning model for coding, instruction following, and agent tasks","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-07-06","last_updated":"2026-07-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":262144,"input":192000,"output":128000},"status":"deprecated","cost":{"input":0.13,"output":0.53,"cache_read":0.033},"canonical_model_id":"tencent/hy3"},"tencent/Hy4-preview":{"id":"tencent/Hy4-preview","name":"Hy4 preview","description":"A next-generation productivity model with significantly enhanced Agent and complex task execution capabilities.","family":"Hy","attachment":false,"reasoning":true,"reasoning_options":[{"type":"effort","values":["none","high"]}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-08-28","last_updated":"2026-08-28","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":64000},"cost":{"input":0.834,"output":2.501,"cache_read":0.042},"canonical_model_id":"tencent/hy4-preview"},"meta-llama/Llama-3.3-70B-Instruct-Turbo":{"id":"meta-llama/Llama-3.3-70B-Instruct-Turbo","name":"Llama 3.3 70B Turbo","description":"Compact Llama instruction model for fast chat and local deployment","family":"llama","attachment":false,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2024-12-06","last_updated":"2024-12-06","modalities":{"input":["text"],"output":["text"]},"open_weights":true,"limit":{"context":131072,"output":16384},"cost":{"input":0.1,"output":0.32}},"meta-llama/Llama-4-Scout-17B-16E-Instruct":{"id":"meta-llama/Llama-4-Scout-17B-16E-Instruct","name":"Llama 4 Scout 17B","description":"Open multimodal Llama model for long-context analysis and efficient agents","family":"llama","attachment":true,"reasoning":false,"tool_call":true,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":327680,"output":16384},"cost":{"input":0.1,"output":0.3}},"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8":{"id":"meta-llama/Llama-4-Maverick-17B-128E-Instruct-FP8","name":"Llama 4 Maverick 17B FP8","description":"Open multimodal Llama model for strong reasoning and fast responses","family":"llama","attachment":true,"reasoning":false,"tool_call":false,"structured_output":true,"release_date":"2025-04-05","last_updated":"2025-04-05","modalities":{"input":["text","image"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":0.2,"output":0.8}},"XiaomiMiMo/MiMo-V2.6-Pro":{"id":"XiaomiMiMo/MiMo-V2.6-Pro","name":"MiMo-V2.6-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"structured_output":true,"temperature":true,"release_date":"2026-09-22","last_updated":"2026-09-22","modalities":{"input":["text","image","audio","video"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":131072},"cost":{"input":0.43,"output":0.87,"cache_read":0.0036},"canonical_model_id":"xiaomi/mimo-v2.6-pro"},"XiaomiMiMo/MiMo-V2.5-Pro":{"id":"XiaomiMiMo/MiMo-V2.5-Pro","name":"MiMo-V2.5-Pro","description":"Stronger MiMo Pro tier for multimodal reasoning and coding-agent execution","family":"mimo","attachment":true,"reasoning":true,"reasoning_options":[{"type":"toggle"}],"tool_call":true,"interleaved":{"field":"reasoning_content"},"structured_output":true,"temperature":true,"knowledge":"2024-12","release_date":"2026-04-22","last_updated":"2026-04-22","modalities":{"input":["text","audio"],"output":["text"]},"open_weights":true,"limit":{"context":1048576,"output":16384},"status":"deprecated","cost":{"input":1,"output":3,"cache_read":0.2},"canonical_model_id":"xiaomi/mimo-v2.5-pro"},"XiaomiMiMo/MiMo-V2.6-Flash":{"id":"XiaomiMiMo/MiMo-V2.6-Flash","name":"MLine truncated
+2
View File
@@ -88,6 +88,7 @@ import { WellKnown } from "../wellknown.js"
import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import GenUIPlugin from "@opencode/plugin-genui"
import { CommandPlugin } from "./command.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
@@ -254,6 +255,7 @@ const post = [
ConfigReferencePlugin.Plugin,
ConfigAgentPlugin.Plugin,
BrowserPlugin,
GenUIPlugin,
ConfigCommandPlugin.Plugin,
ConfigCompactionPlugin.Plugin,
ConfigFormatterPlugin.Plugin,
@@ -282,19 +282,30 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
}),
true,
)
const toolInput = fragments("tool input", (id, value) =>
Effect.gen(function* () {
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
// Keep a bounded excerpt for recovery errors; the full argument text is persisted below.
tool.input = inputExcerpt(value)
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID,
id,
text: value,
})
}),
const toolInput = fragments(
"tool input",
(id, value) =>
Effect.gen(function* () {
const tool = tools.get(id)
if (!tool) return yield* Effect.die(new Error(`Tool input end before start: ${id}`))
// Keep a bounded excerpt for recovery errors; the full argument text is persisted below.
tool.input = inputExcerpt(value)
yield* bus.publish(SessionEvent.Tool.Input.Ended, {
sessionID: input.sessionID,
assistantMessageID,
id,
text: value,
})
}),
(id, value) =>
Effect.gen(function* () {
yield* bus.publish(SessionEvent.Tool.Input.Delta, {
sessionID: input.sessionID,
assistantMessageID,
id,
delta: value,
})
}),
)
// Deltas are batched, but block starts are not. Publishing held deltas first keeps each
@@ -302,6 +313,7 @@ export const createLLMEventPublisher = (bus: Pick<Bus.Interface, "publish">, inp
const publishPendingDeltas = Effect.fnUntraced(function* () {
yield* text.publishPending()
yield* reasoning.publishPending()
yield* toolInput.publishPending()
})
const flushFragments = Effect.fnUntraced(function* () {
+4 -1
View File
@@ -299,7 +299,10 @@ function sanitizeMessage(message: SessionMessage.Info): SessionMessage.Info {
summary: redact("compaction-summary", message.id, message.summary),
recent: redact("compaction-recent", message.id, message.recent),
...(message.status === "completed"
? { providerState: metadata("compaction-provider-state", message.id, message.providerState) }
? {
providerState: metadata("compaction-provider-state", message.id, message.providerState),
providerContext: undefined,
}
: {}),
}
}
+22 -2
View File
@@ -1,5 +1,5 @@
import { expect } from "bun:test"
import { Context, Effect, Fiber, RcMap, Schedule } from "effect"
import { Clock, Context, Duration, Effect, Fiber, Layer, RcMap, Schedule } from "effect"
import { Browser } from "@opencode/plugin-browser/rpc"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
@@ -21,6 +21,22 @@ import { offlineModels } from "./fixture/models"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// LocationActivity measures idleness on this clock. Its sweep still sleeps in real time, but only the test moves the
// clock past the TTL, so slow plugin activation on a busy machine cannot evict the location before the browser
// attachment exists, as a short real TTL could.
const timeToLive = Duration.minutes(1)
const idle = { millis: 0 }
const live = Clock.Clock.defaultValue()
const activityClock: Clock.Clock = {
currentTimeMillisUnsafe: () => live.currentTimeMillisUnsafe() + idle.millis,
currentTimeMillis: Effect.sync(() => live.currentTimeMillisUnsafe() + idle.millis),
currentTimeNanosUnsafe: () => live.currentTimeNanosUnsafe() + BigInt(idle.millis) * 1_000_000n,
currentTimeNanos: Effect.sync(() => live.currentTimeNanosUnsafe() + BigInt(idle.millis) * 1_000_000n),
monotonicTimeNanosUnsafe: () => live.monotonicTimeNanosUnsafe(),
monotonicTimeNanos: live.monotonicTimeNanos,
sleep: (duration) => live.sleep(duration),
}
const it = testEffect(
AppNodeBuilder.build(
LayerNode.group([Database.node, Bus.node, Session.node, LocationServiceMap.node, LocationActivity.node]),
@@ -30,7 +46,9 @@ const it = testEffect(
LocationActivity.node.replace(
makeGlobalNode({
service: LocationActivity.Service,
layer: LocationActivity.layer({ timeToLive: "2 seconds", sweepInterval: "100 millis" }),
layer: LocationActivity.layer({ timeToLive, sweepInterval: "100 millis" }).pipe(
Layer.provide(Layer.succeed(Clock.Clock, activityClock)),
),
deps: [Bus.node, LocationServiceMap.node, SessionExecution.node, SessionStore.node],
}),
),
@@ -62,6 +80,8 @@ it.live(
}),
Effect.timeout("5 seconds"),
)
// The location has now been idle for its whole TTL; the next sweep evicts it.
idle.millis += Duration.toMillis(timeToLive)
// Real idle cleanup must end the long-lived request, not leave a second registry behind it.
expect(yield* Fiber.join(pending).pipe(Effect.timeout("10 seconds"))).toMatchObject({ type: "rpc.unavailable" })
@@ -525,12 +525,11 @@ describe("ConfigNormalize", () => {
experimental: {
portable_shell_scanner: true,
subagent_depth: 0,
location_idle_timeout: "2 hours",
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
},
}).encoded,
).toMatchObject({
experimental: { portable_shell_scanner: true, subagent_depth: 0, location_idle_timeout: "7200000 millis" },
experimental: { portable_shell_scanner: true, subagent_depth: 0 },
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
{ action: "provider.use", resource: "anthropic", effect: "allow" },
@@ -889,7 +889,15 @@ describe("ConfigProviderPlugin.Plugin", () => {
model.variants = [{ id: Model.VariantID.make("fast"), settings: { effort: "high" } }]
})
})
let transformed: Model.Info["limit"] | undefined
yield* models.transform((editor) => {
const model = editor.get(providerID, modelID)
if (!model) return
transformed = { ...model.limit }
model.limit = { context: 10, output: 1 }
})
yield* addPlugin(entries)
expect(transformed).toEqual({ context: 100, output: 75 })
const provider = required(yield* providers.get(providerID))
const model = required(yield* models.get(providerID, modelID))
+3 -1
View File
@@ -309,7 +309,9 @@ describe("config plugin reloads", () => {
(yield* integrations.get(Integration.ID.make("second"))) !== undefined &&
(yield* references.list()).some((reference) => reference.name === "second") &&
(yield* providers.get(Provider.ID.make("first"))) === undefined &&
(yield* providers.get(Provider.ID.make("second"))) !== undefined
(yield* providers.get(Provider.ID.make("second"))) !== undefined &&
(yield* skills.list()).every((skill) => skill.id !== "first") &&
(yield* skills.list()).some((skill) => skill.id === "second")
)
}),
)
@@ -19,6 +19,7 @@ import { Location } from "@opencode/core/location"
import { Plugin } from "@opencode/core/plugin"
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
import { AbsolutePath } from "@opencode/core/schema"
import { initRepo } from "../fixture/git"
import { location } from "../fixture/location"
import { tmpdir } from "../fixture/tmpdir"
import { testEffect } from "../lib/effect"
@@ -267,12 +268,7 @@ function withTmp<A, E, R>(
return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
}
if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
await $`git config user.email test@opencode.test`.cwd(tmp.path).quiet()
await $`git config user.name Test`.cwd(tmp.path).quiet()
await $`git commit --allow-empty -m root`.cwd(tmp.path).quiet()
await initRepo(tmp.path)
await options.init?.(tmp.path)
return { tmp, vcs: { type: "git" as const, store: AbsolutePath.make(path.join(tmp.path, ".git")) } }
}),
+18 -8
View File
@@ -10,13 +10,27 @@ import { tmpdir } from "./tmpdir"
const exec = promisify(execFile)
// Appended to the new repository's own config instead of running one `git config` process per key, which costs
// tens of milliseconds each on Windows. Repositories created by production code still have no identity.
const repositoryConfig = `[core]
fsmonitor = false
[commit]
gpgsign = false
[user]
email = test@opencode.test
name = Test
`
export async function initGit(directory: string, ...args: string[]) {
await $`git init ${args} ${directory}`.quiet()
await fs.appendFile(path.join(directory, ".git", "config"), repositoryConfig)
}
export async function gitRemote(root: string) {
const origin = path.join(root, "origin.git")
const source = path.join(root, "source")
await git(root, "init", "--bare", origin)
await git(root, "init", source)
await git(source, "config", "user.email", "test@example.com")
await git(source, "config", "user.name", "Test")
await initGit(source)
await fs.writeFile(path.join(source, "README.md"), "one\n")
await git(source, "add", "README.md")
await git(source, "commit", "-m", "initial")
@@ -48,11 +62,7 @@ export function read(file: string) {
}
export async function initRepo(directory: string) {
await $`git init`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
await initGit(directory)
await $`git commit --allow-empty -m root`.cwd(directory).quiet()
}
+10 -4
View File
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { Cause, Clock, Duration, Effect, Exit, Fiber, Layer, Scope, Stream } from "effect"
import { TestClock } from "effect/testing"
import { Credential } from "@opencode/core/credential"
@@ -8,6 +9,7 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Bus } from "@opencode/core/bus"
import { Integration } from "@opencode/core/integration"
import { State } from "@opencode/core/state"
import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
const it = testEffect(AppNodeBuilder.build(LayerNode.group([Integration.node, Credential.node, Bus.node])))
@@ -31,17 +33,18 @@ const failingIt = testEffect(
AppNodeBuilder.build(LayerNode.group([Integration.node, Bus.node]), [Credential.node.replace(failingCredentialNode)]),
)
// Bounded by elapsed time rather than attempts, so a slow child process start on a busy machine still fits.
function eventually<A, E, R>(
effect: Effect.Effect<A, E, R>,
predicate: (value: A) => boolean,
remaining = 1000,
deadline = Date.now() + 10_000,
): Effect.Effect<A, E | Error, R> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
if (remaining === 0) return yield* Effect.fail(new Error("Timed out waiting for value"))
if (Date.now() > deadline) return yield* Effect.fail(new Error("Timed out waiting for value"))
yield* Effect.promise(() => Bun.sleep(1))
return yield* eventually(effect, predicate, remaining - 1)
return yield* eventually(effect, predicate, deadline)
})
}
@@ -224,6 +227,8 @@ describe("Integration", () => {
const credentials = yield* Credential.Service
const integrationID = Integration.ID.make("company")
const methodID = Integration.MethodID.make("login")
// The command prints its secret only after the test has seen it pending, however slow the machine is.
const release = path.join((yield* tmpdirScoped()).path, "release")
yield* integrations.transform((editor) =>
editor.method.update({
integrationID,
@@ -234,7 +239,7 @@ describe("Integration", () => {
command: [
process.execPath,
"-e",
'console.error("https://example.com/login"); await Bun.sleep(50); console.log("secret")',
`console.error("https://example.com/login"); while (!(await Bun.file(${JSON.stringify(release)}).exists())) await Bun.sleep(10); console.log("secret")`,
],
},
}),
@@ -246,6 +251,7 @@ describe("Integration", () => {
(status) => status.status === "pending" && status.message?.includes("https://example.com/login") === true,
)
expect(pending).toMatchObject({ status: "pending", message: "https://example.com/login\n" })
yield* Effect.promise(() => Bun.write(release, ""))
expect(
yield* eventually(
+8 -132
View File
@@ -6,11 +6,9 @@ import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { Bus } from "@opencode/core/bus"
import { Config } from "@opencode/core/config"
import { Database } from "@opencode/core/database/database"
import { Form } from "@opencode/core/form"
import { Location } from "@opencode/core/location"
import { Permission } from "@opencode/core/permission"
import { LocationActivity } from "@opencode/core/location-activity"
import { LocationServiceMap, type LocationServices } from "@opencode/core/location-services"
import { Project } from "@opencode/core/project"
@@ -23,35 +21,19 @@ import { SessionRunner } from "@opencode/core/session/runner/index"
import { SessionTable } from "@opencode/core/session/sql"
import { SessionStore } from "@opencode/core/session/store"
import { Workspace } from "@opencode/core/workspace"
import { Document, type Entry, Info } from "@opencode/schema/config"
import { ConfigExperimental } from "@opencode/schema/config/experimental"
import { testEffect } from "./lib/effect"
// Keep real execution ownership, location caching, forms, and eviction. The fixture
// runner asks a question instead of making a model request, then keeps working
// after it is answered. Permission prompts come from `pendingPermissions` and
// config documents from `configEntries`.
const pendingPermissions: Permission.Request[] = []
const configEntries: Entry[] = []
// runner waits on a form instead of making a model request before asking a question.
const locations = Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const map = yield* LayerMap.make(
(ref: Location.Ref) =>
// The fixture only exercises these five Location services.
// The fixture only exercises these three Location services.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.mergeAll(
Layer.succeed(
Config.Service,
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
{ entries: () => Effect.sync(() => [...configEntries]) } as unknown as Config.Interface,
),
Layer.succeed(
Permission.Service,
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
{ list: () => Effect.sync(() => [...pendingPermissions]) } as unknown as Permission.Interface,
),
Layer.merge(
Layer.succeed(
Location.Service,
Location.Service.of({
@@ -74,7 +56,6 @@ const locations = Layer.effect(
})
.pipe(
Effect.orDie,
Effect.andThen(Effect.never),
Effect.as(SessionRunner.DrainResult.Complete()),
Effect.onInterrupt(() => Effect.sleep("5 minutes")),
),
@@ -129,7 +110,7 @@ describe("LocationActivity eviction", () => {
] as const) {
const newWork = admission !== "none"
it.effect(
`interrupts ${count} working executions before eviction (${admission} session admitted during cleanup)`,
`interrupts ${count} waiting executions before eviction (${admission} session admitted during cleanup)`,
() =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
@@ -192,15 +173,15 @@ describe("LocationActivity eviction", () => {
const forms = Context.get(context, Form.Service)
expect((yield* store.listSuspended()).toSorted()).toEqual(sessionIDs.toSorted())
yield* Location.Service.pipe(Effect.provide(map.get(idle)), Effect.scoped)
// Answered questions no longer hold the location; the executions keep working
// without producing durable activity.
yield* Effect.forEach(pending, (form) => forms.reply({ id: form.id, answer: { runtime: "bun" } }))
// Human input produces no durable activity while the question is pending.
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("62 minutes")
// Interruption has started, but slow cleanup still owns the graph.
// Interruption has cancelled each question, but slow cleanup still owns the graph.
expect(Array.from(yield* execution.active).toSorted()).toEqual(sessionIDs.toSorted())
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
expect(yield* forms.list()).toEqual([])
for (const form of pending) expect(yield* forms.state(form.id)).toEqual({ status: "cancelled" })
if (newWork) {
yield* execution.wake(newcomer)
@@ -238,109 +219,4 @@ describe("LocationActivity eviction", () => {
}),
)
}
for (const prompt of ["question", "permission"] as const) {
it.effect(`keeps a location alive while a ${prompt} is pending`, () =>
Effect.gen(function* () {
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const map = yield* LocationServiceMap.Service
const execution = yield* SessionExecution.Service
const sessionID = Session.ID.make(`ses_pending_${prompt}`)
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/project") })
yield* db
.insert(ProjectTable)
.values({ id: Project.ID.global, worktree: ref.directory, sandboxes: [] })
.run()
.pipe(Effect.orDie)
yield* db
.insert(SessionTable)
.values({
id: sessionID,
project_id: Project.ID.global,
slug: prompt,
directory: ref.directory,
title: `Pending ${prompt}`,
version: "test",
})
.run()
.pipe(Effect.orDie)
const created = yield* Deferred.make<Form.Info>()
const interrupted: SessionEvent.Execution.Interrupted["data"][] = []
const unsubscribe = yield* bus.listen((event) =>
Effect.gen(function* () {
if (event.type === SessionEvent.Execution.Interrupted.type) {
interrupted.push(Schema.decodeUnknownSync(SessionEvent.Execution.Interrupted.data)(event.data))
}
if (event.type !== Form.Event.Created.type) return
yield* Deferred.succeed(created, Schema.decodeUnknownSync(Form.Event.Created.data)(event.data).form)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const running = yield* execution.resume(sessionID).pipe(Effect.exit, Effect.forkScoped)
yield* Effect.addFinalizer(() =>
execution
.interrupt(sessionID)
.pipe(
Effect.andThen(TestClock.adjust("5 minutes")),
Effect.andThen(Effect.sync(() => (pendingPermissions.length = 0))),
),
)
const form = yield* Deferred.await(created)
const forms = Context.get(yield* map.contextEffect(ref).pipe(Effect.scoped), Form.Service)
if (prompt === "permission") {
yield* forms.reply({ id: form.id, answer: { runtime: "bun" } })
pendingPermissions.push({ id: Permission.ID.create(), sessionID, action: "bash", resources: ["*"] })
}
// Waiting on a human produces no durable activity, but must not count as idle.
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("3 hours")
expect(Array.from(yield* execution.active)).toEqual([sessionID])
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
expect(interrupted).toEqual([])
if (prompt === "question") expect(yield* forms.list()).toEqual([form])
// Once answered, the usual inactivity deadline applies again.
if (prompt === "question") yield* forms.reply({ id: form.id, answer: { runtime: "bun" } })
else pendingPermissions.length = 0
yield* TestClock.adjust("62 minutes")
yield* TestClock.adjust("5 minutes")
expect((yield* Fiber.join(running))._tag).toBe("Failure")
expect(interrupted).toEqual([{ sessionID, reason: "inactivity" }])
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
}),
)
}
for (const timeout of ["10 minutes", "Infinity"] as const) {
it.effect(`honors experimental.location_idle_timeout (${timeout})`, () =>
Effect.gen(function* () {
const map = yield* LocationServiceMap.Service
const ref = LocationServiceMap.canonical({ directory: AbsolutePath.make("/configured") })
yield* Effect.addFinalizer(() => Effect.sync(() => (configEntries.length = 0)))
configEntries.push(
new Document({
type: "document",
info: new Info({
experimental: new ConfigExperimental.Info({ location_idle_timeout: Duration.fromInputUnsafe(timeout) }),
}),
}),
)
yield* Location.Service.pipe(Effect.provide(map.get(ref)), Effect.scoped)
// The first sweep starts the location's clock.
yield* TestClock.adjust("1 minute")
yield* TestClock.adjust("9 minutes")
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
yield* TestClock.adjust("1 minute")
if (timeout === "10 minutes") {
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([])
return
}
yield* TestClock.adjust("3 hours")
expect(Array.from(yield* RcMap.keys(map.rcMap))).toEqual([ref])
}),
)
}
})
+13 -22
View File
@@ -2,7 +2,7 @@ import type { FileSystem } from "@opencode/core/filesystem"
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Event } from "@opencode/schema/config"
import { Config } from "@opencode/schema/config"
import { Money } from "@opencode/schema/money"
import {
Cause,
@@ -31,9 +31,6 @@ import { LocationServiceMap, type LocationServices } from "@opencode/core/locati
import { LocationActivity } from "@opencode/core/location-activity"
import { Location } from "@opencode/core/location"
import { LocationWatcher } from "@opencode/core/filesystem/location-watcher"
import { Config } from "@opencode/core/config"
import { Form } from "@opencode/core/form"
import { Permission } from "@opencode/core/permission"
import { Plugin } from "@opencode/core/plugin"
import { Model } from "@opencode/core/model"
import { Project } from "@opencode/core/project"
@@ -63,22 +60,14 @@ const activityLocations = Layer.effect(
LocationServiceMap.Service,
LayerMap.make(
(ref) =>
// LocationActivity reads config, questions, and permission prompts during its sweep.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.mergeAll(
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
}),
),
Config.testLayer(),
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.succeed(Form.Service, { list: () => Effect.succeed([]) } as unknown as Form.Interface),
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
Layer.succeed(Permission.Service, { list: () => Effect.succeed([]) } as unknown as Permission.Interface),
Layer.succeed(
Location.Service,
Location.Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory },
}),
) as unknown as Layer.Layer<LocationServices, FileSystem.DirectoryNotFoundError>,
{ idleTimeToLive: Duration.infinity },
),
@@ -377,19 +366,19 @@ describe("LocationServiceMap", () => {
const firstContext = yield* locations.contextEffect(firstRef)
const secondContext = yield* locations.contextEffect(secondRef)
const received = { first: 0, second: 0 }
yield* bus.subscribe(Event.Updated).pipe(
yield* bus.subscribe(Config.Event.Updated).pipe(
Stream.runForEach(() => Effect.sync(() => received.first++)),
Effect.provideContext(firstContext),
Effect.forkScoped({ startImmediately: true }),
)
yield* bus.subscribe(Event.Updated).pipe(
yield* bus.subscribe(Config.Event.Updated).pipe(
Stream.runForEach(() => Effect.sync(() => received.second++)),
Effect.provideContext(secondContext),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.sleep("10 millis")
yield* bus.publish(Event.Updated, {}, { location: firstRef })
yield* bus.publish(Config.Event.Updated, {}, { location: firstRef })
yield* Effect.sleep("10 millis")
expect(received).toEqual({ first: 1, second: 0 })
@@ -498,6 +487,7 @@ describe("LocationServiceMap", () => {
const blockedTools = blockedState.tools.map((tool) => tool.name)
expect(blockedTools.filter((name) => name !== "execute").sort()).toEqual([
"edit",
"genui_render",
"glob",
"grep",
"patch",
@@ -517,6 +507,7 @@ describe("LocationServiceMap", () => {
expect(blockedTools.includes("execute")).toBe(allowedTools.includes("execute"))
expect(allowedTools.filter((name) => name !== "execute").sort()).toEqual([
"edit",
"genui_render",
"glob",
"grep",
"patch",
@@ -28,7 +28,7 @@ import { advance } from "../lib/clock"
import { testEffect } from "../lib/effect"
// Real Location boot with plugin-directory discovery, so local plugin files are loaded and reloaded.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
// Package resolution can be held open so overlapping activations become observable.
const npm = {
+29 -21
View File
@@ -10,12 +10,19 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { AppProcess } from "@opencode/util/process"
import { testEffect } from "../lib/effect"
// Every test spawns a real child, so all of them use the live clock. The spawner's 1s post-exit capture deadline
// never fires under TestClock, and a child whose close event arrives late would hang until the test timeout.
const it = testEffect(LayerNode.compile(AppProcess.node))
const NODE = process.execPath
const MISSING_CWD = path.join(tmpdir(), `opencode-missing-cwd-${randomUUID()}`)
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
// The child registers its SIGTERM handler before it announces readiness, and publishes the pid with a rename, so a
// test that signals it right after reading "ready" never races the handler or reads a partly written file.
const settleOnSigterm = (ready: string, settled: string) =>
`const fs=require('fs');process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});fs.writeFileSync(${JSON.stringify(ready + ".tmp")},String(process.pid));fs.renameSync(${JSON.stringify(ready + ".tmp")},${JSON.stringify(ready)});setInterval(()=>{},60000)`
const waitForFile = (file: string) =>
Effect.promise(async () => {
while (true) {
@@ -31,7 +38,7 @@ const waitForFile = (file: string) =>
describe("AppProcess", () => {
describe("run", () => {
it.effect(
it.live(
"captures stdout and exit code zero",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -54,14 +61,15 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"captures stdout and stderr in emission order",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
const script = [
'process.stdout.write("out 1\\n")',
'setTimeout(() => process.stderr.write("err 1\\n"), 10)',
'setTimeout(() => process.stdout.write("out 2\\n"), 20)',
// Gaps wide enough that a busy test machine still reads each write before the next one.
'setTimeout(() => process.stderr.write("err 1\\n"), 100)',
'setTimeout(() => process.stdout.write("out 2\\n"), 200)',
].join(";")
const result = yield* svc.run(cmd("-e", script), { combineOutput: true })
expect(result.output?.toString("utf8")).toBe("out 1\nerr 1\nout 2\n")
@@ -70,7 +78,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"non-zero exit returns RunResult; caller can require success",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -79,7 +87,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"requireSuccess fails on non-zero exit",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -92,7 +100,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"requireSuccess succeeds on exit 0",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -101,7 +109,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"requireExitIn allowlists multiple exit codes",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -116,7 +124,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"truncates stdout when maxOutputBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -129,7 +137,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"truncates stderr when maxErrorBytes is set",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -142,7 +150,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"result includes command description",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -159,7 +167,7 @@ describe("AppProcess", () => {
(directory) => {
const ready = path.join(directory, "ready")
const settled = path.join(directory, "settled")
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
const script = settleOnSigterm(ready, settled)
return Effect.gen(function* () {
const svc = yield* AppProcess.Service
const exit = yield* Effect.exit(svc.run(cmd("-e", script), { timeout: "250 millis" }))
@@ -180,7 +188,7 @@ describe("AppProcess", () => {
(directory) => {
const ready = path.join(directory, "ready")
const settled = path.join(directory, "settled")
const script = `const fs=require('fs');fs.writeFileSync(${JSON.stringify(ready)},String(process.pid));process.on('SIGTERM',()=>{fs.writeFileSync(${JSON.stringify(settled)},'settled');process.exit(0)});setInterval(()=>{},60000)`
const script = settleOnSigterm(ready, settled)
return Effect.gen(function* () {
const svc = yield* AppProcess.Service
const fiber = yield* svc.run(cmd("-e", script)).pipe(Effect.forkChild)
@@ -197,7 +205,7 @@ describe("AppProcess", () => {
})
describe("inherited platform methods", () => {
it.effect(
it.live(
"string returns stdout as string",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -206,7 +214,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"lines returns the platform's array of lines",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -219,7 +227,7 @@ describe("AppProcess", () => {
describe("run with stdin option", () => {
const echoStdin = "process.stdin.on('data', c => process.stdout.write(c))"
it.effect(
it.live(
"feeds a string to stdin and returns it on stdout",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -229,7 +237,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"feeds a Uint8Array to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -240,7 +248,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"feeds a Stream of Uint8Array chunks to stdin",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -252,7 +260,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"completes correctly with empty input",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -262,7 +270,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"carries existing Command options like env",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
@@ -275,7 +283,7 @@ describe("AppProcess", () => {
}),
)
it.effect(
it.live(
"carries existing Command options like cwd",
Effect.gen(function* () {
const svc = yield* AppProcess.Service
+2 -5
View File
@@ -12,6 +12,7 @@ import { ProjectSchema } from "@opencode/core/project/schema"
import { ProjectTable } from "@opencode/core/project/sql"
import { AbsolutePath } from "@opencode/core/schema"
import { Hash } from "@opencode/util/hash"
import { initGit } from "./fixture/git"
import { tmpdir } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
@@ -326,11 +327,7 @@ function real(value: string) {
}
async function initRepo(dir: string, opts?: { commit?: boolean; remote?: string }) {
await $`git init`.cwd(dir).quiet()
await $`git config core.fsmonitor false`.cwd(dir).quiet()
await $`git config commit.gpgsign false`.cwd(dir).quiet()
await $`git config user.email test@opencode.test`.cwd(dir).quiet()
await $`git config user.name Test`.cwd(dir).quiet()
await initGit(dir)
if (opts?.commit) await $`git commit --allow-empty -m root`.cwd(dir).quiet()
if (opts?.remote) await $`git remote add origin ${opts.remote}`.cwd(dir).quiet()
}
+2 -1
View File
@@ -8,7 +8,8 @@ describe("Provider", () => {
const providers = path.join(import.meta.dir, "../../ai/src/providers")
const entrypoints = await Promise.all(
Array.from(new Bun.Glob("**/*.ts").scanSync(providers)).map(async (file) => {
const specifier = `@opencode/ai/providers/${file.replace(/\.ts$/, "")}`
// Bun.Glob yields native separators, and package specifiers always use "/".
const specifier = `@opencode/ai/providers/${file.replace(/\.ts$/, "").replaceAll(path.sep, "/")}`
const module = await import(specifier)
return typeof module.model === "function" ? specifier : undefined
}),
+1 -1
View File
@@ -16,7 +16,7 @@ import { testEffect } from "./lib/effect"
const it = testEffect(Layer.empty)
// Cold Git setup and cloning can exceed Bun's five-second default on Windows.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
describe("RepositoryCache", () => {
it.live("persists the daily throttle across cache recreation and serializes competing refreshes", () =>
@@ -18,7 +18,7 @@ import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Real Location boot with plugin discovery, so the first request races the initial plugin activation.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
const it = testEffect(
AppNodeBuilder.build(
+3 -8
View File
@@ -36,6 +36,7 @@ import { Workspace } from "@opencode/core/workspace"
import { Expected } from "./lib/session-message"
import { testEffect } from "./lib/effect"
import { LocationServiceMap } from "@opencode/core/location-service-map"
import { initGit } from "./fixture/git"
import { offlineModels } from "./fixture/models"
import { promptLocationNode } from "./fixture/prompt-location"
import { globalProjectNode } from "./lib/project"
@@ -158,11 +159,7 @@ describe("Session.create", () => {
.pipe(Effect.orDie)
if (!before) return yield* Effect.die(new Error("created session not found"))
yield* Effect.promise(async () => {
await $`git init -q`.cwd(directory)
await $`git config user.email test@example.com`.cwd(directory)
await $`git config user.name Test`.cwd(directory)
})
yield* Effect.promise(() => initGit(directory))
const unborn = yield* projects.resolve(ref.directory)
const unbornRoot = yield* session.get(created.id)
const unbornNested = yield* session.get(child.id)
@@ -269,9 +266,7 @@ describe("Session.create", () => {
yield* Effect.promise(async () => {
for (const root of [nested, outer]) {
await $`git init -q`.cwd(root)
await $`git config user.email test@example.com`.cwd(root)
await $`git config user.name Test`.cwd(root)
await initGit(root)
await $`git commit --allow-empty -qm initial`.cwd(root)
}
})
@@ -506,26 +506,55 @@ test("authoritative end values replace accumulated deltas in the durable ended e
})
})
test("tool input deltas are accumulated without being published", async () => {
const { published, publisher } = capture()
await Effect.runPromise(
Effect.forEach(
it.effect("batches tool input deltas and flushes pending input before the terminal event", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
const deltas = () =>
published.filter((event) => event.type === "session.tool.input.delta").map((event) => event.data)
yield* Effect.forEach(
[
LLMEvent.toolInputStart({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file.txt"}' }),
LLMEvent.toolInputEnd({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '"file' }),
],
publisher.publish,
{ discard: true },
),
)
)
expect(published.some((event) => event.type === "session.tool.input.delta")).toBe(false)
expect(published.find((event) => event.type === "session.tool.input.ended.1")?.data).toMatchObject({
text: '{"path":"file.txt"}',
})
})
expect(deltas()).toHaveLength(0)
yield* TestClock.adjust("100 millis")
expect(deltas()).toMatchObject([{ id: "call", delta: '{"path":"file' }])
yield* publisher.publish(LLMEvent.toolInputDelta({ id: "call", name: "read", text: '.txt"}' }))
yield* publisher.publish(LLMEvent.toolInputEnd({ id: "call", name: "read" }))
expect(published.slice(-2)).toMatchObject([
{ type: "session.tool.input.delta", data: { delta: '.txt"}' } },
{ type: "session.tool.input.ended.1", data: { text: '{"path":"file.txt"}' } },
])
}),
)
it.effect("publishes held tool input before the next block starts", () =>
Effect.gen(function* () {
const { published, publisher } = capture()
yield* Effect.forEach(
[
LLMEvent.toolInputStart({ id: "call", name: "read" }),
LLMEvent.toolInputDelta({ id: "call", name: "read", text: '{"path":' }),
LLMEvent.textStart({ id: "text" }),
],
publisher.publish,
{ discard: true },
)
expect(published.map((event) => event.type).filter((type) => type !== "session.step.started.1")).toEqual([
"session.tool.input.started.1",
"session.tool.input.delta",
"session.text.started.1",
])
yield* publisher.flush()
}),
)
test("provider-executed tool metadata is flattened using the route key", async () => {
const { published, publisher } = capture("openai")
+1 -1
View File
@@ -19,7 +19,7 @@ import { tmpdirScoped } from "./fixture/tmpdir"
import { testEffect } from "./lib/effect"
// Every test boots a real Location, so shell start waits for cold plugin activation before spawning.
setDefaultTimeout(15_000)
setDefaultTimeout(30_000)
class ExecutionControl extends Context.Service<
ExecutionControl,
+3 -10
View File
@@ -17,6 +17,7 @@ import { VcsGitPlugin } from "@opencode/core/plugin/vcs/git"
import type { VcsDefinition, VcsDiffInput } from "@opencode/plugin/effect/vcs"
import { FileSystem } from "@opencode/schema/filesystem"
import { VcsEvent } from "@opencode/schema/vcs-event"
import { initGit } from "./fixture/git"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it, testEffect } from "./lib/effect"
@@ -61,7 +62,7 @@ const withTmp = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>, input: { scope?: string } = {}) =>
withTmp((directory) =>
Effect.promise(async () => {
await initRepo(directory)
await initGit(directory, "-b", "main")
if (input.scope) await fs.mkdir(path.join(directory, input.scope), { recursive: true })
}).pipe(
Effect.andThen(
@@ -78,14 +79,6 @@ const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>, inpu
),
)
async function initRepo(directory: string) {
await $`git init -b main`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
await $`git config commit.gpgsign false`.cwd(directory).quiet()
await $`git config user.email test@opencode.test`.cwd(directory).quiet()
await $`git config user.name Test`.cwd(directory).quiet()
}
async function commitAll(directory: string, message: string) {
await $`git add -A`.cwd(directory).quiet()
await $`git commit -m ${message}`.cwd(directory).quiet()
@@ -586,7 +579,7 @@ describe("Vcs", () => {
Effect.gen(function* () {
const count = 40
yield* Effect.promise(async () => {
await initRepo(directory)
await initGit(directory, "-b", "main")
await fs.writeFile(path.join(directory, "keep.txt"), "one\n")
await fs.writeFile(path.join(directory, ".gitignore"), "*.log\n")
await commitAll(directory, "initial")
+7
View File
@@ -40,6 +40,13 @@ CLI preparation uses these channel rules:
default. `bun dev --download-server <version>` instead downloads that CLI version for local development. Neither path
requires `OPENCODE_CLI_DIST` or runs the production prebuild.
## Beta channel
Every push to the `beta` branch publishes OpenCode Beta (`ai.opencode.desktop.beta`) as version `0.0.0-beta-<run>` to
the `beta` update channel. Beta publishes only the desktop app and `beta.opencode.ai`; the CLI and SDK packages ship
from `dev`. Install it from `https://opencode.ai/download/beta/<platform>`, for example `darwin-aarch64-dmg` or
`windows-x64-nsis`. Installed builds update in place on every platform.
## Startup benchmark
`bun run bench:startup` measures a **packaged** build from process spawn to the restored tab being ready and the
+6 -2
View File
@@ -27,6 +27,10 @@ const legacyDesktopEntryFpm = `${legacyDesktopEntry}=/usr/share/applications/ope
const metainfoFpm = (appId: string) =>
`${path.join(packageDir, "resources", `${appId}.metainfo.xml`)}=/usr/share/metainfo/${appId}.metainfo.xml`
// Beta packages every commit for the team, so it trades about 5% (deb) and 17% (rpm) larger files for xz -1, which
// compresses the payload about 3x faster than the deb default (-3) and 5x faster than the rpm default (-9).
const betaCompressionFpm = (target: "deb" | "rpm") => [`--${target}-compression-level`, "1"]
async function signWindows(configuration: { path: string }) {
if (process.platform !== "win32") return
@@ -238,8 +242,8 @@ export function getConfig() {
url: "https://opencode.ai/update/api/beta/desktop/opencode/",
channel: "latest",
},
deb: { fpm: [metainfoFpm(appId)] },
rpm: { packageName: "opencode-beta", fpm: [metainfoFpm(appId)] },
deb: { fpm: [...betaCompressionFpm("deb"), metainfoFpm(appId)] },
rpm: { packageName: "opencode-beta", fpm: [...betaCompressionFpm("rpm"), metainfoFpm(appId)] },
}
}
@@ -4,3 +4,174 @@
nsExec::ExecToLog '"$SYSDIR\icacls.exe" "$INSTDIR" /grant "*S-1-15-2-2:(OI)(CI)(RX)"'
Pop $0
!macroend
!macro customUnInstall
${IfNot} ${isUpdated}
!insertmacro removeStagedUpdates $INSTDIR
${EndIf}
!macroend
; The stock check starts Windows PowerShell up to five times per installer and again in the old
; uninstaller, about 1.2 s each. This walks the process list in-process instead and keeps the stock
; behaviour: stop every process whose image lives under $INSTDIR. An update gives the quitting app
; 10 s to exit on its own; a manual install asks first, then stops it at once.
!macro customCheckAppRunning
System::Call "kernel32::GetCurrentProcessId() i .R9"
; $R8 is the tick count after which processes still running are terminated.
System::Call "kernel32::GetTickCount() i .R8"
IntOp $R8 $R8 + 10000
StrCpy $0 ""
${Do}
System::Call "kernel32::GetTickCount() i .r1"
IntOp $1 $1 - $R8
StrCpy $R7 0
System::Call "kernel32::CreateToolhelp32Snapshot(i 2, i 0) p .R0"
${If} $R0 <> -1
; PROCESSENTRY32W for the 32-bit installer: nine 4-byte fields, then WCHAR szExeFile[260].
System::Alloc 556
Pop $R1
System::Call "*$R1(i 556)"
System::Call "kernel32::Process32FirstW(p R0, p R1) i .R2"
${DoWhile} $R2 != 0
System::Call "*$R1(i, i, i .R3)"
${If} $R3 != $R9
System::Call "kernel32::OpenProcess(i 0x1000, i 0, i R3) p .R4"
${If} $R4 != 0
System::Call "kernel32::QueryFullProcessImageNameW(p R4, i 0, w .R5, *i ${NSIS_MAX_STRLEN}) i .R6"
System::Call "kernel32::CloseHandle(p R4)"
${If} $R6 != 0
StrLen $R6 "$INSTDIR\"
StrCpy $R5 $R5 $R6
${If} $R5 == "$INSTDIR\"
IntOp $R7 $R7 + 1
${If} $1 >= 0
System::Call "kernel32::OpenProcess(i 1, i 0, i R3) p .R4"
${If} $R4 != 0
System::Call "kernel32::TerminateProcess(p R4, i 1)"
System::Call "kernel32::CloseHandle(p R4)"
${EndIf}
${EndIf}
${EndIf}
${EndIf}
${EndIf}
${EndIf}
System::Call "kernel32::Process32NextW(p R0, p R1) i .R2"
${Loop}
System::Free $R1
System::Call "kernel32::CloseHandle(p R0)"
${EndIf}
${If} $R7 == 0
${ExitDo}
${EndIf}
${If} $0 == ""
StrCpy $0 "running"
${IfNot} ${isUpdated}
${If} ${Cmd} `MessageBox MB_OKCANCEL|MB_ICONEXCLAMATION "$(appRunning)" /SD IDOK IDCANCEL`
Quit
${EndIf}
System::Call "kernel32::GetTickCount() i .R8"
${EndIf}
DetailPrint "$(appClosing)"
${ElseIf} $1 >= 2000
${If} ${Cmd} `MessageBox MB_RETRYCANCEL|MB_ICONEXCLAMATION "$(appCannotBeClosed)" /SD IDCANCEL IDCANCEL`
Quit
${EndIf}
System::Call "kernel32::GetTickCount() i .R8"
${EndIf}
Sleep 100
${Loop}
!macroend
; Staged updates. Once an update has downloaded, the running app starts its installer with --stage.
; That unpacks the app next to the install directory, as "<install dir>.update-<payload hash>",
; while the user keeps working. After the restart, the install renames that directory into place
; instead of decompressing 700 MB. Anything unexpected falls back to the normal extraction.
!macro removeStagedUpdates DIR
RMDir /r "${DIR}.staging"
${StdUtils.GetParentPath} $R8 "${DIR}"
FindFirst $R9 $R7 "${DIR}.update-*"
${DoWhile} $R7 != ""
RMDir /r "$R8\$R7"
FindNext $R9 $R7
${Loop}
FindClose $R9
!macroend
!ifndef BUILD_UNINSTALLER
!ifdef APP_64 | APP_ARM64
!ifdef APP_64 & APP_ARM64
!error "Staged updates expect one architecture per installer"
!endif
!ifdef APP_ARM64
!define STAGED_PACKAGE "${APP_ARM64}"
!define STAGED_PACKAGE_HASH "${APP_ARM64_HASH}"
!define STAGED_PACKAGE_SIZE "${APP_ARM64_UNPACKED_SIZE}"
!else
!define STAGED_PACKAGE "${APP_64}"
!define STAGED_PACKAGE_HASH "${APP_64_HASH}"
!define STAGED_PACKAGE_SIZE "${APP_64_UNPACKED_SIZE}"
!endif
; Runs first in .onInit, before the single-instance check, and exits 0 only when it staged.
!macro preInit
${StdUtils.TestParameter} $R0 "stage"
${If} $R0 == "true"
SetErrorLevel 2
ReadRegStr $R1 HKCU "${INSTALL_REGISTRY_KEY}" InstallLocation
; Leave room for the staged copy and as much again, in MB.
${DriveSpace} "$R1" "/D=F /S=M" $R2
IntOp $R3 ${STAGED_PACKAGE_SIZE} / 512
${If} $R1 != ""
${AndIf} ${FileExists} "$R1\${APP_EXECUTABLE_FILENAME}"
${AndIf} $R2 > $R3
!insertmacro removeStagedUpdates $R1
InitPluginsDir
; Stored like the template's copy, so the installer keeps one copy of the payload.
!ifdef COMPRESS
SetCompress off
!endif
File /oname=$PLUGINSDIR\staged.7z "${STAGED_PACKAGE}"
!ifdef COMPRESS
SetCompress "${COMPRESS}"
!endif
ClearErrors
SetOutPath "$R1.staging"
${IfNot} ${Errors}
Nsis7z::Extract "$PLUGINSDIR\staged.7z"
SetOutPath $PLUGINSDIR
StrCpy $R4 "${STAGED_PACKAGE_HASH}" 16
ClearErrors
Rename "$R1.staging" "$R1.update-$R4"
${IfNot} ${Errors}
SetErrorLevel 0
${EndIf}
${EndIf}
${EndIf}
Quit
${EndIf}
!macroend
; Called by the patched app-builder-lib template in place of extraction. Sets $R0 to "installed"
; when it moved the staged app into the empty install directory.
!macro customInstallStagedApp
StrCpy $R0 ""
StrCpy $R1 "${STAGED_PACKAGE_HASH}" 16
${If} ${FileExists} "$INSTDIR.update-$R1\${APP_EXECUTABLE_FILENAME}"
SetOutPath $PLUGINSDIR
; Removes the directory only while it is empty, so the rename never mixes two versions.
RMDir $INSTDIR
ClearErrors
Rename "$INSTDIR.update-$R1" $INSTDIR
${IfNot} ${Errors}
StrCpy $R0 "installed"
${EndIf}
SetOutPath $INSTDIR
${EndIf}
!insertmacro removeStagedUpdates $INSTDIR
!macroend
!endif
!endif
+35 -11
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bun
import { Script } from "@opencode/script"
import { Schema } from "effect"
import { UpdateArtifact } from "../../../script/update-artifact"
const dryRun = process.argv.includes("--dry-run")
@@ -10,19 +11,14 @@ if (!Script.release) {
process.exit(0)
}
const directory = process.env.OPENCODE_DESKTOP_DIST
const UploadedFiles = Schema.fromJsonString(
Schema.Record(Schema.String, Schema.Struct({ url: Schema.String, sha256: Schema.String, size: Schema.Number })),
)
if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required")
// Beta build jobs upload their own platform's files (scripts/upload.ts) and pass only the returned file lists here.
const uploadedDirectory = process.env.OPENCODE_DESKTOP_UPLOADED
const files = (
await Array.fromAsync(
new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }),
)
).sort()
if (!files.length) throw new Error("No desktop release files found")
const uploaded = await UpdateArtifact.upload({ version: Script.version, files, dryRun })
const uploaded = uploadedDirectory ? await uploadedByBuildJobs(uploadedDirectory) : await uploadFiles()
const artifact = {
channel: Script.channel,
@@ -36,6 +32,34 @@ if (dryRun) console.log(`dry-run artifact: ${JSON.stringify(artifact)}`)
if (!dryRun) await UpdateArtifact.publish(artifact)
async function uploadFiles() {
const directory = process.env.OPENCODE_DESKTOP_DIST
if (!directory) throw new Error("OPENCODE_DESKTOP_DIST is required")
const files = (
await Array.fromAsync(
new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({ cwd: directory, absolute: true }),
)
).sort()
if (!files.length) throw new Error("No desktop release files found")
return UpdateArtifact.upload({ version: Script.version, files, dryRun })
}
async function uploadedByBuildJobs(directory: string) {
const lists = await Array.fromAsync(new Bun.Glob("*.json").scan({ cwd: directory, absolute: true }))
if (!lists.length) throw new Error(`No uploaded desktop file lists found in ${directory}`)
const entries = await Promise.all(
lists.map(async (file) => Object.entries(Schema.decodeUnknownSync(UploadedFiles)(await Bun.file(file).text()))),
)
return Object.fromEntries(entries.flat())
}
type DesktopFile = {
url: string
sha512: string
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env bun
// Uploads one platform's desktop release files to R2 from its build job and writes the returned file list, which
// publish.ts reads through OPENCODE_DESKTOP_UPLOADED instead of uploading every platform itself.
// Usage: bun scripts/upload.ts <dist directory> <output json>
import path from "node:path"
import { UpdateArtifact } from "../../../script/update-artifact"
const [directory, output] = process.argv.slice(2)
if (!directory || !output) throw new Error("Usage: bun scripts/upload.ts <dist directory> <output json>")
const version = process.env.OPENCODE_VERSION
if (!version) throw new Error("OPENCODE_VERSION is required")
const files = (
await Array.fromAsync(
new Bun.Glob("*.{exe,blockmap,dmg,zip,AppImage,deb,rpm,app.tar.gz}").scan({
cwd: path.resolve(directory),
absolute: true,
}),
)
).sort()
if (!files.length) throw new Error(`No desktop release files found in ${directory}`)
await Bun.write(output, JSON.stringify(await UpdateArtifact.upload({ version, files }), null, 2))
+2 -1
View File
@@ -131,7 +131,8 @@ async function prepareCli(dest: string) {
if (process.platform !== "win32") await chmod(dest, 0o755)
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") {
// electron-builder signs the bundled CLI again in Package, so beta signs it only there.
if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true" && resolveChannel() !== "beta") {
await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}`
}
@@ -1,36 +1,89 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { copyFile, mkdir, mkdtemp, readdir, rm } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
test.skipIf(process.platform !== "win32")(
const windows = test.skipIf(process.platform !== "win32")
// Matches the 16 hex characters the staged directory name takes from APP_64_HASH.
const payloadHash = "0123456789ABCDEF0123456789ABCDEF"
async function harness() {
const { getMakeNsisPath, getNsisPluginsPath } = await import("app-builder-lib/out/toolsets/windows")
const { nsisTemplatesDir } = await import("app-builder-lib/out/targets/nsis/nsisUtil")
const { NsisScriptGenerator } = await import("app-builder-lib/out/targets/nsis/nsisScriptGenerator")
const config = (await import("../electron-builder.config")).default
const compiler = await getMakeNsisPath(config.toolsets?.nsis, config.nsis?.customNsisBinary)
const plugins = await getNsisPluginsPath(config.toolsets?.nsis, config.nsis?.customNsisResources)
const dir = await mkdtemp(path.join(os.tmpdir(), "opencode-installer-"))
// electron-builder generates the command-line flags such as ${isUpdated}.
const flags = new NsisScriptGenerator()
flags.flags(["updated"])
const run = async (cmd: string[], env: Record<string, string> = {}) => {
const result = Bun.spawn(cmd, { env: { ...process.env, ...compiler.env, ...env }, stdout: "pipe", stderr: "pipe" })
const [stdout, stderr, code] = await Promise.all([
new Response(result.stdout).text(),
new Response(result.stderr).text(),
result.exited,
])
return { stdout: stdout.trim(), stderr, code }
}
// Compiles the custom include with the template's flags and messages around one section body.
const compile = async (name: string, section: string) => {
const script = path.join(dir, `${name}.nsi`)
const exe = path.join(dir, `${name}.exe`)
await Bun.write(
script,
`Unicode true
RequestExecutionLevel user
SilentInstall silent
OutFile "${exe}"
!addplugindir /x86-unicode "${path.join(plugins, "x86-unicode")}"
!addincludedir "${path.join(nsisTemplatesDir, "include")}"
!include LogicLib.nsh
!include StdUtils.nsh
${flags.build()}
LoadLanguageFile "\${NSISDIR}\\Contrib\\Language files\\English.nlf"
LangString appRunning \${LANG_ENGLISH} "running"
LangString appClosing \${LANG_ENGLISH} "closing"
LangString appCannotBeClosed \${LANG_ENGLISH} "cannot close"
!define APP_EXECUTABLE_FILENAME "OpenCode.exe"
!define APP_64 "${path.join(dir, "app.7z")}"
!define APP_64_HASH "${payloadHash}"
!define APP_64_UNPACKED_SIZE 1
!include "${config.nsis?.include}"
Section
${section}
SectionEnd
`,
)
const result = await run([compiler.path, script])
expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0)
return exe
}
return { dir, run, compile, cleanup: () => rm(dir, { recursive: true, force: true }) }
}
windows(
"installer grants sandbox access without changing unrelated permissions",
async () => {
const { getMakeNsisPath } = await import("app-builder-lib/out/toolsets/windows")
const config = (await import("../electron-builder.config")).default
const compiler = await getMakeNsisPath(config.toolsets?.nsis, config.nsis?.customNsisBinary)
const dir = await mkdtemp(path.join(os.tmpdir(), "opencode-installer-"))
const installer = await harness()
const run = async (cmd: string[]) => {
const result = Bun.spawn(cmd, {
env: { ...process.env, ...compiler.env, OPENCODE_INSTALLER_TEST_ROOT: dir },
stdout: "pipe",
stderr: "pipe",
})
const succeed = async (cmd: string[]) => {
const result = await installer.run(cmd, { OPENCODE_INSTALLER_TEST_ROOT: installer.dir })
expect(result.code, `${result.stdout}\n${result.stderr}`).toBe(0)
const [stdout, stderr, code] = await Promise.all([
new Response(result.stdout).text(),
new Response(result.stderr).text(),
result.exited,
])
expect(code, `${stdout}\n${stderr}`).toBe(0)
return stdout.trim()
return result.stdout
}
const acl = (file: string) =>
run([
succeed([
"pwsh",
"-NoProfile",
"-NonInteractive",
@@ -39,35 +92,158 @@ test.skipIf(process.platform !== "win32")(
])
try {
await Bun.write(path.join(dir, "app/runtime/icudtl.dat"), "runtime fixture")
await Bun.write(path.join(dir, "unrelated.txt"), "outside the installation")
await run(["icacls.exe", path.join(dir, "app"), "/grant", "*S-1-15-2-999-999-999:(OI)(CI)(F)"])
await Bun.write(path.join(installer.dir, "app/runtime/icudtl.dat"), "runtime fixture")
await Bun.write(path.join(installer.dir, "unrelated.txt"), "outside the installation")
await succeed(["icacls.exe", path.join(installer.dir, "app"), "/grant", "*S-1-15-2-999-999-999:(OI)(CI)(F)"])
const parent = await acl(".")
const unrelated = await acl("unrelated.txt")
await Bun.write(
path.join(dir, "installer.nsi"),
`Unicode true
RequestExecutionLevel user
SilentInstall silent
OutFile "${path.join(dir, "installer.exe")}"
!include "${config.nsis?.include}"
Section
StrCpy $INSTDIR "$EXEDIR\\app"
!insertmacro customInstall
SectionEnd
`,
const exe = await installer.compile(
"acl",
` StrCpy $INSTDIR "$EXEDIR\\app"
!insertmacro customInstall`,
)
await run([compiler.path, path.join(dir, "installer.nsi")])
await run([path.join(dir, "installer.exe")])
await succeed([exe])
const installed = await acl("app/runtime/icudtl.dat")
expect(installed).toContain("(A;ID;0x1200a9;;;S-1-15-2-2)")
expect(installed).toContain("S-1-15-2-999-999-999")
await run([path.join(dir, "installer.exe")])
await succeed([exe])
expect(await acl("app/runtime/icudtl.dat")).toBe(installed)
expect(await acl(".")).toBe(parent)
expect(await acl("unrelated.txt")).toBe(unrelated)
} finally {
await rm(dir, { recursive: true, force: true })
await installer.cleanup()
}
},
120_000,
)
// The running app unpacks an update beside the install directory before the restart. The install takes it
// only when it belongs to this payload and the old version is gone, and never leaves a staged copy behind.
const stagedCases = [
{
name: "moves the staged app into the emptied install directory",
installed: [],
staged: { [`update-${payloadHash.slice(0, 16)}`]: ["OpenCode.exe", "resources/app.asar"] },
moved: true,
expected: ["OpenCode.exe", "resources/app.asar"],
},
{
name: "keeps an install directory that still holds files",
installed: ["locked.dll"],
staged: { [`update-${payloadHash.slice(0, 16)}`]: ["OpenCode.exe"] },
moved: false,
expected: ["locked.dll"],
},
{
name: "discards an update staged from another payload",
installed: [],
staged: { "update-FEDCBA9876543210": ["OpenCode.exe"] },
moved: false,
expected: [],
},
{
name: "discards staging that never finished",
installed: [],
staged: { staging: ["OpenCode.exe"] },
moved: false,
expected: [],
},
]
windows(
"installs a staged update only into an emptied install directory",
async () => {
const installer = await harness()
try {
const exe = await installer.compile(
"staged",
` InitPluginsDir
SetOutPath $INSTDIR
!insertmacro customInstallStagedApp
\${If} $R0 == "installed"
SetErrorLevel 0
\${Else}
SetErrorLevel 3
\${EndIf}`,
)
for (const item of stagedCases) {
const root = path.join(installer.dir, item.name.replaceAll(" ", "-"))
const install = path.join(root, "app")
await mkdir(install, { recursive: true })
await Promise.all([
...item.installed.map((file) => Bun.write(path.join(install, file), "old")),
...Object.entries(item.staged).flatMap(([suffix, files]) =>
files.map((file) => Bun.write(path.join(`${install}.${suffix}`, file), "new")),
),
])
const result = await installer.run([exe, `/D=${install}`])
const files = await readdir(install, { recursive: true, withFileTypes: true })
expect(result.code, item.name).toBe(item.moved ? 0 : 3)
expect(
files
.filter((entry) => entry.isFile())
.map((entry) => path.relative(install, path.join(entry.parentPath, entry.name)).replaceAll("\\", "/"))
.toSorted(),
item.name,
).toEqual(item.expected)
expect(await readdir(root), item.name).toEqual(["app"])
}
} finally {
await installer.cleanup()
}
},
120_000,
)
// Stand-ins for the app are copies of ping.exe: it exits with 0 when it finishes and 1 when terminated.
const runningCases = [
{ name: "a manual install stops the app at once", args: [], pings: 30, exitCode: 1 },
{ name: "an update lets the quitting app exit by itself", args: ["--updated"], pings: 2, exitCode: 0 },
]
windows(
"stops only the processes that run from the install directory",
async () => {
const installer = await harness()
const ping = path.join(process.env.SystemRoot ?? "C:\\Windows", "System32", "PING.EXE")
try {
const exe = await installer.compile("running", " !insertmacro customCheckAppRunning")
for (const item of runningCases) {
const root = path.join(installer.dir, item.name.replaceAll(" ", "-"))
const install = path.join(root, "app")
// Shares the install directory's name as a prefix, so a match without the separator would stop it.
const sibling = path.join(root, "app-sibling")
await mkdir(path.join(install, "resources"), { recursive: true })
await mkdir(sibling, { recursive: true })
await copyFile(ping, path.join(install, "resources", "standin.exe"))
await copyFile(ping, path.join(sibling, "standin.exe"))
const app = Bun.spawn([path.join(install, "resources", "standin.exe"), "-n", String(item.pings), "127.0.0.1"], {
stdout: "ignore",
})
const other = Bun.spawn([path.join(sibling, "standin.exe"), "-n", "30", "127.0.0.1"], { stdout: "ignore" })
try {
expect((await installer.run([exe, ...item.args, `/D=${install}`])).code, item.name).toBe(0)
expect(other.exitCode, item.name).toBeNull()
expect(await app.exited, item.name).toBe(item.exitCode)
} finally {
app.kill()
other.kill()
await Promise.all([app.exited, other.exited])
}
}
} finally {
await installer.cleanup()
}
},
120_000,
@@ -606,6 +606,19 @@ function ArtifactVideo(props: MediaProps) {
playsinline
onError={() => props.onError()}
src={url()}
onClick={(event) => {
// Chromium's controls answer the second click of a double click with a native fullscreen request, which
// covers the theatre the double click opens. Opting out with controlslist="nofullscreen" would also end
// their click-to-play. The fullscreen element ready check refuses an open popover, so the video is one
// while its controls handle this click, and is released before the next frame renders.
const el = event.currentTarget
// In native fullscreen, the controls' own exit must run; showPopover would throw there.
if (event.detail < 2 || document.fullscreenElement === el) return
el.setAttribute("popover", "manual")
el.showPopover?.()
requestAnimationFrame(() => el.removeAttribute("popover"))
}}
onDblClick={toggle}
onPlay={() => setPaused(false)}
onPause={() => setPaused(true)}
+1 -1
View File
@@ -196,7 +196,7 @@ export default function SessionFileList(props: {
</Show>
<span class="text-12-medium text-text-base shrink-0">{filename()}</span>
</span>
<Show when={kind()}>
<Show when={kind()} fallback={<span data-slot="file-tree-v2-end" />}>
{(value) => (
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
{kindLabel(value())}
+1 -1
View File
@@ -109,7 +109,7 @@ const FileTreeNodeV2 = (
{(() => {
const value = kind()
if (!value || local.node.type !== "file") return null
if (!value || local.node.type !== "file") return <span data-slot="file-tree-v2-end" />
return (
<span data-slot="file-tree-v2-change" data-change={kindChange(value)}>
+8 -6
View File
@@ -448,14 +448,14 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
},
)
const renderFile = (source: string) => (
const renderFile = () => (
<div class="relative overflow-hidden pb-40">
<Dynamic
component={fileComponent}
mode="text"
file={{
name: path() ?? "",
contents: source,
contents: contents(),
cacheKey: cacheKey(),
}}
enableLineSelection
@@ -493,9 +493,11 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
)
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
const codeView = (source: string) => (
// It reads the text itself: `Show` tracks its fallback, so passing the content in would rebuild the view, scrolled to
// the top, each time a reread stores a new content object.
const codeView = () => (
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
{renderFile(source)}
{renderFile()}
</ScrollView>
)
@@ -504,13 +506,13 @@ export function SessionFileView(props: { session: MountedSession; screen: Sessio
<Switch>
<Match when={current()?.loaded ? current()?.content : undefined}>
{(value) => (
<Show when={artifact()} fallback={codeView(value().content)}>
<Show when={artifact()} fallback={codeView()}>
<ArtifactView
session={props.session}
path={path() ?? ""}
content={value()}
cacheKey={cacheKey()}
source={codeView(value().content)}
source={codeView()}
/>
</Show>
)}
@@ -266,7 +266,7 @@ export default function TerminalPanel(props: { model: TerminalModel; session: Mo
}}
>
<div class="flex flex-col h-full">
<div class="h-[52px] shrink-0 flex border-b border-border-weaker-base">
<div class="h-[49px] shrink-0 flex border-b border-border-weaker-base">
<Tabs
variant="panel"
value={terminal().active()}
+1 -1
View File
@@ -105,7 +105,7 @@ const setup: MainSetup<typeof definition> = async (ctx) => {
after: "about",
enabled: () => enabled,
run(window) {
// Beta builds check in the focused window, which can offer the stable installer.
// Beta builds check in the focused window, which reports the result in-app.
if (build.channel !== "beta") return void runFork(show)
if (window) provider.emit("check", null, window.id)
@@ -1,25 +0,0 @@
import { expect, test } from "bun:test"
import { stableMacDownload } from "./migration"
test("selects the signed stable installer for the current Mac architecture", () => {
const artifact = {
version: "2.0.2",
metadata: {
files: {
"opencode-desktop-mac-arm64.dmg": { url: "https://files.test/OpenCode-arm64.dmg" },
"opencode-desktop-mac-x64.dmg": { url: "https://files.test/OpenCode-x64.dmg" },
},
},
}
expect(stableMacDownload(artifact, "arm64")).toEqual({
version: "2.0.2",
url: "https://files.test/OpenCode-arm64.dmg",
})
expect(stableMacDownload(artifact, "x64")).toEqual({
version: "2.0.2",
url: "https://files.test/OpenCode-x64.dmg",
})
expect(stableMacDownload(artifact, "ia32")).toBeUndefined()
expect(stableMacDownload({ version: "2.0.2", metadata: { files: {} } }, "arm64")).toBeUndefined()
})
@@ -1,22 +0,0 @@
import { Option, Schema } from "effect"
const Artifact = Schema.Struct({
version: Schema.String,
metadata: Schema.Struct({
files: Schema.Record(Schema.String, Schema.Struct({ url: Schema.String })),
}),
})
const decode = Schema.decodeUnknownOption(Artifact)
export function requiresStableMacInstaller(platform: string, channel: string) {
return platform === "darwin" && channel === "beta"
}
export function stableMacDownload(input: unknown, arch: string) {
if (arch !== "arm64" && arch !== "x64") return undefined
const artifact = Option.getOrUndefined(decode(input))
const file = artifact?.metadata.files[`opencode-desktop-mac-${arch}.dmg`]
return file && { version: artifact.version, url: file.url }
}
+28 -27
View File
@@ -1,17 +1,19 @@
import { app, autoUpdater, shell } from "electron"
import { execFile } from "node:child_process"
import { promisify } from "node:util"
import { app, autoUpdater } from "electron"
import pkg from "electron-updater"
import { Effect } from "effect"
import type { Platform } from "./machine"
import { requiresStableMacInstaller, stableMacDownload } from "./migration"
const updateClient = pkg.autoUpdater
const execFileAsync = promisify(execFile)
const restartTimeout = 10_000
const stableArtifact = "https://opencode.ai/update/api/latest/desktop/opencode"
const unpackTimeout = 120_000
export const make = Effect.fn("Updater.platform")(function* (channel: string) {
const external = requiresStableMacInstaller(process.platform, channel)
const userAgent = `opencode/${channel === "prod" ? "latest" : channel}/${app.getVersion()}/desktop`
const runFork = Effect.runForkWith(yield* Effect.context())
updateClient.logger = {
@@ -36,17 +38,6 @@ export const make = Effect.fn("Updater.platform")(function* (channel: string) {
return {
checkForUpdate: Effect.tryPromise({
try: async () => {
if (external) {
const response = await fetch(stableArtifact, { headers: { "User-Agent": userAgent } })
if (!response.ok) throw new Error(`Stable OpenCode update check failed: ${response.status}`)
const download = stableMacDownload(await response.json(), process.arch)
if (!download) throw new Error("Stable OpenCode download is unavailable")
return { mode: "external", ...download } as const
}
const result = await updateClient.checkForUpdates()
if (!result?.isUpdateAvailable) return undefined
@@ -69,7 +60,6 @@ export const make = Effect.fn("Updater.platform")(function* (channel: string) {
}),
stageUpdate,
installAndRestart,
externalInstall: external ? openExternal : undefined,
} satisfies Platform
})
@@ -86,7 +76,7 @@ function stageUpdate(options: { readonly differential: boolean }) {
return updateClient.downloadUpdate()
},
catch: (error) => error,
}).pipe(Effect.asVoid)
}).pipe(Effect.flatMap(unpackInstaller))
return Effect.callback<void, Error>((resume) => {
const cleanup = () => {
@@ -112,6 +102,27 @@ function stageUpdate(options: { readonly differential: boolean }) {
})
}
// The NSIS installer unpacks the update beside the install directory while the app keeps running, so the
// install after the restart moves one directory into place instead of decompressing the whole app. When this
// fails, the restart still installs the update, only slower.
function unpackInstaller(files: string[]) {
const installer = files.find((file) => file.endsWith(".exe"))
if (process.platform !== "win32" || !app.isPackaged || !installer) return Effect.void
return Effect.tryPromise(() =>
execFileAsync(installer, ["--stage"], { windowsHide: true, timeout: unpackTimeout }),
).pipe(
Effect.andThen(Effect.logInfo("update unpacked before restart", { installer })),
Effect.catch((error) =>
Effect.logWarning("update not unpacked before restart", {
installer,
message: error.cause instanceof Error ? error.cause.message : String(error.cause),
}),
),
)
}
const installAndRestart = Effect.callback<void, Error>((resume) => {
const cleanup = () => {
autoUpdater.removeListener("before-quit-for-update", started)
@@ -147,13 +158,3 @@ const installAndRestart = Effect.callback<void, Error>((resume) => {
),
}),
)
// Only web links leave the app; a failure to open one is not an install error.
function openExternal(url: string) {
if (!URL.canParse(url) || !["http:", "https:"].includes(new URL(url).protocol))
return Effect.logWarning("blocked external target", { url })
return Effect.tryPromise(() => shell.openExternal(url)).pipe(
Effect.catch((error) => Effect.logError("failed to open external target", { url, error })),
)
}
+13 -11
View File
@@ -129,18 +129,20 @@ describe("WebSocket", () => {
),
)
await Effect.runPromise(
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const options = { headers: { authorization: "Bearer fixture" } }
const socket = constructor("wss://echo.example.test/options", options)
yield* Effect.callback<void>((resume) => {
socket.addEventListener("open", () => {
socket.close()
resume(Effect.void)
await withEnvironment("CI", undefined, () =>
Effect.runPromise(
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const options = { headers: { authorization: "Bearer fixture" } }
const socket = constructor("wss://echo.example.test/options", options)
yield* Effect.callback<void>((resume) => {
socket.addEventListener("open", () => {
socket.close()
resume(Effect.void)
})
})
})
}).pipe(Effect.scoped, Effect.provide(recorder)),
}).pipe(Effect.scoped, Effect.provide(recorder)),
),
)
expect(received).toEqual({ headers: { authorization: "Bearer fixture" } })
+24 -13
View File
@@ -20,7 +20,6 @@ test("audits deterministic flowchart and state layout families", () => {
expect(audits.length).toBeGreaterThanOrEqual(fixtures.length)
expect(violations).toEqual([])
expect(elapsedMs).toBeLessThan(35_000)
for (const audit of audits.filter((audit) => audit.fixture.kind === "state")) {
expect(audit.viewport).toBe(audit.fixture.profile === "short" ? 60 : audit.fixture.profile === "unicode" ? 80 : 120)
}
@@ -34,18 +33,30 @@ test("audits deterministic flowchart and state layout families", () => {
.filter((audit) => audit.fixture.id === "flowchart/deployment-architecture/curated")
.map((audit) => audit.viewport),
).toEqual([60, 80, 120])
expect(summary.total.area.max).toBeLessThanOrEqual(11_011)
expect(summary.total.area.p95).toBeLessThanOrEqual(5_313)
expect(summary.total.bends.max).toBeLessThanOrEqual(30)
expect(summary.total.bends.p95).toBeLessThanOrEqual(11)
expect(summary.total.crossings.total).toBeLessThanOrEqual(40)
expect(summary.total.crossings.max).toBeLessThanOrEqual(3)
expect(summary.total.routeLength.max).toBeLessThanOrEqual(930)
expect(summary.total.routeLength.p95).toBeLessThanOrEqual(364)
expect(summary.total.sharedRouteCells.max).toBeLessThanOrEqual(547)
expect(summary.total.sharedRouteCells.p95).toBeLessThanOrEqual(122)
expect(summary.total.overflow.max).toBeLessThanOrEqual(170)
expect(summary.total.overflow.p95).toBeLessThanOrEqual(99)
// decision-loops is a deliberately crossing-heavy family; ceiling it separately so it cannot absorb the
// p95 headroom of the established families.
const established = summarizeAudits(audits.filter((audit) => audit.fixture.family !== "decision-loops")).total
expect(established.area.max).toBeLessThanOrEqual(11_011)
expect(established.area.p95).toBeLessThanOrEqual(5_313)
expect(established.bends.max).toBeLessThanOrEqual(30)
expect(established.bends.p95).toBeLessThanOrEqual(11)
expect(established.crossings.total).toBeLessThanOrEqual(40)
expect(established.crossings.max).toBeLessThanOrEqual(3)
expect(established.routeLength.max).toBeLessThanOrEqual(930)
expect(established.routeLength.p95).toBeLessThanOrEqual(364)
expect(established.sharedRouteCells.max).toBeLessThanOrEqual(547)
expect(established.sharedRouteCells.p95).toBeLessThanOrEqual(122)
expect(established.overflow.max).toBeLessThanOrEqual(170)
expect(established.overflow.p95).toBeLessThanOrEqual(99)
const decisionLoops = summarizeAudits(audits.filter((audit) => audit.fixture.family === "decision-loops")).total
expect(decisionLoops.runs).toBeGreaterThan(0)
expect(decisionLoops.area.max).toBeLessThanOrEqual(8_784)
expect(decisionLoops.bends.max).toBeLessThanOrEqual(24)
expect(decisionLoops.crossings.total).toBeLessThanOrEqual(24)
expect(decisionLoops.crossings.max).toBeLessThanOrEqual(8)
expect(decisionLoops.routeLength.max).toBeLessThanOrEqual(526)
expect(decisionLoops.sharedRouteCells.max).toBeLessThanOrEqual(352)
expect(decisionLoops.overflow.max).toBeLessThanOrEqual(84)
expect(summary.state.crossings.total).toBe(0)
for (const fixture of [...Map.groupBy(fixtures, (candidate) => `${candidate.kind}/${candidate.family}`).values()].map(
+2 -1
View File
@@ -1348,9 +1348,10 @@ function placeStateTransitionLabels(
const dy = distance - Math.abs(dx)
for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) {
const candidateX = x + dx
if (!isClear(candidateX, candidateY)) continue
// Reject by path distance before isClear: it is far cheaper and rules out most of the spiral.
const pathDistance = labelDistanceToPath(candidateX, candidateY, width, plan.label!.lines.length, plan.path)
if (pathDistance < 2 || pathDistance > 8) continue
if (!isClear(candidateX, candidateY)) continue
x = candidateX
y = candidateY
break search
Loaded 100 of 190 files, more files were not shown because too many files have changed in this diff. Show more