mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-17 01:40:19 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e873bc40b | ||
|
|
4f0ee179fa | ||
|
|
0cd2e829af | ||
|
|
9f5ce4674a | ||
|
|
2573244805 | ||
|
|
dbf5e655df | ||
|
|
78558202c0 | ||
|
|
25facb9dfe | ||
|
|
4003f64688 | ||
|
|
7dbe56078b | ||
|
|
bc65cbe961 | ||
|
|
56782638cb | ||
|
|
9fe5766435 | ||
|
|
77b8f1dfc7 | ||
|
|
3a0a231d84 | ||
|
|
5c4079a926 | ||
|
|
e004ec3f57 | ||
|
|
c3ceabf01c | ||
|
|
4397ee44d0 | ||
|
|
b47c6802da | ||
|
|
ae8d59f8b5 | ||
|
|
7ced591c3c | ||
|
|
b55f220a17 | ||
|
|
fbf5f164d9 | ||
|
|
3aa26f1676 | ||
|
|
a8fc567c5c | ||
|
|
7b5f1bd9e1 | ||
|
|
bdf4793286 | ||
|
|
37df62f4a1 | ||
|
|
d099d2cb2a | ||
|
|
97c201931c | ||
|
|
328f74c213 | ||
|
|
32b1a1bf85 | ||
|
|
2ae7ec8a16 | ||
|
|
ccd3aa787a | ||
|
|
e05cc4a18d | ||
|
|
3943b84ef0 | ||
|
|
1e63a3bb73 | ||
|
|
7291f3e07a | ||
|
|
13afea6a1a | ||
|
|
8ffbcc2c81 | ||
|
|
35bb8e44ef | ||
|
|
0ccfe69ff3 | ||
|
|
07c0ff5914 | ||
|
|
fe1717b309 | ||
|
|
943dc005f7 | ||
|
|
cddf1dd3e9 | ||
|
|
b474b84259 | ||
|
|
c0d41d2b26 | ||
|
|
c458ad72d3 | ||
|
|
f44c40b4d3 | ||
|
|
5fa0a42386 | ||
|
|
d3e1de326b | ||
|
|
81d57087a4 | ||
|
|
34d8ded0c5 |
No files matched your search
@@ -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
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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!),
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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("*/*")
|
||||
}
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("settings schema", () => {
|
||||
showFileTree: false,
|
||||
timelineDetail: timelinePresets[2].value,
|
||||
showCustomAgents: false,
|
||||
showTurnDiffs: false,
|
||||
mobileTitlebarPosition: "top",
|
||||
terminalPlacement: "side",
|
||||
followUpBehavior: "steer",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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[]) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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ù",
|
||||
|
||||
@@ -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日まで有効です。",
|
||||
|
||||
@@ -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일까지 유효합니다.",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Докладніше",
|
||||
|
||||
@@ -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 日。",
|
||||
|
||||
@@ -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 }),
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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)))
|
||||
}),
|
||||
|
||||
@@ -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
Reference in new issue
Block a user