Compare commits

..
Author SHA1 Message Date
Filip Hejmowski 2e873bc40b test(app): serve the built e2e app with a Bun static server
vite preview checks the case of every requested file on case-insensitive
file systems (Windows, macOS) with a synchronous readdir of its parent
directory. The fixture build has about 3100 files in dist/_assets, so each
of the roughly 150 asset requests per page load blocks the one server
that every worker shares. That matches the CI traces, where Windows asset
waits and first navigations grow with the number of tests running.

e2e/utils/serve.ts serves dist with Bun instead and keeps vite preview's
routing rules: files resolve only inside dist, a malformed escape is a
404, and index.html is the fallback only for GET or HEAD requests whose
Accept is missing, empty, text/html or */*, never for /favicon.ico. A
missing script fetched as JavaScript or an API request asking for JSON
still gets a 404. Bun fails on a busy port like --strictPort did, and
reuseExistingServer and the dev-mode command are unchanged.

The performance configs keep vite preview so their baselines stay
comparable.
2026-10-15 22:11:30 +02:00
Filip Hejmowski 4f0ee179fa test(app): block service workers and record video on first retry in e2e
Most app e2e tests let the production build register its PWA service
worker, which then precaches the whole 35 MB build from the shared preview
server while the test runs. No app e2e spec depends on the worker, and
e2e/service-worker keeps that coverage under its own config, so block it
in the shared config. The 11 per-file blocks are now redundant; they are
left alone to keep this change to one file.

retain-on-failure records and encodes a video for every test only to drop
it when the test passes. In CI, record it on the first retry instead,
next to the trace; first-attempt failures still keep their screenshot and
error context. Local runs have no retries, so they keep retain-on-failure.

The performance, uncapped, timeline-stability and projection configs
spread this config and now block service workers too. CI runs none of
them.
2026-10-15 22:11:30 +02:00
Filip Hejmowski 0cd2e829af fix(app): close mobile drawers dismissed while opening
@corvu/drawer 0.2.4 starts its opening transition two frames after it opens. A dismissal before then slid the drawer open while it was closed. A dismissal in that frame started no transition, or cancelled it, so the transitionend that finishes a close never arrived, leaving focus trapped and body pointer-events disabled. Patch it to skip the late opening step once closed, and to finish a close when no transition is running.

Also stop non-fullscreen story pages from scrolling by 32px inside Storybook's padding. Wait for web fonts before the composer overflow story measures the toolbar. Add composer tests that dismiss the variant drawer before and as it starts opening; both fail without the patch.

The overflow story also waits for focus to return to the add button after Escape before measuring, so the menu focus restore cannot land between select-all and Delete.
2026-10-15 22:11:29 +02:00
Filip Hejmowski 9f5ce4674a fix(app): keep timeline anchoring when a scroll settles
TanStack resets isScrolling on a 150ms timer that reports the offset
captured at the last scroll event. If the virtualizer wrote scrollTop
after that event (prepend anchoring or size compensation) and the
browser had not yet dispatched the write's own scroll event, the timer
put the old offset back. The next size compensation was then computed
from it and undid the write, so the transcript jumped to the top of a
newly loaded page or skipped rows while scrolling up.

When a scroll settles, the reconnect-aware offset observer now reports
the element's live scrollTop. Detached elements keep the event offset.
2026-10-15 22:11:29 +02:00
Filip Hejmowski 2573244805 fix(app): stop video double clicks from also entering native fullscreen
Chromium's media controls answer the second click of a double click with a
native fullscreen request for the <video>. The same double click opens the
in-window theatre. Where fullscreen is granted (Chrome, Playwright), the
fullscreened video covers the theatre backdrop, so a backdrop click no longer
closes it. That is the cause of the flaky review.spec.ts video theatre test.

controlslist="nofullscreen" would also remove Chromium's click-to-play,
which it only offers on videos that can go fullscreen. Instead, the video is
held as an open popover while the controls handle a multi-click, and released
before the next frame renders. The fullscreen element ready check refuses an
open popover, so only the fullscreen request fails. Chromium's play/pause
bookkeeping is unchanged. Desktop already denies the fullscreen permission,
so nothing changes there.

The e2e test now also checks that a single click still starts playback, and
that nothing went native fullscreen by the end of the flow.
2026-10-15 22:11:29 +02:00
Filip Hejmowski dbf5e655df test(app): check the first code layout instead of the code font's status
Chromium recreates every CSS-connected FontFace as "unloaded" whenever a
lazily loaded stylesheet with @layer rules becomes active ahead of a sheet
that is already applied. The route's settings and text-input CSS chunks do
this when they finish out of order, which is common on Windows runners. The
downloaded font file is kept, so the first code that lays out still uses
IBM Plex Mono, but the face's status stays "unloaded" and polling
document.fonts.check() timed out.

Wait for document.fonts.ready, then mount one <code> and check once that
its first layout already has the font loaded. A preload that only downloads
the file still fails, because that first layout reports "loading".
2026-10-15 22:11:29 +02:00
Filip Hejmowski 78558202c0 ci: run the test suites of the 11 packages turbo skipped
turbo.json defines a test task per package and no generic one, so the
test scripts of client, gui-extensions, http-recorder, merman, plugin,
plugin-browser, script, sdk, simulation, theme and util never ran in CI
(119 test files). Register them like the other suites.
2026-10-15 22:11:29 +02:00
Filip Hejmowski 25facb9dfe test: fix the suites CI never ran
These suites had no turbo test task, so they rotted unnoticed.

- client: update tests for intended API changes (session.view idle is a
  DateTime, interrupt's continue became resume, new API groups, the
  Effect service no longer imports protocol), and assert the service
  shutdown per platform since Windows cannot catch SIGTERM.
- http-recorder: run the record-mode WebSocket test with CI unset;
  CI=true forces replay mode and the test waited forever for "open".
- util: reset the retry test's socket after the request arrives instead
  of racing the client's write; set TEMP for the child on Windows and
  compare with the native realpath; exclude colocated tests from the
  build, which compiled them into dist where bun test ran them twice.
- sdk: update two tests for intended behaviour changes, keeping the
  unknown-session error on the HTTP transport.
- merman: hold the established layout families to their original
  ceilings and give decision-loops its own, drop the wall-clock
  assertion, and run the cheap label distance check before the clearance
  check (same output, half the audit CPU).
2026-10-15 22:11:29 +02:00
Filip Hejmowski 4003f64688 fix(server): wait for plugins before answering a web search
websearch.query resolved the provider without waiting for the Location's
plugins to activate, so the first query on a cold Location failed with
websearch_provider_not_found for a provider a plugin was about to
register. It now waits like the model and generate handlers do, bounded
by the same 5s activation wait. websearch.providers stays non-blocking.
2026-10-15 21:37:00 +02:00
Filip Hejmowski 7dbe56078b test(tui): wait for filter focus and highlighting before matching frames
command-selection typed into the variant dialog before its filter had
focus (DialogSelect focuses on a 1ms timer). Markdown text is drawn only
after the tree-sitter worker replies, which can take longer than the 20
frames waitForFrame allows on a busy runner. Wait for focus, and add
waitForHighlightedFrame to wait for pending highlights inside the
predicate.
2026-10-15 20:25:32 +02:00
Filip Hejmowski bc65cbe961 fix(ui): stop closed dialogs from blocking shortcuts
The dialog stack keeps a closing dialog for 100ms before disposing it,
and useDialog().active kept returning it, so the global shortcut handler
dropped every shortcut in that window. Reopening the command palette
right after closing it did nothing. active now returns the top dialog
that has not started closing, the same rule the focus trap already uses.
2026-10-15 20:25:31 +02:00
Filip Hejmowski 56782638cb test(app): fix four racy e2e tests
- whats-new: What's New opens as a modal 500ms after the changelog
  response and hides #root, so on Windows start() often looked for the
  Settings button after it was hidden. The dialog tests hold the
  changelog route until Home has been checked.
- subagent-child-navigation: the parent title was measured while the
  loading header could still be swapped for the timeline header, so it
  sometimes measured a detached heading (left 0). Wait for the timeline
  row first.
- session-summary: the tooltip was hovered during the timeline's first
  mount, whose final scroll closes it. Wait for the rows to be revealed.
- tabs: page.clock.pauseAt(new Date()) raced the still-running page
  clock ("Cannot fast-forward to the past"). Install at an explicit time
  and pause at a fixed offset from it.
2026-10-15 20:25:29 +02:00
Filip Hejmowski 9fe5766435 chore(ci): install with the isolated linker on Windows
Windows CI installed with --linker hoisted to work around
oven-sh/bun#28147, where an isolated install re-applied a patched
dependency per peer variant and failed with EPERM while hardlinking. That
issue was fixed in July, and a full test run on Windows with the isolated
linker installs and passes. The install step drops from about 95s to
about 36s on each Windows job.
2026-10-15 15:12:45 +02:00
Filip Hejmowski 77b8f1dfc7 fix(ci): hash every test task's workspace closure and cache Turbo on Windows
A test task's hash only covered its direct dependencies' build tasks. tui
and script have no build task, so core, codemode, plugin-browser and ui
never reached cli#test's hash, and Linux replayed cached cli test passes
from the runner's remote cache for commits that changed core. Every test
task now also depends on a transit task, which runs nothing but folds the
whole workspace closure into its hash. The root package.json is a global
dependency, so a Bun upgrade through packageManager invalidates results.

Windows unit jobs had no Turbo cache at all. Restore one keyed by week on
Windows, save it from pushes to dev, and skip it on manual runs.
2026-10-15 14:57:16 +02:00
Filip Hejmowski 3a0a231d84 chore(ci): skip the bun dependency cache on Windows
On the Windows runners, restoring the ~720 MB bun cache spends about 37s
extracting it with tar onto NTFS, and the warm install still takes about
64s. A cold install without the cache is faster than restore plus warm
install in every paired run we sampled, so Windows skips the restore and
the save. Linux keeps the cache.
2026-10-15 13:18:32 +02:00
Filip Hejmowski 5c4079a926 chore(ci): shard app e2e tests and run component tests in their own leg
Split each OS's e2e job into three legs: two Playwright shards of the app
e2e suite and one leg for the session UI and app component tests. The
service worker suite reads the production build, so it runs on shard 2
after the e2e step that builds it.

Keep "e2e (linux)" and "e2e (windows)" as the names of small aggregator
jobs, since the branch rules require them. They pass only when every leg
passed, or when e2e was not needed, and fail if the affected job failed.
2026-10-15 13:17:40 +02:00
Filip Hejmowski e004ec3f57 chore(ci): run build smoke tests in parallel with unit tests
Move the compiled-service and Node build smoke steps out of the unit job
into a parallel build job. They took about 2.5 minutes after the tests on
each OS and do not depend on anything the tests produce.

Remove the Turbo actions/cache steps. They cached node_modules/.cache/turbo,
which Turbo 2 never writes (its local cache is .turbo/cache), so they never
restored or saved anything. On Windows, hashing the cache key still cost
about 30 seconds before and after the tests.

Check out two commits on pull requests instead of the full history. The
affected-package diff only needs the merge commit and its first parent.
2026-10-15 13:16:38 +02:00
Filip Hejmowski c3ceabf01c fix(storybook): resolve solid-query under the hoisted linker
The Storybook Vite config aliased @tanstack/solid-query to
packages/app/node_modules/@tanstack/solid-query. That path only exists with
bun's isolated linker. Windows CI installs with --linker hoisted, so Vite
returned 404 for every module importing solid-query and the app component
tests timed out on Windows.

Dedupe the package instead; Storybook declares it, so it resolves to one copy
under both linkers and both runtimes.

Also run the e2e job for Storybook-only changes. No app package depends on
Storybook, so those PRs skipped every component test and reported green.
2026-10-15 13:16:03 +02:00
Jack 4397ee44d0 docs(console): add DeepSeek V4.1 Flash Free (#55636) 2026-10-15 15:23:29 +08:00
Aiden Clineandkksingh000 b47c6802da fix(ai): accept null Responses function call namespace (#55631)
Co-authored-by: kksingh000 <kkandsingh000@gmail.com>
2026-10-15 02:13:14 -05:00
Aiden Cline ae8d59f8b5 fix(core): fit compaction output limit to the request actually sent (#55613) 2026-10-15 01:57:46 -05:00
opencode-agent[bot] 7ced591c3c chore: update nix node_modules hashes 2026-10-15 06:51:13 +00:00
Aiden Cline b55f220a17 fix(core): treat *.localhost as loopback for MCP OAuth token endpoints (#55627) 2026-10-15 01:39:46 -05:00
fbf5f164d9 fix(core): skip MCP list calls for unadvertised capabilities (#55474)
Co-authored-by: tsehla42 <tsehla42@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-15 01:26:12 -05:00
Don BowmanandAiden Cline 3aa26f1676 fix(core): advertise the spec-shaped MCP elicitation capability (#53196)
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-15 01:19:35 -05:00
Aiden Cline a8fc567c5c chore(core): redact params in database query errors (#55621) 2026-10-15 01:04:07 -05:00
Aiden Cline 7b5f1bd9e1 fix(core): skip codemode query for MCP servers that reject it (#55618) 2026-10-15 00:50:11 -05:00
opencode-agent[bot] bdf4793286 feat(app): show voice delegations from the iOS app as Voice messages (#54426) 2026-10-15 13:23:12 +08:00
Luke Parker 37df62f4a1 fix(session-ui): show webfetch URL while the fetch runs (#55590) 2026-10-15 12:24:18 +10:00
Aiden Cline d099d2cb2a fix(cli): poll install download progress instead of tracing bytes (#55586) 2026-10-14 20:57:03 -05:00
Aiden Cline 97c201931c fix(www): serve install script from dev branch (#55587) 2026-10-14 20:37:54 -05:00
328f74c213 fix(mcp): pass tool input as permission metadata (#51062)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-10-14 18:52:52 -05:00
Aiden Cline 32b1a1bf85 test(core): run MCP lifecycle tests against an in-process server (#55568) 2026-10-14 18:50:26 -05:00
Luke Parker 2ae7ec8a16 fix(app): keep project settings open when the project folder is missing (#55553) 2026-10-15 09:40:43 +10:00
Aiden Cline ccd3aa787a fix(schema): publish config schema that accepts V1 and V2 shapes (#55524) 2026-10-14 18:09:31 -05:00
Aiden Cline e05cc4a18d docs(plugin): document permission assert and fix session rules example (#55526) 2026-10-14 18:08:45 -05:00
Luke Parker 3943b84ef0 fix(cli): default the Windows service to port 45278 (#55547) 2026-10-15 08:41:49 +10:00
Filipandvegerot 1e63a3bb73 fix(core): load configured instructions (#55539)
Co-authored-by: vegerot <5297725+vegerot@users.noreply.github.com>
2026-10-15 00:34:17 +02:00
vprdev 7291f3e07a fix(console): keep API route handlers out of route modules (#55541) 2026-10-14 14:41:00 -07:00
opencode-agent[bot]andTarquinen 13afea6a1a docs(console): correct Cat Jam Free privacy on Go page (#55499)
Co-authored-by: Tarquinen <78942835+Tarquinen@users.noreply.github.com>
2026-10-14 14:00:46 -05:00
James Long 8ffbcc2c81 fix(tui): tidy docked diff panel spacing (#55467) 2026-10-14 13:12:43 -04:00
opencode-agent[bot]andrekram1-node 35bb8e44ef fix(core): treat empty configured provider and model names as omitted (#55465)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-10-14 11:29:44 -05:00
opencode-agent[bot]anddc85 0ccfe69ff3 docs(console): list Cat Jam Free unavailable regions (#55466)
Co-authored-by: dc85 <83497+dc85@users.noreply.github.com>
2026-10-14 09:28:55 -07:00
Stefan Avram 07c0ff5914 feat(console): enrich Salesforce leads with Console account status (#54387) 2026-10-14 12:28:23 -04:00
James Long fe1717b309 fix(tui): put the diff side panel behind an experiment (#55456) 2026-10-14 12:16:18 -04:00
Aiden Cline 943dc005f7 fix(vscode): use the sst/opencode repository URL for Marketplace publishing (#55463) 2026-10-14 11:04:25 -05:00
Aiden Cline cddf1dd3e9 docs: document top-level policies in Console API response (#55461) 2026-10-14 10:55:52 -05:00
James Long b474b84259 fix(tui): use the action disabled token for inactive key hints (#55452) 2026-10-14 11:28:46 -04:00
James Long c0d41d2b26 feat(tui): render diffs in plugin panel slots (#47152) 2026-10-14 11:26:36 -04:00
Filip c458ad72d3 fix(core): substitute empty for missing config file references (#55388) 2026-10-14 16:35:25 +02:00
opencode-agent[bot]andneriousy f44c40b4d3 docs: add mise install method (#55415)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-10-14 15:43:06 +02:00
opencode-agent[bot]andBrendonovich 5fa0a42386 feat(app): restore option arrow session tab navigation (#54277)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-14 21:35:52 +08:00
Jack d3e1de326b docs(www): remove Ling free models from Console models (#55410) 2026-10-14 21:29:21 +08:00
opencode-agent[bot]andBrendonovich 81d57087a4 feat(session-ui): summarize each turn's changed files (#55064)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-10-14 21:13:31 +08:00
opencode 34d8ded0c5 sync release versions for v2.0.29 [skip ci] 2026-10-14 09:03:25 +00:00
178 changed files with 5093 additions and 956 deletions

No files matched your search

+5 -10
View File
@@ -49,8 +49,11 @@ 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 }}
@@ -61,19 +64,11 @@ runs:
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 }}
+120 -56
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,14 +96,21 @@ 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
# Linux replays Turbo tasks from the runner's remote cache, Windows has none. Weekly keys bound how far the cache
# grows and how old a replayed entry can be. Manual runs skip it so they execute every task.
- name: Get Turbo cache week
if: runner.os == 'Windows'
id: turbo-week
run: echo "week=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT"
- name: Restore Turbo cache
if: runner.os == 'Windows' && github.event_name != 'workflow_dispatch'
id: turbo-cache
uses: actions/cache/restore@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 }}-
path: .turbo/cache
key: turbo-${{ runner.os }}-${{ steps.turbo-week.outputs.week }}-${{ github.sha }}
restore-keys: turbo-${{ runner.os }}-${{ steps.turbo-week.outputs.week }}-
- name: Run unit tests
timeout-minutes: 20
@@ -116,6 +126,13 @@ jobs:
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
- name: Save Turbo cache
if: runner.os == 'Windows' && github.event_name == 'push'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: .turbo/cache
key: ${{ steps.turbo-cache.outputs.cache-primary-key }}
- name: Verify published codemode package
if: runner.os == 'Linux'
working-directory: packages/codemode
@@ -134,32 +151,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 +161,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 +172,92 @@ 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 }}
- 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
- 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; the component suites run once per OS in their own leg.
suite:
- id: app-1
shard: 1/2
- id: app-2
shard: 2/2
- id: 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 +265,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: ${{ !cancelled() && !matrix.suite.shard }}
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: ${{ !cancelled() && !matrix.suite.shard }}
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 +315,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"
+70
View File
@@ -2,6 +2,76 @@
Release notes for OpenCode V2, newest first.
## v2.0.29 — 2026-10-14
### Highlights
- **Remote Device Pairing**: In **Settings → Pairing**, select **Pair device** to enable OpenTunnel remote access and get a QR code or copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
- **Provider Account Management**: Use the connect dialog or **Settings → Providers** to switch, rename, remove, or add provider accounts. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
- **Automatic Response Continuation**: Responses that reach their output-token limit now continue automatically, preserving partial output instead of stopping abruptly. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
- **Trackpad Image Navigation**: Pinch to zoom and use two-finger scrolling to pan image and SVG previews. Select **Fit** to reset the view. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
### Core
#### Improvements
- Automatically continue responses after an output-token limit, with bounded retries and clearer recovery for truncated tool calls. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
- Stabilize access policies under the top-level `policies` configuration key while retaining compatibility with `experimental.policies`. ([#55297](https://github.com/anomalyco/opencode/pull/55297))
#### Bug fixes
- Give centrally managed configuration precedence over local and user configuration. ([#55267](https://github.com/anomalyco/opencode/pull/55267))
- Allow migrated V1 npm provider configuration to target built-in provider routes. ([#55254](https://github.com/anomalyco/opencode/pull/55254))
### TUI
#### Improvements
- Report working sessions and sessions awaiting permission or input through terminal program-status notifications. ([#55172](https://github.com/anomalyco/opencode/pull/55172))
### Desktop
#### Improvements
- Pair devices from **Settings → Pairing** through OpenTunnel remote access, with a QR code and copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
- Manage provider accounts from the connect dialog and **Settings → Providers**, including switching, renaming, removal, and adding another account. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
- Show session context tokens and accumulated family cost in the session view. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
- Pinch to zoom and use two-finger scrolling to pan image and SVG previews; select **Fit** to reset. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
- Cycle side-panel tabs with the configured previous-tab and next-tab shortcuts while the side panel is focused. ([#54899](https://github.com/anomalyco/opencode/pull/54899))
- Refresh the question composer and clarify timeline notices that apply only to a model variant. ([#54822](https://github.com/anomalyco/opencode/pull/54822), [#54880](https://github.com/anomalyco/opencode/pull/54880))
- Refine diff colors and move unread indicators into session tabs. ([#52551](https://github.com/anomalyco/opencode/pull/52551), [#53570](https://github.com/anomalyco/opencode/pull/53570))
#### Bug fixes
- Show steered messages as pending immediately and render inline attachments before their server echo arrives. ([#55251](https://github.com/anomalyco/opencode/pull/55251), [#54650](https://github.com/anomalyco/opencode/pull/54650))
- Preserve raw media data up to 256 MiB so artifact previews remain available. ([#55018](https://github.com/anomalyco/opencode/pull/55018))
- Retain session model selections when the model catalog refreshes. ([#54481](https://github.com/anomalyco/opencode/pull/54481))
- Close browser tabs from the address field and suppress incorrect missing-file notices for restored or already-open tabs. ([#54501](https://github.com/anomalyco/opencode/pull/54501), [#55022](https://github.com/anomalyco/opencode/pull/55022))
- Prevent a project-store eviction loop during launch and remove hover gaps between vertical tabs. ([#55078](https://github.com/anomalyco/opencode/pull/55078), [#54866](https://github.com/anomalyco/opencode/pull/54866))
### SDK
#### Improvements
- Export `lastAssistantWithUsage` from the Solid client to consistently locate the assistant step that determines current context usage. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
#### Bug fixes
- Include all paginated child sessions when loading a session family for usage totals. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
- Admit inline data-URL files into optimistic pending messages so attachments render immediately. ([#54650](https://github.com/anomalyco/opencode/pull/54650))
### Extensions
#### Improvements
- Add `ctx.permission.assert` for Effect and Promise plugins, allowing plugins to use configured permission rules and the native approval prompt. ([#54930](https://github.com/anomalyco/opencode/pull/54930))
#### Bug fixes
- Use Figma's public endpoint and default OAuth configuration, and support Git installations and development releases. ([#55252](https://github.com/anomalyco/opencode/pull/55252), [#55304](https://github.com/anomalyco/opencode/pull/55304))
- Resolve the Figma plugin peer dependency from its workspace. ([#55269](https://github.com/anomalyco/opencode/pull/55269))
- Associate each rejected Promise hook with its own tool call. Thanks [@1052326311](https://github.com/1052326311). ([#55263](https://github.com/anomalyco/opencode/pull/55263))
## v2.0.28 — 2026-10-13
### Highlights
+1
View File
@@ -1083,6 +1083,7 @@
"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",
"@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",
},
+6
View File
@@ -28,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
properties: { value: `https://${domain}/console/api/internal/console-account` },
})
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
handler: "packages/console/function/src/log-processor.ts",
@@ -48,6 +52,8 @@ new sst.cloudflare.x.SolidStart("Console", {
SALESFORCE_CLIENT_ID,
SALESFORCE_CLIENT_SECRET,
SALESFORCE_INSTANCE_URL,
CONSOLE_CRM_TOKEN,
CONSOLE_CRM_LOOKUP_URL,
],
environment: {
//VITE_DOCS_URL: web.url.apply((url) => url!),
+20 -49
View File
@@ -243,17 +243,6 @@ check_version() {
fi
}
unbuffered_sed() {
if echo | sed -u -e "" >/dev/null 2>&1; then
sed -nu "$@"
elif echo | sed -l -e "" >/dev/null 2>&1; then
sed -nl "$@"
else
local pad="$(printf "\n%512s" "")"
sed -ne "s/$/\\${pad}/" "$@"
fi
}
print_progress() {
local bytes="$1"
local length="$2"
@@ -283,52 +272,34 @@ download_with_progress() {
exec 4>/dev/null
fi
local tmp_dir=${TMPDIR:-/tmp}
local basename="${tmp_dir}/opencode_install_$$"
local tracefile="${basename}.trace"
rm -f "$tracefile"
mkfifo "$tracefile"
local headers="${output}.headers"
# Hide cursor
printf "\033[?25l" >&4
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
trap "trap - RETURN; rm -f \"$headers\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
(
curl --trace-ascii "$tracefile" -fsL -o "$output" "$url"
) &
# Poll the file size on a timer so rendering cost stays fixed no matter how many bytes arrive.
# Read the size with ls because busybox wc -c reads the whole file.
curl -fsL -D "$headers" -o "$output" "$url" &
local curl_pid=$!
unbuffered_sed \
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
-e '/^0000: content-length:/p' \
-e '/^<= recv data/p' \
"$tracefile" | \
{
local length=0
local bytes=0
local length bytes
while kill -0 "$curl_pid" 2>/dev/null; do
if [ -f "$output" ]; then
length=$(grep -i '^content-length:' "$headers" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true)
bytes=$(ls -ln "$output" | awk '{print $5}')
print_progress "$bytes" "${length:-0}"
fi
sleep 0.1 2>/dev/null || sleep 1
done
while IFS=" " read -r -a line; do
[ "${#line[@]}" -lt 2 ] && continue
local tag="${line[0]} ${line[1]}"
if [ "$tag" = "0000: content-length:" ]; then
length="${line[2]}"
length=$(echo "$length" | tr -d '\r')
bytes=0
elif [ "$tag" = "<= recv" ]; then
local size="${line[3]}"
bytes=$(( bytes + size ))
if [ "$length" -gt 0 ]; then
print_progress "$bytes" "$length"
fi
fi
done
}
wait $curl_pid
local ret=$?
local ret=0
wait "$curl_pid" || ret=$?
if [ "$ret" -eq 0 ]; then
bytes=$(ls -ln "$output" | awk '{print $5}')
print_progress "$bytes" "$bytes"
fi
echo "" >&4
return $ret
}
+3 -3
View File
@@ -1,7 +1,7 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Nrl3q4qV3YKGnSoKDjs0HvFyviVhtNfKEmMUIoREITc=",
"aarch64-linux": "sha256-AfaHJIwoLA/9icnohKM2DL2dcmgSANfVT3oefOYBylE=",
"aarch64-darwin": "sha256-nat5x4P4yGHqqBbw64CT0B66dmR3oSV3Qic1vMGnE4Q="
"x86_64-linux": "sha256-wmSDJ8NZOJyfSaBD8hnAAtbYrp3qnMS4eJzgFdyrhFc=",
"aarch64-linux": "sha256-6WE4BHW11/gAQ75ktPbWU2Aw7uqivueMrElDAqu7QdE=",
"aarch64-darwin": "sha256-6aAic4o1Zx6QiMk8IFU6lV7typClc3yXoWH7Qiq+hFs="
}
}
+2 -1
View File
@@ -184,6 +184,7 @@
"@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"
}
}
+4 -2
View File
@@ -334,14 +334,14 @@ export const StreamItem = Schema.StructWithRest(
id: Schema.optional(Schema.String),
call_id: Schema.optional(Schema.String),
name: Schema.optional(Schema.String),
namespace: Schema.optional(Schema.String),
namespace: optionalNull(Schema.String),
arguments: Schema.optional(Schema.String),
encrypted_content: optionalNull(Schema.String),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
export type OutputItem = StreamItem & { readonly id: string }
export type OutputItem = StreamItem & { readonly id: string; readonly namespace?: string }
// Responses-compatible providers put error details at the top level, under `error`, or under
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
@@ -946,6 +946,8 @@ const resolveItem = (
index: number | undefined,
): OutputItem => ({
...item,
// Some gateways serialize an absent namespace as null.
namespace: item.namespace ?? undefined,
id:
item.id ??
(index === undefined ? undefined : state.outputItems[index]) ??
@@ -644,6 +644,27 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("treats a null function call namespace as absent", () =>
Effect.gen(function* () {
const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", namespace: null }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "" } },
{ type: "response.function_call_arguments.done", item_id: "fc_1", output_index: 0, arguments: "{}" },
{ type: "response.output_item.done", output_index: 0, item: { ...item, arguments: "{}" } },
{ type: "response.completed", response: { id: "resp_1", output: [{ ...item, arguments: "{}" }] } },
),
),
),
)
expect(response.toolCalls).toEqual([expect.objectContaining({ id: "call_1", name: "lookup", input: {} })])
expect(response.toolCalls[0]?.namespace).toBeUndefined()
}),
)
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
Effect.gen(function* () {
yield* Effect.forEach(
+3 -3
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,9 +64,9 @@ 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
@@ -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()
})
}
})
@@ -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>()
@@ -890,6 +890,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")
+36 -1
View File
@@ -1,7 +1,7 @@
import { expect, test, type Page } from "@playwright/test"
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
import { mockOpenCodeServer } from "../utils/mock-server"
import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server"
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
const directory = "C:/Projects/settings-demo"
@@ -248,6 +248,41 @@ test("project settings open as a nested view that keeps its route", async ({ pag
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
})
// The desktop app reopens its last settings URL on launch, so a crash on this page would repeat on every restart.
test("a project whose folder is gone opens its settings with a notice", async ({ page }) => {
await mockWorkspace(page, {
name: "Settings demo",
directory,
provider: NO_PROVIDER,
sessions: [],
seed: { tabs: [] },
})
await mockMissingLocation(page, directory)
const plugins = page.waitForResponse((response) => {
const url = new URL(response.url())
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
})
await page.goto(
`/settings?server=${encodeURIComponent(SERVER)}&project=${encodeURIComponent(directory)}&tab=extensions`,
)
expect((await plugins).status()).toBe(404)
const settings = page.getByTestId("settings-screen")
const notice = settings.getByRole("status").filter({ hasText: "Project folder not found" })
await expect(notice).toContainText(directory)
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
await expect(
settings.getByRole("tabpanel", { name: "Plugins", exact: true }).getByText("No plugins yet", { exact: true }),
).toBeVisible()
await settings.getByRole("tab", { name: "General", exact: true }).click()
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toBeVisible()
await expect(notice).toContainText(directory)
})
for (const field of [
{
name: "color",
@@ -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)
+32 -2
View File
@@ -162,6 +162,32 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
await expectPath(page, sessionHref(c.id))
})
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
await mockWorkspace(page, {
name: "Tabs",
sessions: [a, b, c],
sessionPermissions: {
[c.id]: [
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
],
},
})
await page.goto(sessionHref(a.id))
await page.mouse.move(0, 0)
await expect(
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`),
).toBeVisible()
await page.keyboard.press("Alt+ArrowDown")
await expectPath(page, sessionHref(b.id))
await page.keyboard.press("Alt+ArrowUp")
await expectPath(page, sessionHref(a.id))
await page.keyboard.press("Shift+Alt+ArrowDown")
await expectPath(page, sessionHref(c.id))
})
for (const row of [
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
@@ -411,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"))
}
+15
View File
@@ -476,6 +476,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
return { server, pty: transport.pty, push }
}
// Answers every Location-scoped request for `directory` as the server does once that folder is deleted.
export function mockMissingLocation(page: Page, directory: string) {
return page.route(
(url) => url.pathname.startsWith("/api/") && url.searchParams.get("location[directory]") === directory,
(route) =>
route.fulfill({
status: 404,
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
headers: { "access-control-allow-origin": "*" },
}),
)
}
// Mocks several servers on one page. Each origin gets its own handler and its own SSE transport for events.
export async function mockServers(page: Page, servers: Record<string, Omit<MockServerConfig, "server" | "events">>) {
return Object.fromEntries(
+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("*/*")
}
+6 -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,7 +21,7 @@ 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
@@ -59,7 +59,10 @@ 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",
},
projects: [
{
+21 -10
View File
@@ -1,5 +1,5 @@
import { Navigate, useSearchParams } from "@solidjs/router"
import { createMemo, Show, type ParentProps } from "solid-js"
import { createMemo, ErrorBoundary, Show, type ParentProps } from "solid-js"
import { CommentsProvider } from "@/composer/comments"
import { FileProvider } from "@/workspaces/files/model"
import { useGlobal } from "@/runtime/server/runtime"
@@ -12,6 +12,7 @@ import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import NewSession from "@/new-session/screen"
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
import { ErrorPage } from "@/shell/errors/error"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
export function DraftRoute() {
@@ -67,15 +68,25 @@ function ResolvedDraftContent(props: { draft: DraftTab }) {
</SessionRouteFrame>
}
>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
<ErrorBoundary
fallback={(error) => (
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<ErrorPage error={error} />
</SessionPanelFrame>
</SessionRouteFrame>
)}
>
<ModelsProvider directory={props.draft.directory}>
<LocationProvider directory={props.draft.directory}>
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</SessionUIProvider>
</LocationProvider>
</ModelsProvider>
</ErrorBoundary>
</Show>
)
}
+8
View File
@@ -42,6 +42,10 @@ export const dict = {
"command.palette": "Command palette",
"command.session.new": "New session",
"command.session.previous": "Previous session",
"command.session.next": "Next session",
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.file.open": "Open file",
"command.tab.close": "Close tab",
"command.tab.reopenClosed": "Reopen closed tab",
@@ -747,6 +751,8 @@ export const dict = {
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
"project.settings.folderMissing.title": "Project folder not found",
"project.settings.folderMissing.description": "Restore the folder or close this project.",
"project.settings.extensions.description": "View extensions available to this project",
"project.settings.extensions.tab.lsps": "LSPs",
"project.settings.extensions.added": "Added to this project",
@@ -790,6 +796,8 @@ export const dict = {
"settings.timeline.description.text-only": "Hide all activity.",
"settings.timeline.description.custom": "Uses advanced settings.",
"settings.timeline.custom": "Custom",
"settings.timeline.row.turnDiffs.title": "Changed files",
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
"settings.timeline.advanced": "Advanced",
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
"settings.timeline.group": "Group",
@@ -180,6 +180,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
timelineDetail,
pendingInputIDs,
queuedCompactionIDs,
turnDiffs: settings.general.showTurnDiffs,
})
const [pending, setPending] = createStore({ rename: false })
@@ -23,6 +23,7 @@ import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory } from "@opencode/util/path"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { parseVoiceDelegation } from "./voice-delegation"
import { useCommand } from "@/shell/commands/command"
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
import { SessionHeaderSpacer } from "@/session/header/session-header"
@@ -293,11 +294,13 @@ function MessageTimelineView(
presentation: (message) => {
const value = readPromptPresentation(message.metadata)
const parsed = value ? undefined : parseCommentNote(message.text)
const voice = parseVoiceDelegation(value?.displayText ?? message.text)
return {
displayText: value?.displayText,
displayText: voice ? (voice.framing ?? voice.instruction) : value?.displayText,
comments: value?.comments ?? (parsed ? [parsed] : []),
references: value?.attachments,
voice: !!voice,
}
},
actions: props.actions,
@@ -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
@@ -0,0 +1,32 @@
import { describe, expect, test } from "bun:test"
import type { FileDiffInfo } from "@opencode/client/promise"
import { createRoot } from "solid-js"
import { createTurnDiffCache } from "./turn-diffs"
const turn = { sessionID: "ses_a", from: "msg_open" }
// Every pending promise callback runs before the next macrotask.
const settle = () => new Promise((resolve) => setImmediate(resolve))
const diff = { file: "src/a.ts", patch: "", additions: 1, deletions: 0, status: "modified" } satisfies FileDiffInfo
describe("turn diff cache", () => {
test("loads each turn once and retries after a failure", async () => {
const cache = createRoot(() => createTurnDiffCache())
const failed = Promise.withResolvers<readonly FileDiffInfo[]>()
const calls: string[] = []
cache.load(turn, () => (calls.push("failed"), failed.promise))
cache.load(turn, () => (calls.push("duplicate"), Promise.resolve([diff])))
failed.reject(new Error("offline"))
await settle()
expect(cache.get(turn)).toBeUndefined()
cache.load(turn, () => (calls.push("retry"), Promise.resolve([diff])))
await settle()
cache.load(turn, () => (calls.push("cached"), Promise.resolve([])))
expect(calls).toEqual(["failed", "retry"])
expect(cache.get(turn)).toEqual([diff])
})
})
@@ -0,0 +1,30 @@
import type { FileDiffInfo } from "@opencode/client/promise"
import type { TurnDiffInput } from "@opencode/session-ui/context"
import { createStore } from "solid-js/store"
/**
* One tab's in-memory cache of the files each finished turn changed. A finished turn's snapshots never
* change, so each turn loads once per tab and survives the timeline remounting; closing the tab drops it.
*/
export function createTurnDiffCache() {
const [diffs, setDiffs] = createStore<Record<string, readonly FileDiffInfo[] | undefined>>({})
const loading = new Set<string>()
const key = (input: TurnDiffInput) => `${input.sessionID}/${input.from}`
return {
get: (input: TurnDiffInput) => diffs[key(input)],
load(input: TurnDiffInput, fetch: () => Promise<readonly FileDiffInfo[]>) {
const id = key(input)
if (diffs[id] || loading.has(id)) return
loading.add(id)
// A failed load leaves the turn unloaded, so the next request retries.
void fetch()
.then(
(value) => setDiffs(id, value),
() => undefined,
)
.finally(() => loading.delete(id))
},
}
}
@@ -0,0 +1,63 @@
import { describe, expect, test } from "bun:test"
import { parseVoiceDelegation } from "./voice-delegation"
// Prompts as the OpenCode iOS app has sent them (`VoiceHandoff.format`), oldest first.
const firstRelease = `Voice delegation. Instruction is the cleaned interpretation of the user's request. Transcript delta supplies new or revised conversation for reference, not additional commands. Resolve meaning from the complete utterance, not a disfluent prefix. Lead your reply with one or two plain sentences suitable for reading aloud, then any detail.
{
"instruction" : "Check why the upload test is flaky.",
"transcript_delta" : [
"user: the upload test",
"assistant: On it."
]
}`
const current = `Voice delegation. The user is on a voice call with a voice agent, which hands their requests to you. Instruction is the user's own spoken words since the previous delegation.
{
"context" : "Session \\"Uploads\\" in project \\"app\\".",
"instruction" : "check the upload test",
"transcript_delta" : [
"user: check the upload test"
],
"voice" : "Checking the upload test."
}`
const continued = `Voice delegation (continued). Same fields as before.
{
"instruction" : "and open a PR",
"transcript_delta" : [
]
}`
describe("parseVoiceDelegation", () => {
test.each([
["the first release's header", firstRelease, { instruction: "Check why the upload test is flaky." }],
[
"the current header with framing",
current,
{ instruction: "check the upload test", framing: "Checking the upload test." },
],
["a continued delegation", continued, { instruction: "and open a PR" }],
[
"a call log attached as a reference line",
`${continued}\n\nAttached file: \`/tmp/uploads/1/voice-call-a.json\``,
{ instruction: "and open a PR" },
],
])("reads %s", (_, text, expected) => {
expect(parseVoiceDelegation(text)).toEqual({ framing: undefined, ...expected })
})
test("ignores blank framing", () => {
expect(parseVoiceDelegation(current.replace('"Checking the upload test."', '" "'))?.framing).toBeUndefined()
})
test.each([
["a typed message that mentions one", "What does a Voice delegation. prompt look like?"],
["a header without a body", "Voice delegation. Same fields as before."],
["a body that isn't JSON", "Voice delegation. Header\ncheck the upload test"],
["a blank instruction", 'Voice delegation. Header\n{"instruction": " ", "transcript_delta": []}'],
["a body without an instruction", 'Voice delegation. Header\n{"transcript_delta": []}'],
])("rejects %s", (_, text) => {
expect(parseVoiceDelegation(text)).toBeUndefined()
})
})
@@ -0,0 +1,33 @@
import { Option, Schema } from "effect"
// The OpenCode iOS app sends each request from a voice call as plain prompt text: a header line, then a JSON
// body. Nothing else marks the message, so this mirrors the app's `VoiceHandoff.parse`. The header's wording
// has changed between releases; its opening and the body's shape have not.
const decodeBody = Schema.decodeUnknownOption(
Schema.fromJsonString(
Schema.Struct({
instruction: Schema.String,
voice: Schema.optional(Schema.String),
}),
),
)
/** The user's spoken request and the voice agent's framing of it, or undefined for any other prompt. */
export function parseVoiceDelegation(text: string) {
if (!text.startsWith("Voice delegation.") && !text.startsWith("Voice delegation (continued).")) return
const newline = text.indexOf("\n")
if (newline === -1) return
// Without presentation metadata, path attachments such as the call log follow the body as reference lines.
const body = Option.getOrUndefined(
decodeBody(text.slice(newline + 1).replace(/(\s*\nAttached file: `[^\n]*`)+\s*$/, "")),
)
const instruction = body?.instruction.trim()
if (!instruction) return
return { instruction, framing: body?.voice?.trim() || undefined }
}
@@ -317,6 +317,17 @@ export const SettingsGeneral: Component = () => {
onChange={settings.general.setTimelineDetail}
/>
</div>
<SettingsRow
title={language.t("settings.timeline.row.turnDiffs.title")}
description={language.t("settings.timeline.row.turnDiffs.description")}
>
<div data-action="settings-show-turn-diffs">
<Switch
checked={settings.general.showTurnDiffs()}
onChange={(checked) => settings.general.setShowTurnDiffs(checked)}
/>
</div>
</SettingsRow>
</SettingsList>
</section>
+1
View File
@@ -93,6 +93,7 @@ describe("settings schema", () => {
showFileTree: false,
timelineDetail: timelinePresets[2].value,
showCustomAgents: false,
showTurnDiffs: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
+6
View File
@@ -116,6 +116,7 @@ const generalSchema = Persistence.struct({
tools: placementOnlySchema,
}),
showCustomAgents: Schema.Boolean,
showTurnDiffs: 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),
@@ -278,6 +279,7 @@ export const defaultSettings: Settings = {
showFileTree: false,
timelineDetail: { ...timelinePresets[2].value },
showCustomAgents: false,
showTurnDiffs: false,
mobileTitlebarPosition: "top",
terminalPlacement: "side",
followUpBehavior: "steer",
@@ -351,6 +353,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowCustomAgents(value: boolean) {
setStore("general", "showCustomAgents", value)
},
showTurnDiffs: withFallback(() => store.general?.showTurnDiffs, defaultSettings.general.showTurnDiffs),
setShowTurnDiffs(value: boolean) {
setStore("general", "showTurnDiffs", value)
},
mobileTitlebarPosition: withFallback(
() => store.general?.mobileTitlebarPosition,
defaultSettings.general.mobileTitlebarPosition,
+14 -2
View File
@@ -2,8 +2,9 @@ import { Button } from "@opencode/ui/button"
import { Icon } from "@opencode/ui/icon"
import { Menu } from "@opencode/ui/menu"
import { Tabs } from "@opencode/ui/tabs"
import { For, Show, type ComponentProps, type JSX } from "solid-js"
import { createEffect, ErrorBoundary, For, on, Show, type ComponentProps, type JSX } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { ErrorPage } from "@/shell/errors/error"
import { useSettingsSurface } from "./surface"
import { SettingsSearch } from "./search"
import "./search.css"
@@ -142,7 +143,18 @@ export function SettingsNavigation(props: {
</Show>
</div>
</aside>
<div class="settings-content">{props.children}</div>
<div class="settings-content">
<ErrorBoundary
fallback={(error, reset) => {
// Keep a failed page inside the content area; choosing another page renders it again.
createEffect(on(() => props.value, reset, { defer: true }))
return <ErrorPage error={error} />
}}
>
{props.children}
</ErrorBoundary>
</div>
</Tabs>
)
}
@@ -29,9 +29,14 @@ export const SettingsExtensions: Component<{
const serverSdk = useServerSDK()
const data = useData()
// Reading `.latest` rethrows a failed fetch, so these lists fall back to empty instead of crashing the page.
const [mcpList, { refetch: refetchMcp }] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.mcp.list().then((result) => result.data),
() =>
serverSdk.api.mcp
.list()
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
@@ -51,7 +56,11 @@ export const SettingsExtensions: Component<{
const [pluginList] = createResource(
() => serverSdk.connection.status() === "connected",
() => serverSdk.api.plugin.list().then((result) => result.data),
() =>
serverSdk.api.plugin
.list()
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
@@ -21,6 +21,7 @@ import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
import { pluginLabels } from "@/providers/catalog/plugin"
import { ExternalLink } from "@/runtime/platform/external-link"
import { ProjectFolderMissing } from "./project-folder-missing"
import { configuredLanguageServers } from "./project-lsp"
import { SettingsList } from "@/settings/list"
import "./project.css"
@@ -248,15 +249,25 @@ export const ProjectSettingsExtensions: Component<{
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)
?.status.status === "connected"
// Reading `.latest` rethrows a failed fetch, such as the project's plugin list after its folder is deleted, so these
// lists fall back to empty instead of crashing the page.
const [globalPluginList] = createResource(
() => serverSDK.connection.status() === "connected",
() => serverSDK.api.plugin.list().then((result) => result.data),
() =>
serverSDK.api.plugin
.list()
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
const [projectPluginList] = createResource(
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
(directory) =>
serverSDK.api.plugin
.list({ location: { directory } })
.then((result) => result.data)
.catch(() => []),
{ initialValue: [] },
)
@@ -318,6 +329,7 @@ export const ProjectSettingsExtensions: Component<{
</div>
<div class="settings-tab-body">
<ProjectFolderMissing />
<Tabs
variant="pill"
value={props.subtab ?? "mcps"}
@@ -0,0 +1,29 @@
import { Icon } from "@opencode/ui/icon"
import { Show } from "solid-js"
import { useLanguage } from "@/runtime/i18n/language"
import { useWorkspaceLocation } from "@/workspaces/location"
import "./project.css"
export function ProjectFolderMissing() {
const language = useLanguage()
const location = useWorkspaceLocation()
return (
<Show when={location().missing}>
{(missing) => (
<div role="status" class="project-settings-folder-missing">
<Icon name="warning" class="project-settings-folder-missing-icon" />
<div class="project-settings-folder-missing-copy">
<span class="project-settings-folder-missing-title">
{language.t("project.settings.folderMissing.title")}
</span>
<bdi dir="ltr" class="project-settings-folder-missing-path">
{missing().directory}
</bdi>
<span>{language.t("project.settings.folderMissing.description")}</span>
</div>
</div>
)}
</Show>
)
}
@@ -94,6 +94,43 @@
background: var(--v2-state-fg-warning);
}
.project-settings-folder-missing {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 16px;
border-radius: 8px;
background-color: var(--v2-background-bg-layer-01);
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
color: var(--v2-text-text-muted);
font-size: 13px;
font-weight: 440;
line-height: var(--line-height-base);
}
.project-settings-folder-missing-icon {
flex-shrink: 0;
color: var(--v2-state-fg-warning);
}
.project-settings-folder-missing-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 2px;
}
.project-settings-folder-missing-title {
color: var(--v2-text-text-base);
font-weight: 530;
}
.project-settings-folder-missing-path {
font-family: var(--font-family-mono);
font-size: 12px;
overflow-wrap: anywhere;
}
.project-settings-shared {
display: flex;
flex-direction: column;
@@ -16,6 +16,7 @@ import { useSettingsServers } from "@/settings/servers/inventory"
import { useServerCtx } from "@/runtime/server/runtime"
import { projectSettingsKey, useSettings } from "@/settings/model"
import { ProjectIcon } from "@/shell/layout/project-icon"
import { ProjectFolderMissing } from "./project-folder-missing"
import { ProjectOptions } from "./project-options"
import { SettingsList } from "@/settings/list"
import { SettingsRow } from "@/settings/row"
@@ -63,6 +64,7 @@ export const SettingsProjectGeneral: Component<{
</div>
<div class="settings-tab-body project-settings-general" aria-busy={model.store.saving > 0}>
<ProjectFolderMissing />
<SettingsList>
<SettingsRow
title={language.t("project.settings.name.title")}
@@ -8,10 +8,11 @@ import type { ServerConnection } from "@/runtime/server/registry"
import { sessionHref } from "@/shell/routes/session"
import { useData } from "@/runtime/server/current"
import { useServerSDK } from "@/runtime/server/client"
import { useTabs } from "@/shell/tabs/tabs"
import { useTabs, type Tab } from "@/shell/tabs/tabs"
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"
export function SessionUIProvider(
props: ParentProps<{
@@ -65,6 +66,23 @@ export function SessionUIProvider(
const providers = useProviders(directory)
// Turn diffs live in the tab's in-memory state, so they outlast timeline remounts and go when the tab closes.
const turnDiffs = () => {
const id = params.id
if (!id) return
const tab =
tabs.store.find(
(item) =>
item.type === "session" &&
item.server === props.server &&
(item.sessionId === id || item.routeSessionId === id),
) ?? ({ type: "session", server: props.server, sessionId: id } satisfies Tab)
return tabs.state(tab, "turnDiffs", createTurnDiffCache)
}
const sessionUIData = createMemo(() => ({
provider: providers.ready()
? { all: providers.all(), default: providers.default(), connected: providers.connected().map((item) => item.id) }
@@ -88,6 +106,10 @@ export function SessionUIProvider(
sessionID={params.id}
shellRunning={(id) => !!data.shell.get(id)}
shellOutput={(input) => serverSDK.api.shell.output(input)}
turnDiff={{
get: (input) => turnDiffs()?.get(input),
load: (input) => turnDiffs()?.load(input, () => serverSDK.api.session.diff(input)),
}}
onNavigateToSession={navigateToSession}
onSessionHref={href}
>
+4 -9
View File
@@ -12,7 +12,6 @@ import { useLanguage } from "@/runtime/i18n/language"
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
import { displayName } from "@opencode/ui/project-avatar"
import { SessionTabAvatarView } from "@/shell/layout/session-tab-avatar"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
import type { SessionInfo } from "@opencode/client/promise"
import { sessionTabTitle } from "./tab-title"
@@ -28,6 +27,8 @@ export function TabNavItem(props: {
server: ServerConnection.Key
session: SessionInfo | undefined
preparing: boolean
unread: boolean
loading: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onClose: () => void
@@ -57,13 +58,7 @@ export function TabNavItem(props: {
const servers = useServers()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const avatar = useSessionTabAvatarState(
() => props.server,
() => props.session?.id ?? "",
() => true,
)
const unread = () => !!props.session && !props.active && avatar.unread() && !avatar.loading()
const unread = () => !!props.session && !props.active && props.unread
const project = createMemo(() => {
const session = props.session
@@ -312,7 +307,7 @@ export function TabNavItem(props: {
project={project()}
directory={session.location.directory}
unread={false}
loading={avatar.loading()}
loading={props.loading}
/>
)}
</Show>
@@ -1,5 +1,16 @@
import { expect, test } from "bun:test"
import { mergeVisibleTabOrder } from "./tab-order"
import { adjacentTabKey, mergeVisibleTabOrder } from "./tab-order"
test("finds the nearest accepted tab in either direction, wrapping around", () => {
const order = ["a", "b", "c", "d", "e"]
const unread = new Set(["a", "d"])
expect(adjacentTabKey(order, "b", 1, (key) => unread.has(key))).toBe("d")
expect(adjacentTabKey(order, "b", -1, (key) => unread.has(key))).toBe("a")
expect(adjacentTabKey(order, "e", 1, (key) => unread.has(key))).toBe("a")
expect(adjacentTabKey(order, "a", -1, (key) => unread.has(key))).toBe("d")
expect(adjacentTabKey(order, "a", 1, (key) => key === "a")).toBeUndefined()
})
test("merges reordered visible tabs around hidden tabs", () => {
expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([
+12 -3
View File
@@ -1,10 +1,19 @@
export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) {
if (!current || order.length === 0) return
/** The nearest other tab in the offset's direction, wrapping around, that `include` accepts. */
export function adjacentTabKey(
order: string[],
current: string | undefined,
offset: -1 | 1,
include: (key: string) => boolean = () => true,
) {
if (!current) return
const index = order.indexOf(current)
if (index === -1) return
return order[(index + offset + order.length) % order.length]
return Array.from(
{ length: order.length - 1 },
(_, step) => order[(index + offset * (step + 1) + order.length) % order.length],
).find(include)
}
export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) {
+50 -2
View File
@@ -13,6 +13,7 @@ import { useGlobal, useServerCtx, type ServerCtx } from "@/runtime/server/runtim
import { useLanguage } from "@/runtime/i18n/language"
import { useCommand } from "@/shell/commands/command"
import { useTabs } from "@/shell/tabs/tabs"
import { useSessionTabAvatarState } from "@/shell/layout/project-avatar-state"
import { createTabComposerState } from "@/composer/persistence"
import { base64Encode } from "@opencode/util/encode"
import { showToast } from "@/shell/notifications/toast"
@@ -27,6 +28,8 @@ function SessionTabSlot(props: {
orientation: "horizontal" | "vertical"
session: SessionInfo | undefined
preparing: boolean
unread: boolean
loading: boolean
fallbackTitle?: string
onRename: (title: string) => Promise<void>
onNavigate: (element: HTMLDivElement) => void
@@ -64,6 +67,8 @@ function SessionTabSlot(props: {
server={props.tab.server}
session={props.session}
preparing={props.preparing}
unread={props.unread}
loading={props.loading}
fallbackTitle={props.fallbackTitle}
onRename={props.onRename}
onNavigate={() => props.onNavigate(ref)}
@@ -84,6 +89,7 @@ function SessionTabEntry(props: {
orientation: "horizontal" | "vertical"
serverCtx: ServerCtx | undefined
onVisibleChange: (visible: boolean) => void
onUnreadChange: (unread: boolean) => void
onNavigate: (element: HTMLDivElement) => void
onClose: () => void
}) {
@@ -94,6 +100,15 @@ function SessionTabEntry(props: {
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
const persisted = createMemo(() => tabs.info[props.id])
const status = useSessionTabAvatarState(
() => props.tab.server,
() => props.tab.sessionId,
() => true,
)
// A busy session shows its progress indicator instead of the unread dot.
const unread = createMemo(() => status.unread() && !status.loading())
const [loadedSession] = createResource(
() => {
if (pending()) return null
@@ -135,6 +150,7 @@ function SessionTabEntry(props: {
}
createEffect(() => props.onVisibleChange(visible()))
createEffect(() => props.onUnreadChange(unread()))
createEffect(() => {
const ctx = props.serverCtx
@@ -186,6 +202,8 @@ function SessionTabEntry(props: {
orientation={props.orientation}
session={session()}
preparing={!!pending()}
unread={unread()}
loading={status.loading()}
fallbackTitle={
pending()
? language.t("session.tab.session")
@@ -263,6 +281,7 @@ export function TitlebarTabStrip(props: {
const vertical = () => props.orientation === "vertical"
let listRef!: HTMLDivElement
const [visibility, setVisibility] = createStore<Record<string, boolean>>({})
const [unread, setUnread] = createStore<Record<string, boolean>>({})
const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)]))
const visibleTabIds = () => visibleTabs().map(tabKey)
@@ -283,11 +302,39 @@ export function TitlebarTabStrip(props: {
hidden: true,
onSelect: () => selectAdjacentTab(1),
},
{
id: "session.previous",
category: language.t("command.category.session"),
title: language.t("command.session.previous"),
keybind: "alt+arrowup",
onSelect: () => selectAdjacentTab(-1),
},
{
id: "session.next",
category: language.t("command.category.session"),
title: language.t("command.session.next"),
keybind: "alt+arrowdown",
onSelect: () => selectAdjacentTab(1),
},
{
id: "session.previous.unseen",
category: language.t("command.category.session"),
title: language.t("command.session.previous.unseen"),
keybind: "shift+alt+arrowup",
onSelect: () => selectAdjacentTab(-1, (key) => !!unread[key]),
},
{
id: "session.next.unseen",
category: language.t("command.category.session"),
title: language.t("command.session.next.unseen"),
keybind: "shift+alt+arrowdown",
onSelect: () => selectAdjacentTab(1, (key) => !!unread[key]),
},
])
function selectAdjacentTab(offset: -1 | 1) {
function selectAdjacentTab(offset: -1 | 1, include?: (key: string) => boolean) {
const current = props.currentTab
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset)
const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset, include)
const next = props.tabs.find((tab) => tabKey(tab) === key)
if (next) props.onNavigate(next)
@@ -390,6 +437,7 @@ export function TitlebarTabStrip(props: {
orientation={vertical() ? "vertical" : "horizontal"}
serverCtx={serverCtx()}
onVisibleChange={(visible) => setVisibility(id, visible)}
onUnreadChange={(value) => setUnread(id, value)}
onNavigate={(element) => {
ref = element
props.onNavigate(tab, element)
@@ -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)
+10 -1
View File
@@ -43,7 +43,16 @@ export function filename(channel = OPENCODE_CHANNEL) {
return `service-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.json`
}
export function defaultPort(channel = OPENCODE_CHANNEL) {
export function defaultPort(channel = OPENCODE_CHANNEL, platform = process.platform) {
const port = channelPort(channel)
// Windows hands out 0xC000-0xFFFF for outgoing connections, and Hyper-V and WinNAT reserve blocks of that range at
// boot, so a port there can fail as in use while nothing listens on it. Moving into the 0xB000 block keeps the low
// 12 bits: 0xC0DE becomes 0xB0DE, which is 0xC0DE - 4096, a nod to the v1 port.
if (platform === "win32" && port >= 0xc000) return 0xb000 | (port & 0xfff)
return port
}
function channelPort(channel: string) {
if (channel === "latest" || channel === "dev" || channel === "beta" || channel === "next") return 0xc0de
if (channel === "local") return 0xc0df
return 10_000 + (Number.parseInt(Hash.fast(channel).slice(0, 8), 16) % 50_000)
+21 -7
View File
@@ -18,13 +18,27 @@ import { testEffect } from "../../core/test/lib/effect"
const it = testEffect(NodeFileSystem.layer)
test("managed service ports are stable per installation channel", () => {
expect(ServiceConfig.defaultPort("latest")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("dev")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("beta")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("preview-a")).toBe(ServiceConfig.defaultPort("preview-a"))
expect(ServiceConfig.defaultPort("preview-a")).not.toBe(ServiceConfig.defaultPort("preview-b"))
expect(ServiceConfig.defaultPort("latest", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("dev", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("beta", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("next", "linux")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("local", "linux")).toBe(0xc0df)
expect(ServiceConfig.defaultPort("latest", "darwin")).toBe(0xc0de)
expect(ServiceConfig.defaultPort("preview-a", "linux")).toBe(ServiceConfig.defaultPort("preview-a", "linux"))
expect(ServiceConfig.defaultPort("preview-a", "linux")).not.toBe(ServiceConfig.defaultPort("preview-b", "linux"))
})
test("managed service ports on Windows stay below the range Windows reserves", () => {
expect(ServiceConfig.defaultPort("latest", "win32")).toBe(0xc0de - 4096)
expect(ServiceConfig.defaultPort("dev", "win32")).toBe(0xc0de - 4096)
expect(ServiceConfig.defaultPort("local", "win32")).toBe(0xc0df - 4096)
const ports = Array.from({ length: 200 }, (_, index) => ({
windows: ServiceConfig.defaultPort(`preview-${index}`, "win32"),
linux: ServiceConfig.defaultPort(`preview-${index}`, "linux"),
}))
expect(ports.some((port) => port.linux >= 0xc000)).toBe(true)
expect(ports.every((port) => port.windows >= 10_000 && port.windows < 0xc000)).toBe(true)
expect(ports.filter((port) => port.linux < 0xc000 && port.windows !== port.linux)).toEqual([])
})
test("service disabled accepts only booleans without changing configuration on invalid input", async () => {
+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)
})
+2 -1
View File
@@ -6,8 +6,9 @@
"scripts": {
"typecheck": "tsgo --noEmit",
"dev": "vite dev --host 0.0.0.0",
"dev:enterprise": "bun --watch script/dev-enterprise.ts",
"dev:remote": "bun sst shell --stage=dev bun dev",
"build": "bun ./script/generate-sitemap.ts && vite build",
"build": "bun ./script/generate-sitemap.ts && vite build && bun ../../schema/script/config-schema.ts ./.output/public/config.json",
"start": "vite start"
},
"dependencies": {
@@ -0,0 +1,99 @@
import { handleEnterpriseRequest } from "../src/lib/enterprise"
import { z } from "zod"
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
import { handleConsoleSignup } from "../src/lib/console-signup"
import { consoleAccountSchema } from "../src/lib/console-account"
const org = process.env.SF_TARGET_ORG || "opencode"
const crmToken = process.env.CONSOLE_CRM_TOKEN
if (!crmToken) throw new Error("Set CONSOLE_CRM_TOKEN to the shared local Console CRM token")
const accountUrl = process.env.CONSOLE_CRM_LOOKUP_URL || "http://localhost:3100/console/api/internal/console-account"
async function authenticatedRequest(
path: string,
payload?: Record<string, unknown>,
method: "GET" | "POST" | "PATCH" = payload ? "POST" : "GET",
) {
const child = Bun.spawn(
[
"sf",
"api",
"request",
"rest",
path,
"--target-org",
org,
"--json",
"--method",
method,
...(payload ? ["--body", "-", "--header", "Content-Type:application/json"] : []),
],
{
stdin: payload ? new Blob([JSON.stringify(payload)]) : "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [output] = await Promise.all([
new Response(child.stdout).text(),
child.exited,
new Response(child.stderr).text(),
])
return salesforceCliResponse(output)
}
await authenticatedRequest("/services/data/v59.0/")
const server = Bun.serve({
hostname: "127.0.0.1",
port: Number(process.env.ENTERPRISE_DEV_PORT || 3101),
async fetch(request) {
const path = new URL(request.url).pathname
if (path === "/health" && request.method === "GET") {
return Response.json({ handler: "local-sst-enterprise", salesforceOrg: org })
}
if (path === "/api/console-signup" && request.method === "POST") {
return handleConsoleSignup(request, crmToken, (path, method, payload) =>
authenticatedRequest(path, payload, method),
)
}
if (path !== "/api/enterprise") return new Response("Not found", { status: 404 })
if (request.method !== "POST") return new Response("Method not allowed", { status: 405 })
const failures: string[] = []
const response = await handleEnterpriseRequest(request, {
salesforceSubmit: async (payload) => {
const result = await authenticatedRequest("/services/data/v59.0/sobjects/Lead", payload).catch(
(error: unknown) => {
const code = error instanceof SalesforceCliError ? error.code : "CLI_ERROR"
failures.push(code)
console.error(`Local Salesforce submission rejected: ${code}`)
return undefined
},
)
return z.object({ success: z.literal(true) }).safeParse(result).success
},
notifications: false,
accountLookup: async (email) => {
const response = await fetch(accountUrl, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${crmToken}` },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(8000),
})
if (!response.ok) throw new Error(`Local Console account lookup failed (${response.status})`)
return z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json()).account
},
})
if (failures.length > 0) {
return Response.json(
{ error: "Salesforce rejected the request.", errorCode: failures[0] },
{ status: 502, headers: { "X-Enterprise-Handler": "local-sst-enterprise" } },
)
}
response.headers.set("X-Enterprise-Handler", "local-sst-enterprise")
return response
},
})
console.log(`Local Enterprise handler: ${server.url.origin}/api/enterprise`)
console.log(`Salesforce CLI org: ${org}; local email and EmailOctopus delivery disabled.`)
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test"
import { SalesforceCliError, salesforceCliResponse } from "./salesforce-cli-response"
test("unwraps a successful Salesforce Lead creation from the CLI HTTP envelope", () => {
expect(
salesforceCliResponse(
JSON.stringify({
status: 0,
result: { statusCode: 201, headers: {}, body: { id: "test-lead", success: true, errors: [] } },
}),
),
).toEqual({ id: "test-lead", success: true, errors: [] })
})
test("rejects a Salesforce HTTP failure even if the CLI exits successfully", () => {
expect(() =>
salesforceCliResponse(
JSON.stringify({
status: 0,
result: { statusCode: 400, body: [{ errorCode: "INVALID_OR_NULL_FOR_RESTRICTED_PICKLIST" }] },
}),
),
).toThrow()
})
test("rejects CLI errors and responses missing the HTTP envelope", () => {
expect(() => salesforceCliResponse(JSON.stringify({ status: 1, message: "CLI error" }))).toThrow()
expect(() => salesforceCliResponse(JSON.stringify({ status: 0, result: { success: true } }))).toThrow()
})
test("preserves the Salesforce rejection code when the CLI exits nonzero", () => {
expect(() =>
salesforceCliResponse(
JSON.stringify({
status: 1,
result: { statusCode: 400, body: [{ errorCode: "DUPLICATES_DETECTED", message: "Duplicate found" }] },
}),
),
).toThrow(new SalesforceCliError("DUPLICATES_DETECTED"))
})
@@ -0,0 +1,24 @@
import { z } from "zod"
export class SalesforceCliError extends Error {
constructor(readonly code: string) {
super(`Salesforce request failed: ${code}`)
}
}
export function salesforceCliResponse(output: string) {
const response = z
.object({
status: z.number(),
result: z.object({ statusCode: z.number().int(), body: z.unknown() }).optional(),
})
.parse(JSON.parse(output))
if (!response.result) throw new SalesforceCliError("CLI_ERROR")
if (response.status !== 0 || response.result.statusCode < 200 || response.result.statusCode > 299) {
const errors = z
.array(z.object({ errorCode: z.string().regex(/^[A-Z][A-Z0-9_]+$/) }))
.safeParse(response.result.body)
throw new SalesforceCliError(errors.success ? (errors.data[0]?.errorCode ?? "REQUEST_FAILED") : "REQUEST_FAILED")
}
return response.result.body
}
-1
View File
@@ -299,7 +299,6 @@ export const dict = {
"تُنشأ سجلات مراقبة إساءة الاستخدام لكل استخدام لميزات API، ويُحتفظ بها لمدة تصل إلى 30 يومًا.",
"go.faq.a5.museRetention":
"أسعار توكنات مخفّضة للغاية مقابل منح الإذن باستخدام مطالباتك وإكمالات النموذج لتدريب نماذج Meta المستقبلية.",
"go.faq.a5.catJamRetention": "خلال فترته المجانية، قد تُستخدم البيانات التي يتم جمعها لتحسين النموذج.",
"go.faq.a5.geminiRetention": "قد تُستخدم مطالباتك وإكمالات النموذج لتدريب نماذج Google المستقبلية.",
"go.faq.a5.learnMore": "اعرف المزيد",
"go.faq.a5.deepseekRetention": "تُجدَّد اتفاقية ZDR شهريًا. الاتفاقية الحالية سارية حتى 31 أكتوبر 2026.",
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"Logs de monitoramento de abuso são gerados para todo uso de recursos da API e retidos por até 30 dias.",
"go.faq.a5.museRetention":
"Preços de tokens com grandes descontos em troca da permissão para usar seus prompts e respostas geradas para treinar futuros modelos da Meta.",
"go.faq.a5.catJamRetention": "Durante o período gratuito, os dados coletados podem ser usados para melhorar o modelo.",
"go.faq.a5.geminiRetention":
"Seus prompts e respostas geradas podem ser usados para treinar futuros modelos do Google.",
"go.faq.a5.learnMore": "Saiba mais",
-1
View File
@@ -304,7 +304,6 @@ export const dict = {
"Logfiler til overvågning af misbrug genereres ved al brug af API-funktioner og opbevares i op til 30 dage.",
"go.faq.a5.museRetention":
"Kraftigt nedsatte tokenpriser til gengæld for tilladelse til at bruge dine prompts og modelsvar til at træne fremtidige Meta-modeller.",
"go.faq.a5.catJamRetention": "I gratisperioden kan indsamlede data blive brugt til at forbedre modellen.",
"go.faq.a5.geminiRetention": "Dine prompts og modelsvar kan blive brugt til at træne fremtidige Google-modeller.",
"go.faq.a5.learnMore": "Læs mere",
"go.faq.a5.deepseekRetention":
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"Für die Nutzung aller API-Funktionen werden Protokolle zur Missbrauchsüberwachung erstellt und bis zu 30 Tage lang aufbewahrt.",
"go.faq.a5.museRetention":
"Stark vergünstigte Tokenpreise im Gegenzug für die Erlaubnis, deine Prompts und Vervollständigungen zum Trainieren zukünftiger Meta-Modelle zu verwenden.",
"go.faq.a5.catJamRetention": "Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden.",
"go.faq.a5.geminiRetention":
"Deine Prompts und Vervollständigungen können zum Trainieren zukünftiger Google-Modelle verwendet werden.",
"go.faq.a5.learnMore": "Mehr erfahren",
-1
View File
@@ -304,7 +304,6 @@ export const dict = {
"Heavily discounted token pricing in exchange for permission to use your prompts and completions to train future Meta models.",
"go.faq.a5.deepseekRetention":
"ZDR agreement is renewed monthly. The current agreement is valid through October 31, 2026.",
"go.faq.a5.catJamRetention": "During its free period, collected data may be used to improve the model.",
"go.faq.a5.geminiRetention": "Your prompts and completions may be used to train future Google models.",
"go.faq.a5.learnMore": "Learn more",
-1
View File
@@ -307,7 +307,6 @@ export const dict = {
"Se generan registros de supervisión de abusos para todo el uso de funciones de la API y se conservan durante un máximo de 30 días.",
"go.faq.a5.museRetention":
"Precios de tokens muy reducidos a cambio de permitir que tus prompts y las respuestas generadas se utilicen para entrenar futuros modelos de Meta.",
"go.faq.a5.catJamRetention": "Durante su periodo gratuito, los datos recopilados pueden usarse para mejorar el modelo.",
"go.faq.a5.geminiRetention":
"Tus prompts y las respuestas generadas pueden utilizarse para entrenar futuros modelos de Google.",
"go.faq.a5.learnMore": "Más información",
-1
View File
@@ -309,7 +309,6 @@ export const dict = {
"Des journaux de surveillance des abus sont générés pour toute utilisation des fonctionnalités API et conservés pendant un maximum de 30 jours.",
"go.faq.a5.museRetention":
"Des tarifs de tokens fortement réduits en échange de l’autorisation d’utiliser vos prompts et vos complétions pour entraîner de futurs modèles Meta.",
"go.faq.a5.catJamRetention": "Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle.",
"go.faq.a5.geminiRetention":
"Vos prompts et vos complétions peuvent être utilisés pour entraîner de futurs modèles Google.",
"go.faq.a5.learnMore": "En savoir plus",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ZDR disabilita importanti funzionalità API che dipendono dai dati archiviati, tra cui la Responses API con stato, Files and Collections e Batch API.",
"go.faq.a5.gptRetention":
"I log di monitoraggio degli abusi vengono generati per l'utilizzo di tutte le funzionalità API e conservati per un massimo di 30 giorni.",
"go.faq.a5.catJamRetention": "Durante il periodo gratuito, i dati raccolti possono essere utilizzati per migliorare il modello.",
"go.faq.a5.geminiRetention":
"I tuoi prompt e completamenti possono essere utilizzati per addestrare futuri modelli Google.",
"go.faq.a5.learnMore": "Scopri di più",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"go.faq.a5.gptRetention": "不正使用監視ログはすべてのAPI機能の使用時に生成され、最大30日間保持されます。",
"go.faq.a5.museRetention":
"将来のMetaモデルのトレーニングにプロンプトと生成結果を使用する許可と引き換えに、トークン料金が大幅に割引されます。",
"go.faq.a5.catJamRetention": "無料提供期間中、収集されたデータはモデルの改善に使用される場合があります。",
"go.faq.a5.geminiRetention": "プロンプトと生成結果は、将来のGoogleモデルのトレーニングに使用される場合があります。",
"go.faq.a5.learnMore": "詳しく見る",
"go.faq.a5.deepseekRetention": "ZDR契約は毎月更新されます。現在の契約は2026年10月31日まで有効です。",
-1
View File
@@ -297,7 +297,6 @@ export const dict = {
"go.faq.a5.gptRetention": "모든 API 기능 사용에 대해 악용 모니터링 로그가 생성되며 최대 30일 동안 보존됩니다.",
"go.faq.a5.museRetention":
"향후 Meta 모델 학습에 사용자의 프롬프트와 생성 결과를 사용할 수 있도록 허용하는 대신 토큰 가격이 대폭 할인됩니다.",
"go.faq.a5.catJamRetention": "무료 제공 기간 동안 수집된 데이터는 모델 개선에 사용될 수 있습니다.",
"go.faq.a5.geminiRetention": "사용자의 프롬프트와 생성 결과는 향후 Google 모델 학습에 사용될 수 있습니다.",
"go.faq.a5.learnMore": "자세히 알아보기",
"go.faq.a5.deepseekRetention": "ZDR 계약은 매월 갱신됩니다. 현재 계약은 2026년 10월 31일까지 유효합니다.",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ZDR deaktiverer viktige API-funksjoner som er avhengige av lagrede data, inkludert den tilstandsbaserte Responses API, Files and Collections og Batch API.",
"go.faq.a5.gptRetention":
"Logger for overvåking av misbruk genereres for all bruk av API-funksjoner og oppbevares i opptil 30 dager.",
"go.faq.a5.catJamRetention": "I gratisperioden kan innsamlede data brukes til å forbedre modellen.",
"go.faq.a5.geminiRetention": "Ledetekstene og fullføringene dine kan brukes til å trene fremtidige Google-modeller.",
"go.faq.a5.learnMore": "Les mer",
"go.faq.a5.deepseekRetention":
-1
View File
@@ -303,7 +303,6 @@ export const dict = {
"ZDR wyłącza ważne funkcje API zależne od przechowywanych danych, w tym stanowy Responses API, Files and Collections oraz Batch API.",
"go.faq.a5.gptRetention":
"Dzienniki monitorowania nadużyć są generowane dla każdego użycia funkcji API i przechowywane przez maksymalnie 30 dni.",
"go.faq.a5.catJamRetention": "W okresie bezpłatnego dostępu zebrane dane mogą być wykorzystywane do ulepszania modelu.",
"go.faq.a5.geminiRetention":
"Twoje prompty i odpowiedzi mogą być wykorzystywane do trenowania przyszłych modeli Google.",
"go.faq.a5.learnMore": "Dowiedz się więcej",
-1
View File
@@ -306,7 +306,6 @@ export const dict = {
"ZDR отключает важные функции API, зависящие от сохраненных данных, включая Responses API с сохранением состояния, Files and Collections и Batch API.",
"go.faq.a5.gptRetention":
"Журналы мониторинга злоупотреблений создаются при любом использовании функций API и хранятся до 30 дней.",
"go.faq.a5.catJamRetention": "В течение бесплатного периода собранные данные могут использоваться для улучшения модели.",
"go.faq.a5.geminiRetention": "Ваши промпты и ответы могут использоваться для обучения будущих моделей Google.",
"go.faq.a5.learnMore": "Подробнее",
"go.faq.a5.deepseekRetention":
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ระบบจะสร้างบันทึกการตรวจสอบการใช้งานในทางที่ผิดสำหรับการใช้งานฟีเจอร์ API ทั้งหมด และเก็บรักษาไว้นานสูงสุด 30 วัน",
"go.faq.a5.museRetention":
"ราคาของ token ลดลงอย่างมาก โดยแลกกับการอนุญาตให้นำพรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณไปใช้ฝึกโมเดล Meta ในอนาคต",
"go.faq.a5.catJamRetention": "ในช่วงที่ให้ใช้ฟรี ข้อมูลที่รวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล",
"go.faq.a5.geminiRetention": "พรอมต์และผลลัพธ์ที่สร้างขึ้นของคุณอาจถูกนำไปใช้ฝึกโมเดล Google ในอนาคต",
"go.faq.a5.learnMore": "ดูข้อมูลเพิ่มเติม",
"go.faq.a5.deepseekRetention": "ข้อตกลง ZDR จะต่ออายุทุกเดือน ข้อตกลงปัจจุบันมีผลใช้ถึงวันที่ 31 ตุลาคม 2026",
-1
View File
@@ -304,7 +304,6 @@ export const dict = {
"ZDR, durum bilgisi tutan Responses API, Files and Collections ve Batch API dahil olmak üzere saklanan verilere bağlı önemli API özelliklerini devre dışı bırakır.",
"go.faq.a5.gptRetention":
"Tüm API özelliklerinin kullanımı için kötüye kullanım izleme günlükleri oluşturulur ve 30 güne kadar saklanır.",
"go.faq.a5.catJamRetention": "Ücretsiz dönem boyunca toplanan veriler modeli geliştirmek için kullanılabilir.",
"go.faq.a5.geminiRetention":
"İstemleriniz ve tamamlamalarınız gelecekteki Google modellerini eğitmek için kullanılabilir.",
"go.faq.a5.learnMore": "Daha fazla bilgi",
-1
View File
@@ -302,7 +302,6 @@ export const dict = {
"ZDR вимикає важливі функції API, які залежать від збережених даних, зокрема Responses API зі збереженням стану, Files and Collections та Batch API.",
"go.faq.a5.gptRetention":
"Журнали моніторингу зловживань створюються для всіх випадків використання функцій API та зберігаються до 30 днів.",
"go.faq.a5.catJamRetention": "Протягом безкоштовного періоду зібрані дані можуть використовуватися для покращення моделі.",
"go.faq.a5.geminiRetention":
"Ваші промпти та відповіді можуть використовуватися для навчання майбутніх моделей Google.",
"go.faq.a5.learnMore": "Докладніше",
-1
View File
@@ -288,7 +288,6 @@ export const dict = {
"ZDR 会禁用依赖所存储数据的重要 API 功能,包括有状态的 Responses API、Files and Collections 和 Batch API。",
"go.faq.a5.gptRetention": "所有 API 功能的使用都会生成滥用监控日志,并最多保留 30 天。",
"go.faq.a5.museRetention": "以允许使用你的提示词和补全结果训练未来的 Meta 模型为交换,token 价格可获得大幅折扣。",
"go.faq.a5.catJamRetention": "在免费期间,收集的数据可能会用于改进模型。",
"go.faq.a5.geminiRetention": "你的提示词和模型输出可能会被用于训练未来的 Google 模型。",
"go.faq.a5.learnMore": "了解更多",
"go.faq.a5.deepseekRetention": "ZDR 协议每月续签。当前协议有效期至 2026 年 10 月 31 日。",
-1
View File
@@ -287,7 +287,6 @@ export const dict = {
"ZDR 會停用依賴儲存資料的重要 API 功能,包括具狀態的 Responses API、Files and Collections 與 Batch API。",
"go.faq.a5.gptRetention": "所有 API 功能的使用都會產生濫用監控日誌,並保留最多 30 天。",
"go.faq.a5.museRetention": "以允許使用您的提示詞和生成結果來訓練未來的 Meta 模型為交換,token 價格可享大幅折扣。",
"go.faq.a5.catJamRetention": "在免費期間,收集的資料可能會用於改進模型。",
"go.faq.a5.geminiRetention": "你的提示詞和模型輸出可能會被用於訓練未來的 Google 模型。",
"go.faq.a5.learnMore": "了解更多",
"go.faq.a5.deepseekRetention": "ZDR 協議每月續簽。目前的協議有效至 2026 年 10 月 31 日。",
@@ -0,0 +1,31 @@
import { Resource } from "@opencode/console-resource"
import { z } from "zod"
export const consoleAccountSchema = z.object({
id: z.string().min(1).max(255),
email: z.email(),
name: z.string().nullable(),
createdAt: z.iso.datetime(),
})
export type ConsoleAccount = z.infer<typeof consoleAccountSchema>
export async function lookupConsoleAccount(email: string): Promise<ConsoleAccount | null> {
const response = await fetch(`${Resource.CONSOLE_CRM_LOOKUP_URL.value}`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${Resource.CONSOLE_CRM_TOKEN.value}` },
body: JSON.stringify({ email }),
signal: AbortSignal.timeout(3000),
})
if (!response.ok) throw new Error(`Console account lookup failed (${response.status})`)
const result = z.object({ account: consoleAccountSchema.nullable() }).parse(await response.json())
return result.account
}
export function consoleAccountFields(account: ConsoleAccount | null) {
if (!account) return { Has_Console_Account__c: false }
return {
Has_Console_Account__c: true,
Console_Account_ID__c: account.id,
Console_Signup_Date__c: account.createdAt,
}
}
@@ -0,0 +1,159 @@
import { afterEach, expect, test } from "bun:test"
import { syncConsoleAccount, type SalesforceRequest } from "./salesforce"
import { handleConsoleSignup } from "./console-signup"
import { z } from "zod"
const account = {
id: "user_console_test",
email: "person@example.test",
name: "Test Person",
createdAt: "2026-10-01T10:00:00.000Z",
}
const servers = new Set<ReturnType<typeof Bun.serve>>()
afterEach(async () => {
await Promise.all([...servers].map((server) => server.stop(true)))
servers.clear()
})
function fixture(
initial: { object: "Lead" | "Contact"; Id: string; LeadSource: string; Company?: string; AccountId?: string }[] = [],
email = account.email,
) {
const people: Record<string, unknown>[] = initial.map((person) => ({ ...person }))
const writes: { method: string; path: string; payload: Record<string, unknown> }[] = []
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request) {
const url = new URL(request.url)
if (url.pathname.endsWith("/query")) {
const query = url.searchParams.get("q") ?? ""
expect(query).toContain("Console_Account_ID__c")
expect(query).toContain(`Email = '${email.toLowerCase()}'`)
const object = query.includes("FROM Contact") ? "Contact" : "Lead"
if (object === "Lead") expect(query).toContain("IsConverted = false")
return Response.json({
done: true,
records: people.filter((person) => person.object === object).map((person) => ({ Id: person.Id })),
})
}
const payload = z.record(z.string(), z.unknown()).parse(await request.json())
writes.push({ method: request.method, path: url.pathname, payload })
if (request.method === "PATCH") {
const person = people.find((person) => person.Id === url.pathname.split("/").at(-1))
if (!person) return Response.json({ error: "Not found" }, { status: 404 })
Object.assign(person, payload)
return new Response(null, { status: 204 })
}
const id = "00Q000000000001AAA"
people.push({ object: "Lead", Id: id, ...payload })
return Response.json({ success: true, id }, { status: 201 })
},
})
servers.add(server)
const request: SalesforceRequest = async (path, method = "GET", payload) => {
const response = await fetch(new URL(path, server.url), {
method,
...(payload ? { body: JSON.stringify(payload), headers: { "Content-Type": "application/json" } } : {}),
})
if (!response.ok) throw new Error("CRM request failed")
return response.status === 204 ? null : response.json()
}
return { people, writes, request }
}
test("a new signup creates one Console lead and an exact retry updates it", async () => {
const crm = fixture()
await syncConsoleAccount(account, crm.request)
await syncConsoleAccount(account, crm.request)
expect(crm.people).toHaveLength(1)
expect(crm.writes.filter((write) => write.method === "POST")).toHaveLength(1)
expect(crm.people[0]).toMatchObject({
Company: "example.test",
LeadSource: "console signup",
Has_Console_Account__c: true,
Console_Account_ID__c: account.id,
Console_Signup_Date__c: account.createdAt,
})
expect(crm.writes[1].payload).not.toHaveProperty("LeadSource")
expect(crm.writes[1].payload).not.toHaveProperty("Company")
})
test.each([
["hello@acme.com", "acme.com"],
["jane@company.co.uk", "company.co.uk"],
["HELLO@ACME.COM", "acme.com"],
["person@gmail.com", "Individual"],
["person+signup@GOOGLEMAIL.COM", "Individual"],
["person@outlook.com", "Individual"],
["person@hotmail.co.uk", "Individual"],
["person@yahoo.com", "Individual"],
["person@icloud.com", "Individual"],
["person@proton.me", "Individual"],
["person@protonmail.com", "Individual"],
["person@pm.me", "Individual"],
["person@aol.com", "Individual"],
["person@gmail.com.example.test", "gmail.com.example.test"],
])("sets Company for a new signup with %s to %s", async (email, company) => {
const crm = fixture([], email)
await syncConsoleAccount({ ...account, email }, crm.request)
expect(crm.people).toHaveLength(1)
expect(crm.people[0]).toMatchObject({ Company: company, Email: email.toLowerCase(), LeadSource: "console signup" })
})
test("matching Leads and Contacts keep their source and receive the Console fields", async () => {
const crm = fixture([
{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Partner", AccountId: "001000000000001AAA" },
{ object: "Lead", Id: "00Q000000000002AAA", LeadSource: "enterprise website form", Company: "Visitor's Company" },
])
await syncConsoleAccount(account, crm.request)
expect(crm.writes.every((write) => write.method === "PATCH")).toBe(true)
expect(crm.people.map((person) => person.LeadSource)).toEqual(["Partner", "enterprise website form"])
expect(crm.people[0].AccountId).toBe("001000000000001AAA")
expect(crm.people[1].Company).toBe("Visitor's Company")
for (const write of crm.writes) {
expect(write.payload).not.toHaveProperty("Company")
expect(write.payload).not.toHaveProperty("AccountId")
}
for (const person of crm.people)
expect(person).toMatchObject({
Has_Console_Account__c: true,
Console_Account_ID__c: account.id,
Console_Signup_Date__c: account.createdAt,
})
})
test("a converted person's Contact is updated without creating another Lead", async () => {
const crm = fixture([{ object: "Contact", Id: "003000000000001AAA", LeadSource: "Website" }])
await syncConsoleAccount(account, crm.request)
expect(crm.people).toHaveLength(1)
expect(crm.writes[0].path).toContain("/Contact/")
expect(crm.people[0].LeadSource).toBe("Website")
})
test("the signup endpoint requires its server credential before touching Salesforce", async () => {
const crm = fixture()
const response = await handleConsoleSignup(
new Request("http://localhost/api/console-signup", { method: "POST", body: JSON.stringify(account) }),
"test-shared-token",
crm.request,
)
expect(response.status).toBe(401)
expect(crm.writes).toHaveLength(0)
})
test("the authenticated signup endpoint reports confirmed delivery", async () => {
const crm = fixture()
const response = await handleConsoleSignup(
new Request("http://localhost/api/console-signup", {
method: "POST",
headers: { Authorization: "Bearer test-shared-token" },
body: JSON.stringify(account),
}),
"test-shared-token",
crm.request,
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ success: true })
expect(crm.people).toHaveLength(1)
})
@@ -0,0 +1,19 @@
import { timingSafeEqual } from "node:crypto"
import { consoleAccountSchema } from "~/lib/console-account"
import { salesforceRequest, syncConsoleAccount, type SalesforceRequest } from "~/lib/salesforce"
export async function handleConsoleSignup(request: Request, token: string, salesforce?: SalesforceRequest) {
const expected = Buffer.from(`Bearer ${token}`)
const actual = Buffer.from(request.headers.get("authorization") ?? "")
if (!token || expected.length !== actual.length || !timingSafeEqual(expected, actual))
return new Response(null, { status: 401 })
const data: unknown = await request.json().catch(() => null)
const account = consoleAccountSchema.safeParse(data)
if (!account.success) return Response.json({ error: "Invalid Console account" }, { status: 400 })
return Promise.resolve(salesforce)
.then(async (client) => syncConsoleAccount(account.data, client ?? (await salesforceRequest())))
.then(
(result) => Response.json(result),
() => Response.json({ error: "Salesforce sync unavailable" }, { status: 503 }),
)
}
+156
View File
@@ -0,0 +1,156 @@
import { sendEmail } from "~/lib/aws"
import { Resource } from "@opencode/console-resource"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
import { inferenceSpendOptions } from "~/lib/inference-spend"
import { lookupConsoleAccount, type ConsoleAccount } from "~/lib/console-account"
interface EnterpriseFormData {
name: string
role: string
company?: string
email: string
phone?: string
inferenceSpend?: string
alias?: string
message: string
}
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
function splitFullName(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter((p) => p.length > 0)
if (parts.length === 0) return { firstName: "", lastName: "" }
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
}
function subscribe(email: string, fullName: string) {
const name = splitFullName(fullName)
const fields: Record<string, string> = {}
if (name.firstName) fields.FirstName = name.firstName
if (name.lastName) fields.LastName = name.lastName
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
if (Object.keys(fields).length) payload.fields = fields
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
method: "PUT",
headers: {
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}).then(
async (res) => {
if (!res.ok) {
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
return false
}
return true
},
(err) => {
console.error("Failed to subscribe enterprise email:", err)
return false
},
)
}
export async function handleEnterpriseRequest(
request: Request,
options: {
salesforceSubmit?: (payload: Record<string, unknown>) => Promise<boolean>
notifications?: boolean
accountLookup?: (email: string) => Promise<ConsoleAccount | null>
} = {},
) {
const dict = i18n(localeFromRequest(request))
try {
const body = (await request.json()) as EnterpriseFormData
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
if (trap) {
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
if (!body.name || !body.role || !body.email || !body.message) {
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
}
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
if (body.inferenceSpend && !inferenceSpend) {
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
}
const account = await (options.accountLookup ?? lookupConsoleAccount)(body.email).catch((err) => {
console.error("Failed to look up Console account:", err)
return undefined
})
const emailContent = `
${body.message}<br><br>
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
--<br>
${body.name}<br>
${body.role}<br>
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
const [lead, mail, octopus] = await Promise.all([
createLead(
{
name: body.name,
role: body.role,
company: body.company,
email: body.email,
phone: body.phone,
inferenceSpend: inferenceSpend?.salesforceValue,
message: body.message,
consoleAccount: account,
},
options.salesforceSubmit,
).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
return false
}),
options.notifications === false
? Promise.resolve(false)
: sendEmail({
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
}).then(
() => true,
(err) => {
console.error("Failed to send enterprise email:", err)
return false
},
),
options.notifications === false ? Promise.resolve(false) : subscribe(body.email, body.name),
])
if (!lead && !mail && !octopus) {
if (import.meta.env.DEV) {
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
} catch (error) {
console.error("Error processing enterprise form:", error)
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
}
@@ -0,0 +1,112 @@
import { afterAll, expect, test } from "bun:test"
const leads: Record<string, unknown>[] = []
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
async fetch(request, instance) {
const path = new URL(request.url).pathname
if (path === "/services/oauth2/token") {
return Response.json({ access_token: "local-test-token", instance_url: instance.url.origin })
}
if (path === "/services/data/v59.0/sobjects/Lead" && request.method === "POST") {
leads.push(await request.json())
return Response.json({ id: "local-test-lead", success: true }, { status: 201 })
}
return new Response("Not found", { status: 404 })
},
})
const resources = {
SST_RESOURCE_SALESFORCE_INSTANCE_URL: server.url.origin,
SST_RESOURCE_SALESFORCE_CLIENT_ID: "local-test-client",
SST_RESOURCE_SALESFORCE_CLIENT_SECRET: "local-test-secret",
}
const previous = Object.fromEntries(Object.keys(resources).map((key) => [key, process.env[key]]))
Object.entries(resources).forEach(([key, value]) => {
process.env[key] = JSON.stringify({ value })
})
afterAll(async () => {
await server.stop(true)
Object.entries(previous).forEach(([key, value]) => {
if (value === undefined) {
delete process.env[key]
return
}
process.env[key] = value
})
})
test("a fake entry keeps the visitor message, spend range, and enterprise lead source in their own fields", async () => {
const { createLead } = await import("./salesforce")
const accepted = await createLead({
name: "Fake Console Visitor",
role: "Engineering lead",
company: "Example Test Company",
email: "fake-visitor@example.test",
inferenceSpend: "$10K–$50K",
message: " We need SSO for our developers.\nPlease share the enterprise options. ",
})
expect(accepted).toBe(true)
expect(leads).toHaveLength(1)
expect(leads[0]).toEqual({
LastName: "Fake Console Visitor",
Company: "Example Test Company",
Email: "fake-visitor@example.test",
Phone: null,
Title: "Engineering lead",
Description: " We need SSO for our developers.\nPlease share the enterprise options. ",
Current_Monthly_Inference_Spend__c: "$10K–$50K",
LeadSource: "enterprise website form",
})
})
test("the local Enterprise request handler applies the updated mapping to a form submission", async () => {
const { handleEnterpriseRequest } = await import("./enterprise")
const response = await handleEnterpriseRequest(
new Request("http://localhost/api/enterprise", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Fake Local Form Visitor",
role: "Engineering lead",
company: "Example Test Company",
email: "fake-local-visitor@example.test",
inferenceSpend: "50k-100k",
message: " How are you bro!\nThis is a local form test. ",
alias: "",
}),
}),
{
salesforceSubmit: async (payload) => {
const response = await fetch(`${server.url.origin}/services/data/v59.0/sobjects/Lead`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
})
return response.ok
},
notifications: false,
accountLookup: async () => ({
id: "user_test",
email: "fake-local-visitor@example.test",
name: null,
createdAt: "2026-10-01T12:00:00.000Z",
}),
},
)
expect(response.status).toBe(200)
expect(await response.json()).toMatchObject({ success: true })
expect(leads.at(-1)).toMatchObject({
LastName: "Fake Local Form Visitor",
Description: " How are you bro!\nThis is a local form test. ",
Current_Monthly_Inference_Spend__c: "$50K–$100K",
LeadSource: "enterprise website form",
Has_Console_Account__c: true,
Console_Account_ID__c: "user_test",
Console_Signup_Date__c: "2026-10-01T12:00:00.000Z",
})
})
+130 -13
View File
@@ -1,4 +1,54 @@
import { Resource } from "@opencode/console-resource"
import { z } from "zod"
import { consoleAccountFields, type ConsoleAccount } from "./console-account"
// Match provider domains exactly; business domains hosted by these providers remain business addresses.
const personalEmailDomains = new Set([
"gmail.com",
"googlemail.com",
"outlook.com",
"outlook.co.uk",
"hotmail.com",
"hotmail.co.uk",
"hotmail.fr",
"hotmail.de",
"hotmail.it",
"live.com",
"live.co.uk",
"live.ca",
"live.com.au",
"msn.com",
"yahoo.com",
"yahoo.co.uk",
"yahoo.co.in",
"yahoo.com.au",
"yahoo.ca",
"yahoo.fr",
"yahoo.de",
"yahoo.it",
"yahoo.co.jp",
"ymail.com",
"rocketmail.com",
"icloud.com",
"me.com",
"mac.com",
"proton.me",
"protonmail.com",
"pm.me",
"aol.com",
"aim.com",
"gmx.com",
"gmx.de",
"gmx.net",
"mail.com",
"fastmail.com",
"fastmail.fm",
"hey.com",
"tuta.com",
"tuta.io",
"tutanota.com",
"tutanota.de",
])
async function login() {
const url = Resource.SALESFORCE_INSTANCE_URL.value.replace(/\/$/, "")
@@ -46,9 +96,25 @@ export interface SalesforceLeadInput {
phone?: string
inferenceSpend?: string
message: string
consoleAccount?: ConsoleAccount | null
}
export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
export async function createLead(
input: SalesforceLeadInput,
submit?: (payload: Record<string, unknown>) => Promise<boolean>,
): Promise<boolean> {
const payload = {
LastName: input.name,
Company: input.company?.trim() || "Website",
Email: input.email,
Phone: input.phone ?? null,
Title: input.role,
Description: input.message,
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
LeadSource: "enterprise website form",
...(input.consoleAccount === undefined ? {} : consoleAccountFields(input.consoleAccount)),
}
if (submit) return submit(payload)
const auth = await login()
if (!auth) return false
@@ -58,18 +124,7 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
Authorization: `Bearer ${auth.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
LastName: input.name,
Company: input.company?.trim() || "Website",
Email: input.email,
Phone: input.phone ?? null,
Title: input.role,
Description: input.inferenceSpend
? `Current monthly inference spend: ${input.inferenceSpend}\n\n${input.message}`
: input.message,
Current_Monthly_Inference_Spend__c: input.inferenceSpend,
LeadSource: "Website",
}),
body: JSON.stringify(payload),
}).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
})
@@ -83,3 +138,65 @@ export async function createLead(input: SalesforceLeadInput): Promise<boolean> {
return true
}
export type SalesforceRequest = (
path: string,
method?: "GET" | "POST" | "PATCH",
payload?: Record<string, unknown>,
) => Promise<unknown>
export async function salesforceRequest(): Promise<SalesforceRequest> {
const auth = await login()
if (!auth) throw new Error("Salesforce authentication unavailable")
return async (path, method = "GET", payload) => {
const response = await fetch(`${auth.url}${path}`, {
method,
headers: { Authorization: `Bearer ${auth.token}`, "Content-Type": "application/json" },
...(payload ? { body: JSON.stringify(payload) } : {}),
signal: AbortSignal.timeout(8000),
})
if (!response.ok) throw new Error(`Salesforce request failed (${response.status})`)
if (response.status === 204) return null
return response.json()
}
}
/** Update existing people without changing their acquisition source or sales notes. */
export async function syncConsoleAccount(account: ConsoleAccount, request: SalesforceRequest) {
const quote = (value: string) => value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")
const matches = async (object: "Contact" | "Lead") => {
const query = `SELECT Id FROM ${object} WHERE ${object === "Lead" ? "IsConverted = false AND " : ""}(Console_Account_ID__c = '${quote(account.id)}' OR Email = '${quote(account.email.toLowerCase())}')`
const records: { Id: string }[] = []
let path: string | undefined = `/services/data/v59.0/query?q=${encodeURIComponent(query)}`
while (path) {
const result = z
.object({
records: z.array(z.object({ Id: z.string().regex(/^[a-zA-Z0-9]{15,18}$/) })),
done: z.boolean(),
nextRecordsUrl: z.string().optional(),
})
.parse(await request(path))
records.push(...result.records)
if (result.done) break
if (!result.nextRecordsUrl?.startsWith("/services/data/")) throw new Error("Salesforce query pagination missing")
path = result.nextRecordsUrl
}
return records.map((record) => ({ object, id: record.Id }))
}
const people = [...(await matches("Contact")), ...(await matches("Lead"))]
const fields = consoleAccountFields(account)
for (const person of people)
await request(`/services/data/v59.0/sobjects/${person.object}/${person.id}`, "PATCH", fields)
if (people.length > 0) return { success: true, created: false, updated: people.length }
const domain = account.email.slice(account.email.lastIndexOf("@") + 1).toLowerCase()
const result = z.object({ success: z.literal(true), id: z.string() }).parse(
await request("/services/data/v59.0/sobjects/Lead", "POST", {
LastName: (account.name?.trim() || account.email).slice(0, 80),
Company: personalEmailDomains.has(domain) ? "Individual" : domain,
Email: account.email.toLowerCase(),
LeadSource: "console signup",
...fields,
}),
)
return { success: true, created: true, id: result.id }
}
@@ -0,0 +1,8 @@
import type { APIEvent } from "@solidjs/start/server"
import { Resource } from "@opencode/console-resource"
import { handleConsoleSignup } from "~/lib/console-signup"
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
export async function POST(event: APIEvent) {
return handleConsoleSignup(event.request, Resource.CONSOLE_CRM_TOKEN.value)
}
@@ -1,138 +1,7 @@
import type { APIEvent } from "@solidjs/start/server"
import { sendEmail } from "~/lib/aws"
import { Resource } from "@opencode/console-resource"
import { i18n } from "~/i18n"
import { localeFromRequest } from "~/lib/language"
import { createLead } from "~/lib/salesforce"
import { inferenceSpendOptions } from "~/lib/inference-spend"
interface EnterpriseFormData {
name: string
role: string
company?: string
email: string
phone?: string
inferenceSpend?: string
alias?: string
message: string
}
const EMAIL_OCTOPUS_LIST_ID = "1b381e5e-39bd-11f1-ba4a-cdd4791f0c43"
function splitFullName(fullName: string) {
const parts = fullName
.trim()
.split(/\s+/)
.filter((p) => p.length > 0)
if (parts.length === 0) return { firstName: "", lastName: "" }
if (parts.length === 1) return { firstName: parts[0], lastName: "" }
return { firstName: parts[0], lastName: parts.slice(1).join(" ") }
}
function subscribe(email: string, fullName: string) {
const name = splitFullName(fullName)
const fields: Record<string, string> = {}
if (name.firstName) fields.FirstName = name.firstName
if (name.lastName) fields.LastName = name.lastName
const payload: { email_address: string; fields?: Record<string, string> } = { email_address: email }
if (Object.keys(fields).length) payload.fields = fields
return fetch(`https://api.emailoctopus.com/lists/${EMAIL_OCTOPUS_LIST_ID}/contacts`, {
method: "PUT",
headers: {
Authorization: `Bearer ${Resource.EMAILOCTOPUS_API_KEY.value}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
}).then(
async (res) => {
if (!res.ok) {
console.error("EmailOctopus subscribe failed:", res.status, res.statusText, await res.text())
return false
}
return true
},
(err) => {
console.error("Failed to subscribe enterprise email:", err)
return false
},
)
}
import { handleEnterpriseRequest } from "~/lib/enterprise"
// SolidStart keeps only the method export from API routes, so the handler lives in lib.
export async function POST(event: APIEvent) {
const dict = i18n(localeFromRequest(event.request))
try {
const body = (await event.request.json()) as EnterpriseFormData
const trap = typeof body.alias === "string" ? body.alias.trim() : ""
if (trap) {
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
if (!body.name || !body.role || !body.email || !body.message) {
return Response.json({ error: dict["enterprise.form.error.allFieldsRequired"] }, { status: 400 })
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
if (!emailRegex.test(body.email)) {
return Response.json({ error: dict["enterprise.form.error.invalidEmailFormat"] }, { status: 400 })
}
const inferenceSpend = inferenceSpendOptions.find((option) => option.value === body.inferenceSpend)
if (body.inferenceSpend && !inferenceSpend) {
return Response.json({ error: dict["enterprise.form.error.invalidInferenceSpend"] }, { status: 400 })
}
const emailContent = `
${body.message}<br><br>
${inferenceSpend ? `Current monthly inference spend: ${inferenceSpend.salesforceValue}<br><br>` : ""}
--<br>
${body.name}<br>
${body.role}<br>
${body.company ? `${body.company}<br>` : ""}${body.email}<br>
${body.phone ? `${body.phone}<br>` : ""}`.trim()
const [lead, mail, octopus] = await Promise.all([
createLead({
name: body.name,
role: body.role,
company: body.company,
email: body.email,
phone: body.phone,
inferenceSpend: inferenceSpend?.salesforceValue,
message: body.message,
}).catch((err) => {
console.error("Failed to create Salesforce lead:", err)
return false
}),
sendEmail({
to: Resource.ENTERPRISE_SALES_INBOX_EMAIL.value,
subject: `Enterprise Inquiry from ${body.name}`,
body: emailContent,
replyTo: body.email,
}).then(
() => true,
(err) => {
console.error("Failed to send enterprise email:", err)
return false
},
),
subscribe(body.email, body.name),
])
if (!lead && !mail && !octopus) {
if (import.meta.env.DEV) {
console.warn("Enterprise inquiry accepted in dev mode without integrations", { email: body.email })
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
}
console.error("Enterprise inquiry delivery failed", { email: body.email })
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
return Response.json({ success: true, message: dict["enterprise.form.success.submitted"] }, { status: 200 })
} catch (error) {
console.error("Error processing enterprise form:", error)
return Response.json({ error: dict["enterprise.form.error.internalServer"] }, { status: 500 })
}
return handleEnterpriseRequest(event.request)
}
+1 -4
View File
@@ -35,7 +35,7 @@ const models = [
{ name: "Space Bunny", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "LongCat 2.5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "Step 5 Preview Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention0" },
{ name: "Cat Jam Free", training: "go.faq.a5.used", retention: "go.faq.a5.notZdr" },
{ name: "Cat Jam Free", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "Grok 4.7", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "Grok 4.6", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
{ name: "GPT 6 Luna", training: "go.faq.a5.notUsed", retention: "go.faq.a5.retention30" },
@@ -343,9 +343,6 @@ export default function Home() {
</a>
.
</p>
<p>
<strong>Cat Jam Free:</strong> {i18n.t("go.faq.a5.catJamRetention")}
</p>
<p>
<strong>DeepSeek:</strong> {i18n.t("go.faq.a5.deepseekRetention")}
</p>
+111 -15
View File
@@ -4,13 +4,14 @@ import { define } from "@opencode/plugin/effect/plugin"
import { FSUtil } from "@opencode/util/fs-util"
import { Global } from "@opencode/util/global"
import { sameDirectory } from "@opencode/util/path"
import { dirname, join } from "path"
import { Effect, PubSub, Semaphore, Stream } from "effect"
import path, { dirname, join } from "path"
import { Effect, FiberMap, PubSub, Semaphore, Stream } from "effect"
import { HttpClient, HttpClientRequest } from "effect/http"
import { Config } from "../../config.js"
import { Watcher } from "../../filesystem/watcher.js"
import { InstructionDiscovery } from "../../instruction-discovery.js"
import { Instructions } from "../../instructions/index.js"
import { Location } from "../../location.js"
import { AbsolutePath } from "../../schema.js"
type Loaded =
| { readonly type: "available"; readonly files: InstructionDiscovery.File[] }
@@ -18,17 +19,19 @@ type Loaded =
export const Plugin = define({
id: "opencode.config.instruction",
effect: Effect.fn(function* () {
effect: Effect.fn(function* (ctx) {
const discovery = yield* InstructionDiscovery.Service
// Nothing this plugin watches or loads can contribute when both scopes
// are disabled; skip the resolves, the watcher fiber, and the transform.
if (!discovery.project && !discovery.global) return
// Activate even when both discovery scopes are disabled: configured `instructions`
// entries are explicit requests, not discovery.
yield* Effect.gen(function* () {
const config = yield* Config.Service
const fs = yield* FSUtil.Service
const global = yield* Global.Service
const http = HttpClient.filterStatusOk(yield* HttpClient.HttpClient)
const location = yield* Location.Service
const watcher = yield* Watcher.Service
const changes = yield* PubSub.sliding<string>(1)
const watches = yield* FiberMap.make<string>()
const lock = Semaphore.makeUnsafe(1)
const start = yield* fs.resolve(location.directory)
const root = yield* fs.resolve(location.project.directory)
@@ -37,6 +40,10 @@ export const Plugin = define({
const stop = FSUtil.contains(home, start) ? home : root
const globalFile = yield* fs.resolve(join(global.config, "AGENTS.md"))
const loaded: { current: Loaded } = { current: { type: "available", files: [] } }
const configured: { sources: string[]; remote: Map<string, InstructionDiscovery.File> } = {
sources: [],
remote: new Map(),
}
const publish = (update: Watcher.Update) => PubSub.publish(changes, update.path).pipe(Effect.asVoid)
// The ancestor walk can reach the global file when the location sits
@@ -49,15 +56,15 @@ export const Plugin = define({
.filter((file) => discovery.global || file !== globalFile)
: []),
]
for (const path of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path, type: "file" })
for (const file of new Set(candidates)) {
const updates = yield* watcher.subscribe({ path: file, type: "file" })
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped({ startImmediately: true }))
}
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (path: string) {
const content = yield* fs.readFileStringSafe(path)
if (content !== undefined) return new InstructionDiscovery.File({ path: AbsolutePath.make(path), content })
yield* Effect.logDebug("instruction file skipped", { path, reason: "unavailable" })
const read = Effect.fn("ConfigInstructionPlugin.read")(function* (filepath: string) {
const content = yield* fs.readFileStringSafe(filepath)
if (content !== undefined) return new InstructionDiscovery.File({ path: filepath, content })
yield* Effect.logDebug("instruction file skipped", { path: filepath, reason: "unavailable" })
})
const globalSource = Effect.fn("ConfigInstructionPlugin.globalSource")(function* () {
@@ -78,6 +85,83 @@ export const Plugin = define({
return files.filter((file): file is InstructionDiscovery.File => file !== undefined)
})
// Relative patterns search upward from the location to the project root. Without
// project discovery they resolve against the global config directory instead.
const match = Effect.fn("ConfigInstructionPlugin.match")(function* (source: string) {
const expanded = source.startsWith("~/") ? path.join(global.home, source.slice(2)) : source
const options = { absolute: true, include: "file", dot: true } as const
if (path.isAbsolute(expanded))
return yield* fs
.scan(path.basename(expanded), { ...options, cwd: path.dirname(expanded) })
.pipe(Effect.orElseSucceed(() => [] as string[]))
if (project) return yield* fs.globUp(expanded, start, root)
if (!discovery.global) return []
return yield* fs
.scan(expanded, { ...options, cwd: global.config })
.pipe(Effect.orElseSucceed(() => [] as string[]))
})
const configuredSource = Effect.fn("ConfigInstructionPlugin.configuredSource")(function* () {
const files: InstructionDiscovery.File[] = []
const watched = new Set<string>()
for (const source of configured.sources) {
if (isUrl(source)) {
const file = configured.remote.get(source)
if (file) files.push(file)
continue
}
for (const item of (yield* match(source)).toSorted()) {
const resolved = yield* fs.resolve(item)
if (watched.has(resolved)) continue
watched.add(resolved)
const file = yield* read(resolved)
if (file) files.push(file)
}
}
for (const [key] of Array.from(watches)) if (!watched.has(key)) yield* FiberMap.remove(watches, key)
for (const file of watched) {
const updates = yield* watcher.subscribe({ path: file, type: "file" })
yield* FiberMap.run(watches, file, updates.pipe(Stream.runForEach(publish)), {
onlyIfMissing: true,
startImmediately: true,
})
}
return files
})
// Remote instructions are fetched only when config loads, not on every file rescan.
// A failed fetch keeps the last good copy or skips the URL, so an unreachable URL
// never makes the whole instruction set unavailable.
const reconfigure = Effect.fn("ConfigInstructionPlugin.reconfigure")(
function* () {
const entries = yield* config.entries()
const sources = Array.from(
new Set(entries.flatMap((entry) => (entry.type === "document" ? (entry.info.instructions ?? []) : []))),
)
const fetched = yield* Effect.forEach(
sources.filter(isUrl),
(url) =>
HttpClientRequest.get(url).pipe(
http.execute,
Effect.flatMap((response) => response.text),
Effect.timeout("5 seconds"),
Effect.map((content) => new InstructionDiscovery.File({ path: url, content })),
Effect.catchCause((cause) =>
Effect.logWarning("failed to fetch instruction url", { url, cause }).pipe(
Effect.as(configured.remote.get(url)),
),
),
),
{ concurrency: 4 },
)
configured.sources = sources
configured.remote = new Map(
fetched.filter((file) => file !== undefined).map((file) => [file.path, file] as const),
)
},
(effect) => lock.withPermit(effect),
)
const isolate = <A, E, R>(source: string, effect: Effect.Effect<A, E, R>) =>
effect.pipe(
Effect.catchCause((cause) =>
@@ -92,10 +176,11 @@ export const Plugin = define({
const sources = yield* Effect.all({
global: isolate("global", globalSource()),
project: isolate("project", projectSource()),
configured: isolate("configured", configuredSource()),
})
loaded.current =
Array.isArray(sources.global) && Array.isArray(sources.project)
? { type: "available", files: [...sources.global, ...sources.project] }
Array.isArray(sources.global) && Array.isArray(sources.project) && Array.isArray(sources.configured)
? { type: "available", files: [...sources.global, ...sources.project, ...sources.configured] }
: { type: "unavailable" }
if (!file) return
yield* Effect.logDebug("instructions rescanned", {
@@ -115,6 +200,13 @@ export const Plugin = define({
Stream.runForEach((file) => refresh(file).pipe(Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
// Subscribe before the first config read so an update in between still triggers a reload.
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
Stream.runForEach(() => reconfigure().pipe(Effect.andThen(refresh()), Effect.andThen(discovery.reload()))),
Effect.forkScoped({ startImmediately: true }),
)
yield* reconfigure()
yield* refresh()
yield* discovery.transform((editor) => {
if (loaded.current.type === "unavailable") {
@@ -141,3 +233,7 @@ function ancestorDirectories(start: string, stop: string): string[] {
if (sameDirectory(start, stop)) return [start]
return [start, ...ancestorDirectories(dirname(start), stop)]
}
function isUrl(source: string) {
return URL.canParse(source) && /^https?:$/.test(new URL(source).protocol)
}
+3 -3
View File
@@ -30,7 +30,7 @@ export const Plugin = define({
})
}
integrations.update(integrationID, (integration) => {
integration.name = provider.name ?? integration.name
integration.name = provider.name || integration.name
})
if (provider.env !== undefined) {
integrations.method.update({
@@ -66,7 +66,7 @@ export const Plugin = define({
})
provider.activation = "enabled"
if (item.canonical !== undefined) provider.canonical = item.canonical
if (item.name !== undefined) provider.name = item.name
if (item.name) provider.name = item.name
if (item.package !== undefined) provider.package = item.package
if (item.compatibility !== undefined)
provider.compatibility = { ...provider.compatibility, ...item.compatibility }
@@ -111,7 +111,7 @@ export const Plugin = define({
if (item.settings?.baseURL !== undefined && model.settings) delete model.settings.baseURL
}
if (config.family !== undefined) model.family = config.family
if (config.name !== undefined) model.name = config.name
if (config.name) model.name = config.name
if (config.modelID !== undefined) model.modelID = config.modelID
if (config.compatibility !== undefined)
model.compatibility = { ...model.compatibility, ...config.compatibility }
+17 -21
View File
@@ -19,7 +19,6 @@ type ParseSource =
type SubstituteInput = ParseSource & {
text: string
missing?: "error" | "empty"
env?: Record<string, string>
}
@@ -56,27 +55,24 @@ const substituteFiles = Effect.fnUntraced(function* (input: SubstituteInput, tex
const filePath = token.slice("{file:".length, -1)
const expandedPath = filePath.startsWith("~/") ? path.join(os.homedir(), filePath.slice(2)) : filePath
const resolvedPath = path.isAbsolute(expandedPath) ? expandedPath : path.resolve(configDir, expandedPath)
const fileContent = yield* fs.readFileString(resolvedPath).pipe(
Effect.catch((error) => {
if (input.missing === "empty") return Effect.succeed("")
const fileContent = yield* fs
.readFileStringSafe(resolvedPath)
.pipe(
Effect.mapError(
(error) =>
new InvalidError({ path: configSource, message: `bad file reference: "${token}"` }, { cause: error }),
),
)
// Like a missing {env:VAR}, a missing or unreadable file substitutes empty so one stale secret
// reference cannot take down every Location that loads this config.
if (fileContent === undefined)
yield* Effect.logWarning("config file reference is missing or unreadable", {
source: configSource,
reference: token,
path: resolvedPath,
})
const message = `bad file reference: "${token}"`
return Effect.fail(
new InvalidError(
{
path: configSource,
message:
error._tag === "PlatformError" && error.reason._tag === "NotFound"
? `${message} ${resolvedPath} does not exist`
: message,
},
{ cause: error },
),
)
}),
)
out += JSON.stringify(fileContent.trim()).slice(1, -1)
out += JSON.stringify((fileContent ?? "").trim()).slice(1, -1)
cursor = index + token.length
}
@@ -277,7 +277,7 @@ export class SQLiteEffectPreparedQuery<
}
assertUnreachable(cacheStrat)
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params, cause: Cause.fail(e) })))
}).pipe(Effect.mapError((e) => new EffectDrizzleQueryError({ query: queryString, params: params.map(() => "[redacted]"), cause: Cause.fail(e) })))
}
getQuery(): Query {
+6 -6
View File
@@ -5,11 +5,11 @@ import { makeLocationNode } from "@opencode/util/effect/app-node"
import { createPatch } from "diff"
import { Bus } from "./bus.js"
import { Instructions } from "./instructions/index.js"
import { AbsolutePath } from "./schema.js"
import { State } from "./state.js"
export class File extends Schema.Class<File>("InstructionDiscovery.File")({
path: AbsolutePath,
// An absolute file path, or the URL of a configured remote instruction.
path: Schema.String,
content: Schema.String,
}) {}
@@ -21,7 +21,7 @@ export const Event = {
}
export type Data = {
files: Map<AbsolutePath, Types.DeepMutable<File>>
files: Map<string, Types.DeepMutable<File>>
available: boolean
}
@@ -64,12 +64,12 @@ export const layer = (options?: Options) =>
list: () => Array.from(editor.files.values()),
add: (file) => editor.files.set(file.path, new File(file) as Types.DeepMutable<File>),
update: (path, update) => {
const current = editor.files.get(AbsolutePath.make(path))
const current = editor.files.get(path)
if (!current) return
update(current)
current.path = AbsolutePath.make(path)
current.path = path
},
remove: (path) => editor.files.delete(AbsolutePath.make(path)),
remove: (path) => editor.files.delete(path),
unavailable: () => {
editor.available = false
},
+36 -8
View File
@@ -185,7 +185,7 @@ export const connect = Effect.fnUntraced(function* (
const runFork = Effect.runForkWith(yield* Effect.context())
const client = new Client(clientInfo, {
capabilities: {
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
...(elicitation ? { elicitation: { form: {}, url: {} } } : {}),
// Legacy era only: roots are deprecated as of 2026-07-28 and modern servers cannot request them.
// Some legacy servers refuse to run without one (https://github.com/anomalyco/opencode/issues/2308).
roots: {},
@@ -265,7 +265,8 @@ export const connect = Effect.fnUntraced(function* (
// Servers that bundle their own Code Mode (Cloudflare and others) expose raw tools when asked
// with ?codemode=false, which is what our Code Mode wants. The configured URL stays the OAuth identity.
const url = new URL(config.url)
const addedCodemode = config.codemode !== false && !url.searchParams.has("codemode")
const addedCodemode =
config.codemode !== false && !url.searchParams.has("codemode") && !rejectsUnknownQuery(url)
if (addedCodemode) url.searchParams.set("codemode", "false")
const open = Effect.fnUntraced(function* (url: URL) {
session.transport = new StreamableHTTPClientTransport(url, {
@@ -326,19 +327,35 @@ export const connect = Effect.fnUntraced(function* (
Effect.mapError(failure),
Effect.tapError((error) => Effect.logWarning(`failed to ${what}`, { server, error: error.message })),
)
// The SDK answers a list for an unadvertised capability with a console.debug line plus an empty
// result, so refreshing a tools-only server prints to stdout on every catalog load. Negotiate
// first and leave the request unmade; the result is the same empty list without the noise.
const advertised = (capability: "tools" | "prompts" | "resources") =>
Boolean(client.getServerCapabilities()?.[capability])
return {
modern: client.getProtocolEra() === "modern",
instructions: client.getInstructions()?.trim() || undefined,
tools: () => list("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools)),
tools: () =>
advertised("tools")
? list("list MCP tools", () => client.listTools(undefined, catalog)).pipe(Effect.map((r) => r.tools))
: Effect.succeed([]),
prompts: () =>
list("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts)),
advertised("prompts")
? list("list MCP prompts", () => client.listPrompts(undefined, catalog)).pipe(Effect.map((r) => r.prompts))
: Effect.succeed([]),
resources: () =>
list("list MCP resources", () => client.listResources(undefined, catalog)).pipe(Effect.map((r) => r.resources)),
advertised("resources")
? list("list MCP resources", () => client.listResources(undefined, catalog)).pipe(
Effect.map((r) => r.resources),
)
: Effect.succeed([]),
resourceTemplates: () =>
list("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
Effect.map((r) => r.resourceTemplates),
),
advertised("resources")
? list("list MCP resource templates", () => client.listResourceTemplates(undefined, catalog)).pipe(
Effect.map((r) => r.resourceTemplates),
)
: Effect.succeed([]),
readResource: (input) => {
if (!client.getServerCapabilities()?.resources) return Effect.succeed(undefined)
return request("read MCP resource", (signal) =>
@@ -389,6 +406,17 @@ export const connect = Effect.fnUntraced(function* (
return yield* new ConnectError({ server, message: explain(error) })
})
// These servers reject any unknown query parameter and have no Code Mode of their own, so adding
// ?codemode=false only costs a failed request before the retry at the configured URL.
function rejectsUnknownQuery(url: URL) {
// Google Workspace MCP (gmailmcp, drivemcp, calendarmcp, ...) returns 404.
if (url.hostname.endsWith(".googleapis.com")) return true
// Zoho MCP gateways (zohomcp.com, zohomcp.eu, ...) return 400.
if (/(^|\.)zohomcp\.[a-z.]+$/.test(url.hostname)) return true
// Loopback servers run on this machine (Figma Desktop's Dev Mode MCP returns 404), so they are not hosted Code Mode servers.
return url.hostname === "localhost" || url.hostname === "[::1]" || /^127(\.\d+){3}$/.test(url.hostname)
}
// Absent config is legacy: the SDK sends the plain initialize handshake with no discover probe.
function negotiation(protocol: ConfigMCP.Protocol | undefined): VersionNegotiationOptions | undefined {
if (protocol === undefined || protocol === "legacy") return undefined
+3 -4
View File
@@ -23,10 +23,9 @@ Use only <https://opencode.ai/v2/docs/> documentation as the source of truth for
Do not use <https://opencode.ai/docs/>, which documents V1, and do not use
general web search to resolve a V2 documentation question when the V2 docs or
linked pages cover it. The schema served from
<https://opencode.ai/config.json> may describe V1 even though V2 configuration
files include that URL for editor integration. Never use it to infer V2 field
names or shapes. If V2 documentation is missing or contradictory, state the
uncertainty or ask for clarification instead of falling back to V1.
<https://opencode.ai/config.json> accepts both V2 fields and deprecated V1
fields. Never recommend a field it marks deprecated. If V2 documentation is
missing or contradictory, state the uncertainty or ask for clarification instead of falling back to V1.
V1 documentation and syntax may be consulted only when the user explicitly
asks about V1 or when needed as migration input. Outputs and recommendations
+27 -2
View File
@@ -288,11 +288,17 @@ export const layer = Layer.effect(
// template gets one reminder before it counts as a failure.
const send = (request: LLMRequest) =>
Effect.gen(function* () {
const prompted = LLMRequest.update(request, { messages: [...request.messages, Message.user(prompt)] })
const prompted = fitOutput(
LLMRequest.update(request, { messages: [...request.messages, Message.user(prompt)] }),
context.model.limit,
)
const reply = yield* stream(context, prompted, prepared.options)
if (filled(reply.text)) return { ...reply, recent: split.recent }
const nudged = LLMRequest.update(prompted, { messages: [...prompted.messages, Message.user(NUDGE)] })
const nudged = fitOutput(
LLMRequest.update(prompted, { messages: [...prompted.messages, Message.user(NUDGE)] }),
context.model.limit,
)
const retry = yield* stream(context, nudged, prepared.options)
if (filled(retry.text)) return { ...retry, recent: split.recent }
return yield* Effect.fail<Failure>({
@@ -922,6 +928,25 @@ const hasMeasuredPrompt = (message: SessionMessage.Info, model: SessionContext.L
message.tokens !== undefined &&
message.tokens.input + message.tokens.cache.read + message.tokens.cache.write > 0
/**
* `prepare` sizes the output limit for a request of `budget` tokens, but `deliver` can send a larger one when it
* cannot shrink the conversation further. Lower the limit to the room the request being sent leaves in the window,
* so the input and the requested output never exceed the window by our own estimate.
*
* Like `budget` in `prepare`, the estimate counts as measured, without the margin primary requests add for estimates:
* `deliver` aims at about 90% of the window, so that margin would cut every summary near its target to the minimum.
*/
const fitOutput = (request: LLMRequest, limit: SessionContext.Loaded["model"]["limit"]) => {
const current = request.generation?.maxTokens
if (current === undefined) return request
const maxTokens = Math.min(
current,
SessionModelRequest.outputLimit(limit, "compaction", { measured: estimateRequest(request), estimated: 0 }),
)
if (maxTokens === current) return request
return LLMRequest.update(request, { generation: { ...request.generation, maxTokens } })
}
const estimateRequest = (request: Pick<LLMRequest, "system" | "tools" | "messages">) =>
request.system.reduce((sum, part) => sum + Token.estimate(part.text), 0) +
request.tools.reduce((sum, tool) => sum + estimateTool(tool), 0) +
+1 -1
View File
@@ -85,7 +85,7 @@ export interface Input {
}
/** The default output limit: the catalog limit, fitted to the room the prompt leaves in the context window. */
const outputLimit = (
export const outputLimit = (
limit: Model.Info["limit"],
kind: "primary" | "compaction",
inputTokens?: Input["inputTokens"],
+1 -1
View File
@@ -52,7 +52,7 @@ export const layer = Layer.effect(
action: name(tool.server, tool.name),
resources: ["*"],
save: ["*"],
metadata: {},
metadata: { input: input ?? {} },
sessionID: context.sessionID,
agent: context.agent,
source: {
+4 -2
View File
@@ -1306,7 +1306,7 @@ describe("Config", () => {
fs.writeFile(
path.join(tmp.path, "opencode.jsonc"),
`{
// Ignored reference: {file:missing.txt}
// Ignored reference: {file:.}
"username": "user-{env:OPENCODE_TEST_MISSING}",
"mcp": {
"servers": {
@@ -1315,7 +1315,8 @@ describe("Config", () => {
"url": "https://example.com/mcp",
"headers": {
"Authorization": "Bearer {env:OPENCODE_TEST_MCP_TOKEN}",
"X-Token": "{file:token.txt}"
"X-Token": "{file:token.txt}",
"X-Missing": "Bearer {file:~/.opencode-test-missing/key}"
}
}
}
@@ -1335,6 +1336,7 @@ describe("Config", () => {
expect(remote.headers).toEqual({
Authorization: "Bearer secret",
"X-Token": 'file\n"token"',
"X-Missing": "Bearer ",
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
+2 -2
View File
@@ -48,7 +48,7 @@ test("selects rows through Effect-yieldable query builders", async () => {
)
})
test("maps query failures with query, params, and cause", async () => {
test("maps query failures with query, redacted params, and cause", async () => {
await run(
Effect.gen(function* () {
const db = yield* EffectDrizzleSqlite.makeWithDefaults()
@@ -56,7 +56,7 @@ test("maps query failures with query, params, and cause", async () => {
expect(error).toBeInstanceOf(EffectDrizzleQueryError)
expect(error.query).toBe("select * from missing_table where id = ?")
expect(error.params).toEqual([42])
expect(error.params).toEqual(["[redacted]"])
expect(Cause.isCause(error.cause)).toBe(true)
if (!Cause.isCause(error.cause)) return
expect(error.cause.reasons[0]?._tag).toBe("Fail")
Loaded 100 of 178 files, more files were not shown because too many files have changed in this diff. Show more