Compare commits

..
59 Commits
Author SHA1 Message Date
Dax eb1ac54d73 feat(tui): support multiple integration accounts (#45072) 2026-08-25 17:03:16 -04:00
opencode-agent[bot]andrekram1-node d2100a51f1 feat(cli): restore debug paths command (#45063)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-25 23:01:43 +02:00
opencode-agent[bot]andneriousy d4803ffe38 feat(cli): publish generated CLI configuration schema (#45070)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:49:45 +02:00
Kit Langton db7837814c fix(project): refresh renamed projects across live clients (#45043) 2026-08-25 20:13:10 +00:00
opencode-agent[bot]andneriousy 543a4f4912 fix(ci): preserve required e2e matrix checks (#45065)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:11:43 +02:00
opencode-agent[bot]andneriousy d562f6df1e fix(core): recover legacy database migration history (#45062)
Co-authored-by: neriousy <34747899+neriousy@users.noreply.github.com>
2026-08-25 22:02:26 +02:00
Aiden Cline 263a442a6c fix(ai): normalize chat tool call ids (#45056) 2026-08-25 14:39:00 -05:00
Filip 82947af8e3 fix(core): ignore SSE comment heartbeats (#43626) 2026-08-25 21:34:08 +02:00
Filip e1afdaac52 fix(core): fall back on oversized websocket requests v2 (#43100) 2026-08-25 21:27:08 +02:00
Aiden Cline a5829431b0 fix(ai): bridge tool results for mistral family models (#45051) 2026-08-25 13:40:35 -05:00
Aiden Cline 1ca82d154c fix(ai): omit empty chat assistant messages (#45046) 2026-08-25 12:56:24 -05:00
James Long bc1f67e518 feat(cli): embed persistent PTY service binaries (#44970) 2026-08-25 13:19:48 -04:00
Kit Langton 1f45962c84 fix(tui): support inline session rename and title regeneration (#45023) 2026-08-25 13:10:22 -04:00
Kit Langton 88242e21a8 fix(tui): stop completed exploration groups from spinning 2026-08-25 13:09:03 -04:00
0fd719067d feat(plugin): add permission review hooks (#45003)
Co-authored-by: R44VC0RP <R44VC0RP@users.noreply.github.com>
Co-authored-by: nexxeln <nexxeln@users.noreply.github.com>
Co-authored-by: thdxr <thdxr@users.noreply.github.com>
2026-08-25 16:57:17 +00:00
Aiden Cline c94a4913c0 fix(ai): preserve unencrypted reasoning history (#45032) 2026-08-25 11:44:44 -05:00
opencode-agent[bot]andBrendonovich 6c32ba81e2 test: skip unaffected app e2e tests (#45030)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:33:14 +08:00
Kit Langton 938a82226a test(tui): synchronize tab close frame before pointer moves (#45017) 2026-08-25 12:30:20 -04:00
Aiden Cline c46b76b58e fix(ai): route response events by output index (#45013) 2026-08-25 11:27:40 -05:00
opencode-agent[bot] fbb3730fdd chore: update nix node_modules hashes 2026-08-25 16:26:05 +00:00
opencode-agent[bot]andBrendonovich 96cff7bb7a fix(app): clarify context usage label (#45012)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-26 00:20:00 +08:00
Kit Langton 578f8d637a fix(server): pin shared Effect platform runtime (#45016) 2026-08-25 12:19:24 -04:00
Kit Langton e5308a988f feat(merman): refine diagram styling (#44815) 2026-08-25 16:07:12 +00:00
opencode-agent[bot]andnexxeln d177f29dba fix(tui): keep autocomplete selection visible (#44983)
Co-authored-by: nexxeln <95541290+nexxeln@users.noreply.github.com>
2026-08-25 21:30:53 +05:30
Aiden Cline 7f51da509b fix(ai): preserve terminal reasoning metadata (#44997) 2026-08-25 10:37:49 -05:00
James Long 004b647311 feat(server): add persistent PTY daemon API (#44969) 2026-08-25 11:31:47 -04:00
Aiden Cline cce86ac166 fix(ai): fail unknown Bedrock stream exceptions (#45004) 2026-08-25 10:08:21 -05:00
Shoubhit Dash b71291c05a refactor(core): move mercurial vcs into internal plugin (#44993) 2026-08-25 20:35:21 +05:30
Shoubhit Dash 0ab2d783e8 refactor(core): move git vcs into internal plugin (#44992) 2026-08-25 20:26:43 +05:30
Aiden Cline 3deac93d27 fix(ai): sanitize outbound provider request surrogates (#44880) 2026-08-25 09:53:47 -05:00
ed582d1bdb feat(core): run worktree setup scripts (#44455)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendonovich <Brendonovich@users.noreply.github.com>
2026-08-25 22:45:01 +08:00
opencode-agent[bot]andBrendonovich db5a10dad1 fix(app): hide close buttons on cramped inactive tabs (#44995)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 22:35:41 +08:00
Aiden Cline ff59a22ff4 fix(ai): reconcile completed response output items (#44893) 2026-08-25 09:32:42 -05:00
Shoubhit Dash 9c1787617c feat(plugin): add vcs provider api (#44979) 2026-08-25 19:37:06 +05:30
opencode-agent[bot]andBrendonovich 7601ab9fc4 fix(desktop): hide sessions during removal (#44913)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 21:09:20 +08:00
4fb8a6038a feat(app): select worktree base branch (#44906)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
2026-08-25 21:05:52 +08:00
opencode-agent[bot]andrekram1-node 5c25c38961 fix(tui): paste into active custom answers (#44849)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-25 18:19:28 +05:30
Brendan Allan e3aa13c7d0 fix(app): show pending states when switching servers (#44947) 2026-08-25 20:34:53 +08:00
opencode-agent[bot]andBrendan 5963a30621 fix(ui): restore stacked dialog focus (#44941)
Co-authored-by: Brendan <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 19:10:09 +08:00
opencode-agent[bot] bcd1769521 chore: update nix node_modules hashes 2026-08-25 09:05:45 +00:00
Brendan Allan 691cb456ae feat(desktop): add development component picker (#44922) 2026-08-25 16:48:22 +08:00
opencode-agent[bot]andBrendonovich 190f189fbe fix(app): route notification clicks through tabs (#44897)
Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
2026-08-25 14:11:59 +08:00
Major Hayden 8c126e98da fix(ci): check PR body for linked issue on non-default branches (#43964)
Signed-off-by: Major Hayden <major@mhtx.net>
2026-08-25 00:38:02 -05:00
Aiden Cline ce8a489aaa fix(ai): respect prompt cache opt-out (#44891) 2026-08-25 00:36:18 -05:00
Brendan Allan 1f7ae3f638 fix(app): animate composer delivery controls (#44886) 2026-08-25 13:34:53 +08:00
Luke Parker 5ad0f0dc5a fix(app): prevent timeline row identity collisions (#44878) 2026-08-25 15:33:45 +10:00
e589969398 fix(core): resolve compatible shells for commands (#44485)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
2026-08-25 00:24:08 -05:00
Aiden Cline 42867d3bbc fix(ai): recover incomplete streamed tool arguments (#44875) 2026-08-25 00:22:03 -05:00
Brendan Allan d4cdb99e4c fix(desktop): suppress resize observer loop warnings (#44883) 2026-08-25 13:17:29 +08:00
Aiden Cline 0a78b11222 fix(ai): ignore unknown Anthropic stream variants (#44817) 2026-08-25 00:03:08 -05:00
Aiden Cline 28c1806950 fix(core): route Copilot fallback models through AI SDK (#44882) 2026-08-25 00:01:42 -05:00
opencode-agent[bot]andrekram1-node 683f5fdee0 fix(tui): inherit model for new sessions (#44879)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-08-24 23:58:09 -05:00
Dax Raad e9b5e055f5 fix(docs): refine header links and copy feedback 2026-08-25 00:53:10 -04:00
Aiden Cline f327adb0f2 fix(core): support Zod tool schemas (#44861) 2026-08-24 23:50:01 -05:00
Dax Raad 442bc92a21 feat(docs): add markdown copy button to page headings 2026-08-25 00:48:39 -04:00
Dax Raad f2ff93a5b7 fix(www): use official favicon 2026-08-25 00:44:42 -04:00
Aiden Cline 1b30098e8d fix(ai): default responses to encrypted reasoning (#44863) 2026-08-24 23:43:04 -05:00
Dax Raad 1144ef6c5d feat(www): serve cached markdown documentation 2026-08-25 00:34:34 -04:00
Luke Parker d6deb62379 fix(app): default typography to Inter and IBM Plex Mono (#44876) 2026-08-25 14:30:21 +10:00
307 changed files with 14904 additions and 1200 deletions
+10 -1
View File
@@ -135,7 +135,16 @@ jobs:
const linkedIssues = result.repository.pullRequest.closingIssuesReferences.totalCount;
if (linkedIssues === 0) {
// GitHub only populates closingIssuesReferences when a PR targets the repository's
// default branch (dev). PRs targeting other branches like v2 always return totalCount 0.
// Fall back to checking the PR description for closing keywords (e.g. Closes #123).
const body = pr.body || '';
const issueMatch = body.match(/### Issue for this PR\s*\n([\s\S]*?)(?=###|$)/);
const issueContent = issueMatch ? issueMatch[1].trim() : body;
const hasBodyIssueRef = /(closes|fixes|resolves)\s+#\d+/i.test(issueContent) || /#\d+/.test(issueContent);
const hasLinkedIssue = linkedIssues > 0 || hasBodyIssueRef;
if (!hasLinkedIssue) {
await addLabel('needs:issue');
await comment('issue', `Thanks for your contribution!
+41 -4
View File
@@ -22,6 +22,36 @@ env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
affected:
name: affected packages
runs-on: blacksmith-4vcpu-ubuntu-2404
outputs:
app: ${{ steps.packages.outputs.app }}
steps:
- name: Checkout repository
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
token: ${{ secrets.GITHUB_TOKEN }}
fetch-depth: 0
- name: Setup Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version-file: package.json
- name: Find affected packages
id: packages
env:
TURBO_SCM_BASE: ${{ github.event.pull_request.base.sha || github.event.before }}
TURBO_SCM_HEAD: ${{ github.sha }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "app=true" >> "$GITHUB_OUTPUT"
exit 0
fi
bun x turbo@2.10.2 ls --affected --filter=@opencode-ai/app --output=json > affected.json
bun -e 'const result = await Bun.file("affected.json").json(); console.log(`app=${result.packages.count > 0}`)' >> "$GITHUB_OUTPUT"
unit:
name: unit (${{ matrix.settings.name }})
strategy:
@@ -133,7 +163,7 @@ jobs:
e2e:
name: e2e (${{ matrix.settings.name }})
if: github.ref_name != 'v2' && github.head_ref != 'v2'
needs: affected
strategy:
fail-fast: false
matrix:
@@ -144,32 +174,38 @@ jobs:
host: blacksmith-4vcpu-windows-2025
runs-on: ${{ matrix.settings.host }}
env:
E2E_ENABLED: ${{ needs.affected.outputs.app == 'true' && github.ref_name != 'v2' && github.head_ref != 'v2' }}
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:
@@ -177,23 +213,24 @@ jobs:
key: ${{ runner.os }}-${{ runner.arch }}-playwright-${{ steps.playwright-version.outputs.version }}-chromium
- name: Install Playwright system dependencies
if: runner.os == 'Linux'
if: env.E2E_ENABLED == 'true' && runner.os == 'Linux'
working-directory: packages/app
run: bunx playwright install-deps chromium
- name: Install Playwright browsers
if: steps.playwright-cache.outputs.cache-hit != 'true'
if: env.E2E_ENABLED == 'true' && steps.playwright-cache.outputs.cache-hit != 'true'
working-directory: packages/app
run: bunx playwright install chromium
- name: Run app e2e tests
if: env.E2E_ENABLED == 'true'
run: bun --cwd packages/app test:e2e:local
env:
CI: true
timeout-minutes: 30
- name: Upload Playwright artifacts
if: always()
if: always() && env.E2E_ENABLED == 'true'
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: playwright-${{ matrix.settings.name }}-${{ github.run_attempt }}
+10
View File
@@ -430,6 +430,7 @@
},
"devDependencies": {
"@actions/artifact": "4.0.0",
"@brendonovich/vite-plugin-opencode": "0.1.1",
"@lydell/node-pty": "catalog:",
"@opencode-ai/app": "workspace:*",
"@opencode-ai/client": "workspace:*",
@@ -685,6 +686,7 @@
"version": "1.18.4",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
"@opencode-ai/schema": "workspace:*",
@@ -1610,6 +1612,8 @@
"@braintree/sanitize-url": ["@braintree/sanitize-url@7.1.2", "", {}, "sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA=="],
"@brendonovich/vite-plugin-opencode": ["@brendonovich/vite-plugin-opencode@0.1.1", "", { "dependencies": { "@babel/core": "^7.29.0", "@opencode-ai/client": "0.0.0-beta-18050" }, "peerDependencies": { "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-aPG0ct8ctxAqndbNOx7NW0GhU6QY6sOUfi/DaKqH9c5WdxICSsUop6uSkJwPDHP9WpN9eg0dd2D2qwYpG6UdHw=="],
"@bruits/satteri-darwin-arm64": ["@bruits/satteri-darwin-arm64@0.9.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iw4nZgx9v30lWo/MTngQqi1pI78KI0DnkSm+lVJGYdmPLgAyDNJigVhpG42/Iq55A6c1Ll8q66ljyyRiQUxwow=="],
"@bruits/satteri-darwin-x64": ["@bruits/satteri-darwin-x64@0.9.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-6T26Z5Kf3cFW2PSlk9p7zT7yVxvuBSiJvYyz9u8KjYwMTqZyIDOj2wDyNpxKV4+6yUVG7rddq2QwvG/8LJA2+Q=="],
@@ -6132,6 +6136,8 @@
"@babel/preset-env/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client": ["@opencode-ai/client@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/protocol": "0.0.0-beta-18050", "@opencode-ai/schema": "0.0.0-beta-18050" }, "peerDependencies": { "effect": "4.0.0-rc.111", "solid-js": ">=1.9.0" }, "optionalPeers": ["effect", "solid-js"] }, "sha512-zWZv5X23iyx+/mxwiAi18YY/VMjQofTaH7RyKMBt7KL6FmaKAWf9Q05zbQhrLX8DYJD3MOtbjBeQCGLsPUDU8g=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.11.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.2", "tslib": "^2.4.0" } }, "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ=="],
"@bruits/satteri-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.11.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw=="],
@@ -6956,6 +6962,10 @@
"@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/protocol": ["@opencode-ai/protocol@0.0.0-beta-18050", "", { "dependencies": { "@opencode-ai/schema": "0.0.0-beta-18050", "effect": "4.0.0-rc.111" } }, "sha512-HDQMnvGp8IU0MdBRbEuydX1WQm09BZ4HJm9iSMQwzweJuQ2HNscgzHJPIH6P02BsbbtfJ8J7sZGPItrz1tWSgw=="],
"@brendonovich/vite-plugin-opencode/@opencode-ai/client/@opencode-ai/schema": ["@opencode-ai/schema@0.0.0-beta-18050", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "effect": "4.0.0-rc.111" } }, "sha512-/D6VXaWlytTXR3IOiMLIKuPcfp7FQNUzRPm9z3K7UBFd1Bw4q/WZksaf5RVcBGz+0YRxYMc1V4D7MFlceSgtyg=="],
"@bruits/satteri-wasm32-wasi/@emnapi/core/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.2", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA=="],
"@electron/asar/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="],
+1 -1
View File
@@ -2,7 +2,7 @@
exact = true
# Only install newly resolved package versions published at least 3 days ago.
minimumReleaseAge = 259200
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@brendonovich/vite-plugin-opencode", "@opencode-ai/sdk", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "blume"]
[test]
root = "./do-not-run-tests-from-root"
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-2bkzaLe/n63btVRQNhu8LXCtMZJArX1Kedi5U40l1xw=",
"aarch64-linux": "sha256-5Cs9M3hvDKAymo71y8oZ7jj3pEm+MI+HHhuSuV7UvtM=",
"aarch64-darwin": "sha256-LsJcuxE/NMu+vUFdpBKHc2z0sC0C5bRMlH1Kj+ns9dY=",
"x86_64-darwin": "sha256-KDjmKC3JZD8I5A7gi+dYIl0dgHVt20/DwkM9RKBWiJk="
"x86_64-linux": "sha256-3Jx1Q7hl+Y0Log/k2vd5y6dzBpzFKWlhShPESxn1Rm4=",
"aarch64-linux": "sha256-EiiI6g01oBIrExCMAUgT3w82P0fvu4FAJhI32C+ze0I=",
"aarch64-darwin": "sha256-s+w49HRp1+ewtiTaU65tPWjUiO1NQw3kzfemMEEQZb0=",
"x86_64-darwin": "sha256-/Ee5V7pnL/qm3c4ZHeWEjH7FhGVXArXryOugbG5vsz8="
}
}
+14 -2
View File
@@ -1,7 +1,8 @@
import { Context, Effect, Layer } from "effect"
import { RequestExecutor } from "./route/executor.js"
import { mergeHttpOptions, type AIError } from "./schema/index.js"
import { sanitizeSurrogates } from "./utils/sanitize.js"
import type { ImageOptions, ImageRequest, ImageRequestFor, ImageResponse } from "./image.js"
import type { AIError } from "./schema/index.js"
export type Execute = RequestExecutor.Interface["execute"]
@@ -26,7 +27,18 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
return Service.of({
generate: (request) => request.model.route.generate(request, executor.execute),
generate: (request) =>
request.model.route.generate(
{
...sanitizeSurrogates({
...request,
model: undefined,
http: mergeHttpOptions(request.model.http, request.http),
}),
model: request.model,
},
executor.execute,
),
})
}),
)
+74 -12
View File
@@ -1,5 +1,5 @@
import { Buffer } from "node:buffer"
import { Effect, Schema } from "effect"
import { Effect, Option, Schema } from "effect"
import { Tool } from "@opencode-ai/schema/tool"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
@@ -361,6 +361,8 @@ const AnthropicStreamBlock = Schema.Struct({
tool_use_id: Schema.optional(Schema.String),
content: Schema.optional(Schema.Unknown),
})
type AnthropicStreamBlock = Schema.Schema.Type<typeof AnthropicStreamBlock>
const decodeAnthropicStreamBlock = Schema.decodeUnknownOption(AnthropicStreamBlock)
const AnthropicStreamDelta = Schema.Struct({
type: Schema.optional(Schema.String),
@@ -371,13 +373,15 @@ const AnthropicStreamDelta = Schema.Struct({
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
})
type AnthropicStreamDelta = Schema.Schema.Type<typeof AnthropicStreamDelta>
const decodeAnthropicStreamDelta = Schema.decodeUnknownOption(AnthropicStreamDelta)
const AnthropicEvent = Schema.Struct({
type: Schema.String,
index: Schema.optional(Schema.Number),
message: Schema.optional(Schema.Struct({ usage: Schema.optional(AnthropicUsage) })),
content_block: Schema.optional(AnthropicStreamBlock),
delta: Schema.optional(AnthropicStreamDelta),
content_block: Schema.optional(Schema.Unknown),
delta: Schema.optional(Schema.Unknown),
usage: Schema.optional(AnthropicUsage),
// `type` and `message` are both required per Anthropic's spec, but
// OpenAI-compatible proxies and gateway translations occasionally drop one
@@ -1106,7 +1110,7 @@ const SERVER_TOOL_RESULT_NAMES: Record<AnthropicServerToolResultType, string> =
const isServerToolResultType = (type: string): type is AnthropicServerToolResultType => type in SERVER_TOOL_RESULT_NAMES
const serverToolResultEvent = (block: NonNullable<AnthropicEvent["content_block"]>): LLMEvent | undefined => {
const serverToolResultEvent = (block: AnthropicStreamBlock): LLMEvent | undefined => {
if (!block.type || !isServerToolResultType(block.type)) return undefined
const errorPayload =
typeof block.content === "object" && block.content !== null && "type" in block.content
@@ -1133,7 +1137,10 @@ const onMessageStart = (state: ParserState, event: AnthropicEvent): StepResult =
return [usage ? { ...state, usage: mergeUsage(state.usage, usage) } : state, NO_EVENTS]
}
const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepResult => {
const onContentBlockStart = (
state: ParserState,
event: AnthropicEvent & { readonly content_block: AnthropicStreamBlock },
): StepResult => {
const block = event.content_block
if (!block) return [state, NO_EVENTS]
@@ -1224,11 +1231,12 @@ const onContentBlockStart = (state: ParserState, event: AnthropicEvent): StepRes
const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(function* (
state: ParserState,
event: AnthropicEvent,
event: AnthropicEvent & { readonly delta: AnthropicStreamDelta },
) {
const delta = event.delta
if (delta?.type === "text_delta" && delta.text) {
if (!state.lifecycle.text.has(`text-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{ ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, `text-${event.index ?? 0}`, delta.text) },
@@ -1237,6 +1245,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
}
if (delta?.type === "thinking_delta" && delta.thinking) {
if (!state.lifecycle.reasoning.has(`reasoning-${event.index ?? 0}`)) return [state, NO_EVENTS] satisfies StepResult
const events: LLMEvent[] = []
return [
{
@@ -1249,6 +1258,7 @@ const onContentBlockDelta = Effect.fn("AnthropicMessages.onContentBlockDelta")(f
if (delta?.type === "signature_delta" && delta.signature) {
const index = event.index ?? 0
if (!state.lifecycle.reasoning.has(`reasoning-${index}`)) return [state, NO_EVENTS] satisfies StepResult
return [
{
...state,
@@ -1301,7 +1311,10 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
return [{ ...state, lifecycle, tools: result.tools, reasoningSignatures }, events] satisfies StepResult
})
const onMessageDelta = (state: ParserState, event: AnthropicEvent): StepResult => {
const onMessageDelta = (
state: ParserState,
event: AnthropicEvent & { readonly delta?: AnthropicStreamDelta },
): StepResult => {
const usage = mergeUsage(state.usage, mapUsage(event.usage))
return [
{
@@ -1356,11 +1369,49 @@ const onError = (event: AnthropicEvent) =>
}),
)
const isKnownStreamBlockType = (type: string) =>
type === "text" ||
type === "thinking" ||
type === "redacted_thinking" ||
type === "tool_use" ||
type === "server_tool_use" ||
isServerToolResultType(type)
const isKnownStreamDeltaType = (type: string) =>
type === "text_delta" || type === "thinking_delta" || type === "signature_delta" || type === "input_json_delta"
const invalidStreamEvent = (event: AnthropicEvent) =>
Effect.fail(
ProviderShared.eventError(
ADAPTER,
"Invalid anthropic/anthropic-messages stream event",
ProviderShared.encodeJson(event),
),
)
const step = (state: ParserState, event: AnthropicEvent) => {
if (!SSE_EVENTS.has(event.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
if (
event.type !== "content_block_start" &&
event.content_block !== undefined &&
Option.isNone(decodeAnthropicStreamBlock(event.content_block))
)
return invalidStreamEvent(event)
if (
event.type !== "content_block_delta" &&
event.delta !== undefined &&
Option.isNone(decodeAnthropicStreamDelta(event.delta))
)
return invalidStreamEvent(event)
if (event.type === "message_start") return Effect.succeed(onMessageStart(state, event))
if (event.type === "content_block_start") {
const block = event.content_block
if (block && (block.type === "tool_use" || block.type === "server_tool_use")) {
if (!ProviderShared.isRecord(event.content_block) || typeof event.content_block.type !== "string")
return invalidStreamEvent(event)
if (!isKnownStreamBlockType(event.content_block.type)) return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamBlock(event.content_block)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
const block = decoded.value
if (block.type === "tool_use" || block.type === "server_tool_use") {
if (event.index === undefined)
return Effect.fail(ProviderShared.eventError(ADAPTER, `Anthropic ${block.type} missing index`))
if (!block.id)
@@ -1368,11 +1419,22 @@ const step = (state: ParserState, event: AnthropicEvent) => {
ProviderShared.eventError(ADAPTER, `Anthropic tool_use missing id at index ${event.index}`),
)
}
return Effect.succeed(onContentBlockStart(state, event))
return Effect.succeed(onContentBlockStart(state, { ...event, content_block: block }))
}
if (event.type === "content_block_delta") {
if (!ProviderShared.isRecord(event.delta)) return invalidStreamEvent(event)
if (typeof event.delta.type === "string" && !isKnownStreamDeltaType(event.delta.type))
return Effect.succeed<StepResult>([state, NO_EVENTS])
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return onContentBlockDelta(state, { ...event, delta: decoded.value })
}
if (event.type === "content_block_delta") return onContentBlockDelta(state, event)
if (event.type === "content_block_stop") return onContentBlockStop(state, event)
if (event.type === "message_delta") return Effect.succeed(onMessageDelta(state, event))
if (event.type === "message_delta") {
const decoded = decodeAnthropicStreamDelta(event.delta)
if (Option.isNone(decoded)) return invalidStreamEvent(event)
return Effect.succeed(onMessageDelta(state, { ...event, delta: decoded.value }))
}
if (event.type === "message_stop") return onMessageStop(state)
if (event.type === "error") return onError(event)
return Effect.succeed<StepResult>([state, NO_EVENTS])
+5 -17
View File
@@ -212,11 +212,7 @@ const BedrockEvent = Schema.Struct({
metrics: Schema.optional(Schema.Unknown),
}),
),
internalServerException: Schema.optional(BedrockStreamException),
modelStreamErrorException: Schema.optional(BedrockStreamException),
validationException: Schema.optional(BedrockStreamException),
throttlingException: Schema.optional(BedrockStreamException),
serviceUnavailableException: Schema.optional(BedrockStreamException),
exception: Schema.optional(Schema.Struct({ type: Schema.String, details: BedrockStreamException })),
})
type BedrockEvent = Schema.Schema.Type<typeof BedrockEvent>
@@ -650,22 +646,14 @@ const step = (state: ParserState, event: BedrockEvent) =>
] as const
}
const exception = (
[
["internalServerException", event.internalServerException],
["modelStreamErrorException", event.modelStreamErrorException],
["serviceUnavailableException", event.serviceUnavailableException],
["throttlingException", event.throttlingException],
["validationException", event.validationException],
] as const
).find((entry) => entry[1] !== undefined)
if (exception) {
if (event.exception) {
return yield* new AIError({
module: ADAPTER,
method: "stream",
reason: classifyProviderFailure({
message: exception[1]?.message ?? exception[1]?.originalMessage ?? "Bedrock Converse stream error",
code: exception[0],
message:
event.exception.details.message ?? event.exception.details.originalMessage ?? "Bedrock Converse stream error",
code: event.exception.type,
}),
})
}
@@ -82,7 +82,7 @@ const consumeFrames = (route: string) => (state: FrameBufferState, chunk: Uint8A
"Failed to parse Bedrock Converse event-stream payload",
)) as Record<string, unknown>
delete parsed.p
out.push({ [eventType]: parsed })
out.push(messageType === "exception" ? { exception: { type: eventType, details: parsed } } : { [eventType]: parsed })
}
return [cursor, out] as const
})
@@ -154,7 +154,12 @@ export const driver = (input: DriverInput): WebSocketChannelDriver => {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
value: {
version: VERSION,
responseID,
request,
output: event.response?.output ? [...event.response.output] : output.slice(),
} satisfies CheckpointValue,
},
}
}),
+49 -19
View File
@@ -288,6 +288,7 @@ export const Event = Schema.StructWithRest(
arguments: Schema.optional(Schema.String),
text: Schema.optional(Schema.String),
item_id: Schema.optional(Schema.String),
output_index: Schema.optional(Schema.Number),
summary_index: Schema.optional(Schema.Number),
item: Schema.optional(StreamItem),
response: Schema.optional(
@@ -296,6 +297,7 @@ export const Event = Schema.StructWithRest(
id: Schema.optional(Schema.String),
service_tier: optionalNull(Schema.String),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.optional(Schema.String) })),
output: Schema.optional(Schema.Array(StreamItem)),
usage: optionalNull(OpenResponsesUsage),
error: optionalNull(OpenResponsesErrorPayload),
}),
@@ -340,6 +342,7 @@ export interface ParserState {
readonly tools: ToolStream.State<string>
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly outputItems: Readonly<Record<number, string>>
readonly messageItems: ReadonlySet<string>
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
@@ -654,19 +657,12 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
}
// With store:false, Responses APIs only accept previous reasoning items when the
// complete item has encrypted state. Summary blocks for one item may carry
// that state only on the last block, so filter after they have been joined.
return store === false
? input.filter(
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
)
: input
return input
})
const lowerOptions = (request: LLMRequest) => {
const options = OpenResponsesOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const cacheKey = ProviderShared.promptCacheKey(request)
const parallelToolCalls = resolveParallelToolCalls(request)
return {
...(options.instructions ? { instructions: options.instructions } : {}),
@@ -817,6 +813,9 @@ const onOutputTextDone = (state: ParserState, event: Event, id: string): StepRes
return [{ ...state, lifecycle: Lifecycle.textEnd(state.lifecycle, events, id) }, events]
}
export const outputItemID = (state: ParserState, event: Event) =>
event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id)
export const onReasoningDelta = (state: ParserState, event: Event, itemID: string): StepResult => {
const item = state.reasoningItems[itemID]
if (!event.delta || !item) return [state, NO_EVENTS]
@@ -1111,30 +1110,49 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
})
const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (state: ParserState, event: Event) {
const reconciled =
event.type === "response.completed"
? yield* Effect.reduce(
event.response?.output ?? [],
() => [state, NO_EVENTS] satisfies StepResult,
([current, events], item) => {
if (
!item.id ||
((item.type !== "function_call" || !current.tools[item.id]) &&
(item.type !== "reasoning" || !current.reasoningItems[item.id]))
)
return Effect.succeed([current, events] satisfies StepResult)
return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(
Effect.map(([next, emitted]) => [next, [...events, ...emitted]] satisfies StepResult),
)
},
)
: ([state, NO_EVENTS] satisfies StepResult)
const current = reconciled[0]
// Some compatible providers omit output_item.done even after completing the response.
const pending =
event.type === "response.completed"
? yield* ToolStream.finishAll(state.id, state.tools)
: { tools: state.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...pending.events]
? yield* ToolStream.finishAll(current.id, current.tools)
: { tools: current.tools, events: NO_EVENTS }
const events: LLMEvent[] = [...reconciled[1], ...pending.events]
const hasFunctionCall =
pending.events.some((event) => LLMEvent.is.toolCall(event) || LLMEvent.is.toolInputError(event)) ||
state.hasFunctionCall
const lifecycle = Lifecycle.finish(state.lifecycle, events, {
current.hasFunctionCall
const lifecycle = Lifecycle.finish(current.lifecycle, events, {
reason: {
normalized: mapFinishReason(event, hasFunctionCall),
raw: event.response?.incomplete_details?.reason,
},
usage: mapUsage(event.response?.usage, state.providerMetadataKey),
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
? providerMetadata(state, {
? providerMetadata(current, {
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
: undefined,
})
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
return [{ ...current, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build the prettiest summary available from whatever the provider supplied.
@@ -1181,7 +1199,11 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, event: Event) => {
export const step = (state: ParserState, input: Event) => {
const event =
input.item_id && outputItemID(state, input) !== input.item_id
? { ...input, item_id: outputItemID(state, input) }
: input
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(
@@ -1223,7 +1245,14 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_item.added") {
if (event.item?.type === "message" && !event.item.id)
return ProviderShared.eventError(state.id, `${event.type} message is missing id`)
return Effect.succeed(onOutputItemAdded(state, event))
return Effect.succeed(
onOutputItemAdded(
event.output_index !== undefined && event.item?.id
? { ...state, outputItems: { ...state.outputItems, [event.output_index]: event.item.id } }
: state,
event,
),
)
}
if (event.type === "response.function_call_arguments.delta" || event.type === "response.function_call_arguments.done")
return event.item_id
@@ -1258,6 +1287,7 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
hasFunctionCall: false,
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set<string>(),
messagePhases: {},
reasoningItems: {},
+29 -8
View File
@@ -278,6 +278,7 @@ interface LoweringOptions {
readonly cacheControl?: (
cache: CacheHint | undefined,
) => Schema.Schema.Type<typeof OpenAIChatCacheControl> | undefined
readonly toolCallID?: (id: string) => string
}
const lowerTool = (
@@ -304,8 +305,8 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
id: part.id,
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
type: "function",
function: {
name: part.name,
@@ -381,7 +382,7 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part))
toolCalls.push(lowerToolCall(part, options))
continue
}
}
@@ -427,7 +428,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
if (part.result.type !== "content") {
messages.push({
role: "tool",
tool_call_id: part.id,
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: ProviderShared.toolResultText(part),
cache_control: options.cacheControl?.(part.cache),
})
@@ -437,7 +438,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (
const text = content.filter((item) => item.type === "text").map((item) => item.text)
messages.push({
role: "tool",
tool_call_id: part.id,
tool_call_id: options.toolCallID?.(part.id) ?? part.id,
content: text.join("\n"),
cache_control: options.cacheControl?.(part.cache),
})
@@ -478,12 +479,30 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
]
: [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages = [...system]
const modelID = request.model.id.toLowerCase()
const mistral = ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family))
const lowering = {
...options,
toolCallID: (id: string) => {
if (mistral) return id.replace(/[^a-zA-Z0-9]/g, "").slice(0, 9).padEnd(9, "0")
if (modelID.includes("claude")) return id.replace(/[^a-zA-Z0-9_-]/g, "_")
if (request.model.provider === "openai" || request.model.provider === "azure" || modelID.startsWith("openai/"))
return id.slice(0, 40)
return id
},
}
const requireAssistantAfterTool = request.model.compatibility?.requireAssistantAfterTool ?? mistral
const bridgeTools = () => {
if (requireAssistantAfterTool && messages.at(-1)?.role === "tool") messages.push({ role: "assistant", content: "Done." })
}
const pendingImages: Array<Schema.Schema.Type<typeof OpenAIChatUserContent>> = []
const flushImages = () => {
if (pendingImages.length === 0) return
bridgeTools()
messages.push({ role: "user", content: pendingImages.splice(0) })
}
for (const message of request.messages) {
if (message.role === "user") bridgeTools()
if (message.role === "system") {
const part = yield* ProviderShared.wrappedSystemUpdate("OpenAI Chat", message)
if (pendingImages.length > 0) {
@@ -526,14 +545,16 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
)
continue
}
if (message.role === "assistant" && message.content.every((part) => part.type === "text" && part.text.trim() === ""))
continue
if (message.role === "tool") {
const lowered = yield* lowerToolMessages(message, options)
const lowered = yield* lowerToolMessages(message, lowering)
messages.push(...lowered.messages)
pendingImages.push(...lowered.images)
continue
}
flushImages()
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, options)))
messages.push(...(yield* lowerMessage(message, request.model.compatibility?.reasoningField, lowering)))
}
flushImages()
return messages
@@ -659,7 +680,7 @@ const detectZaiToolStream = (
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
const cacheKey = ProviderShared.promptCacheKey(request)
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
@@ -17,6 +17,7 @@ export const route = Route.make({
protocol: OpenResponses.protocol,
endpoint: Endpoint.path(OpenResponses.PATH),
transport: OpenResponses.httpTransport,
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
@@ -166,7 +166,9 @@ const HOSTED_TOOLS = {
const step = (state: OpenResponses.ParserState, event: OpenResponses.Event) => {
if (event.type === "response.reasoning_text.delta")
return event.item_id
? Effect.succeed(OpenResponses.onReasoningDelta(state, event, event.item_id))
? Effect.succeed(
OpenResponses.onReasoningDelta(state, event, OpenResponses.outputItemID(state, event) ?? event.item_id),
)
: ProviderShared.eventError(ADAPTER, `${event.type} is missing item_id`)
if (event.type === "response.output_item.done" && event.item && ResponsesHostedTools.isItem(event.item, HOSTED_TOOLS))
return ResponsesHostedTools.onDone(state, event.item, HOSTED_TOOLS)
@@ -207,7 +209,7 @@ export const route = Route.make({
endpoint,
auth,
transport,
defaults: { providerOptions: { store: false } },
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
export * as OpenAIResponses from "./openai-responses.js"
+4 -4
View File
@@ -28,10 +28,10 @@ export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
// OpenAI limits `prompt_cache_key` to 64 chars; DeepSeek and Zai inherit the same
// limit via their OpenAI-compatible APIs. Clamp with unicode-aware slicing.
export const clampPromptCacheKey = (key: string | undefined): string | undefined => {
if (key === undefined) return undefined
const chars = Array.from(key)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return key
export const promptCacheKey = (request: LLMRequest): string | undefined => {
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
const chars = Array.from(request.promptCacheKey)
if (chars.length <= OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH) return request.promptCacheKey
return chars.slice(0, OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH).join("")
}
@@ -42,7 +42,61 @@ export function parseJSON(jsonString: string, allowPartial = Allow.ALL): unknown
try {
return decodeJson(input)
} catch {}
return _parseJSON(input, allowPartial)
const repaired = repairJSON(input)
if (repaired !== input) {
try {
return decodeJson(repaired)
} catch {}
}
try {
return _parseJSON(input, allowPartial)
} catch (error) {
if (repaired !== input) return _parseJSON(repaired, allowPartial)
throw error
}
}
const repairJSON = (input: string) => {
let repaired = ""
let quoted = false
for (let index = 0; index < input.length; index++) {
const character = input[index]
if (!quoted) {
repaired += character
if (character === '"') quoted = true
continue
}
if (character === '"') {
repaired += character
quoted = false
continue
}
if (character === "\\") {
const next = input[index + 1]
if (next === "u" && /^[0-9a-fA-F]{4}$/.test(input.slice(index + 2, index + 6))) {
repaired += input.slice(index, index + 6)
index += 5
continue
}
if (next !== undefined && '"\\/bfnrtu'.includes(next)) {
repaired += `\\${next}`
index++
continue
}
repaired += "\\\\"
continue
}
const code = character.charCodeAt(0)
repaired += code <= 0x1f ? `\\u${code.toString(16).padStart(4, "0")}` : character
}
return repaired
}
const _parseJSON = (jsonString: string, allow: number) => {
@@ -148,7 +202,12 @@ const _parseJSON = (jsonString: string, allow: number) => {
skipBlank()
index++
try {
object[key] = parseAny()
Object.defineProperty(object, key, {
value: parseAny(),
enumerable: true,
configurable: true,
writable: true,
})
} catch (error) {
if (Allow.OBJ & allow) return object
throw error
+23 -25
View File
@@ -1,5 +1,5 @@
import { Effect, Option } from "effect"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall, type ToolInputError } from "../../schema/index.js"
import { AIError, LLMEvent, type ProviderMetadata, type ToolCall } from "../../schema/index.js"
import { eventError, parseToolInput, type ToolAccumulator } from "../shared.js"
import { parse } from "./partial-json.js"
@@ -59,46 +59,44 @@ const inputStart = (tool: PendingTool) =>
providerMetadata: tool.providerMetadata,
})
const inputDelta = (tool: PendingTool, text: string) => {
const input = parsePartialInput(tool.input)
return LLMEvent.toolInputDelta({
const inputDelta = (tool: PendingTool, text: string) =>
LLMEvent.toolInputDelta({
id: tool.id,
name: tool.name,
text,
...(Option.isSome(input) ? { input: input.value } : {}),
input: Option.getOrElse(parsePartialInput(tool.input), () => ({})),
})
}
const toolCall = (route: string, tool: PendingTool, inputOverride?: string) => {
const raw = inputOverride ?? tool.input
return parseToolInput(route, tool.name, raw).pipe(
Effect.map((input): ToolCall | ToolInputError =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
Effect.catch((error) =>
tool.providerExecuted
? Effect.fail(error)
: Effect.succeed(
LLMEvent.toolInputError({
id: tool.id,
name: tool.name,
raw,
}),
Option.getOrElse(
Option.map(parsePartialInput(raw), (input) => input ?? {}),
() => ({}),
),
),
),
Effect.map(
(input): ToolCall =>
LLMEvent.toolCall({
id: tool.id,
name: tool.name,
input,
providerExecuted: tool.providerExecuted ? true : undefined,
providerMetadata: tool.providerMetadata,
}),
),
)
}
const finishEvents = (tool: PendingTool, event: ToolCall | ToolInputError): ReadonlyArray<LLMEvent> =>
event.type === "tool-input-error"
? [event]
: [LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }), event]
const finishEvents = (tool: PendingTool, event: ToolCall): ReadonlyArray<LLMEvent> => [
LLMEvent.toolInputEnd({ id: tool.id, name: tool.name, providerMetadata: tool.providerMetadata }),
event,
]
/** Store the updated tool and produce the optional public delta event. */
const appendTool = <K extends StreamKey>(
@@ -181,7 +179,7 @@ export const appendExisting = <K extends StreamKey>(
/**
* Finalize one pending tool call: parse the accumulated raw JSON, remove it
* from state, and return either a call or a non-executable local input error.
* from state, and recover incomplete local arguments when needed.
* Missing keys are a no-op because some providers emit stop events for
* non-tool content blocks.
*/
+1 -3
View File
@@ -9,7 +9,7 @@ import type { ProviderPackage } from "../provider-package.js"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile.js"
import * as OpenAIChat from "../protocols/openai-chat.js"
import { newBreakpoints, ttlBucket } from "../protocols/utils/cache.js"
import { isRecord, ProviderShared } from "../protocols/shared.js"
import { isRecord } from "../protocols/shared.js"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
@@ -115,12 +115,10 @@ export const protocol = Protocol.make({
reasoning_details: reasoningDetails,
}
})
const cacheKey = ProviderShared.clampPromptCacheKey(request.promptCacheKey)
return {
...body,
messages,
...bodyOptions(request.providerOptions),
...(cacheKey ? { prompt_cache_key: cacheKey } : {}),
} as OpenRouterBody
}),
),
+1 -1
View File
@@ -42,7 +42,7 @@ const responsesRoute = Route.make({
name: "xAI Responses",
rotateAfterMs: RESPONSES_WEBSOCKET_ROTATE_AFTER_MS,
}),
defaults: { providerOptions: { store: false } },
defaults: { providerOptions: { store: false, include: ["reasoning.encrypted_content"] } },
})
const chatRoute = Route.make({
+3 -1
View File
@@ -7,6 +7,7 @@ import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import { sanitizeSurrogates } from "../utils/sanitize.js"
import * as ProviderShared from "../protocols/shared.js"
import type { ProtocolID, ProviderOptions } from "../schema/index.js"
import {
@@ -400,7 +401,8 @@ export function make<Body, Prepared, Frame, Event, State>(
}
const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest, options?: StreamOptions) {
const resolved = applyCachePolicy(resolveRequestOptions(request))
const original = applyCachePolicy(resolveRequestOptions(request))
const resolved = LLMRequest.update(original, sanitizeSurrogates({ ...LLMRequest.input(original), model: undefined }))
const route = resolved.model.route
const body = yield* route.body
+1
View File
@@ -155,6 +155,7 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
reasoningField: Schema.optional(Schema.String),
maxTokensField: Schema.optional(LanguageModelMaxTokensFieldCompatibility),
requireFinishReason: Schema.optional(Schema.Boolean),
requireAssistantAfterTool: Schema.optional(Schema.Boolean),
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
+12
View File
@@ -0,0 +1,12 @@
import { isRecord } from "./record.js"
export const sanitizeSurrogates = <T>(value: T): T => {
if (typeof value === "string") return value.toWellFormed() as T
if (Array.isArray(value)) return value.map(sanitizeSurrogates) as T
if (value instanceof Uint8Array || value instanceof Error) return value
if (isRecord(value))
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => [key.toWellFormed(), sanitizeSurrogates(entry)]),
) as T
return value
}
+68 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "bun:test"
import { Effect, Ref, Schema } from "effect"
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, mergeProviderOptions } from "../src/index.js"
import { LLM, Message, ToolCallPart, mergeProviderOptions } from "../src/index.js"
import { AnthropicMessages, OpenAIChat } from "../src/protocols.js"
import { Auth, LLMClient } from "../src/route.js"
import { compileRequest } from "../src/route/client.js"
@@ -247,6 +247,73 @@ describe("request option precedence", () => {
}),
)
it.effect("sanitizes outbound JSON without an HTTP overlay", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
prompt: "hello \uD800 \u{1F600}",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [{ role: "user", content: "hello \uFFFD \u{1F600}" }],
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("sanitizes unpaired surrogates throughout outbound JSON", () =>
LLMClient.generate(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4o-mini" }),
system: "system \uD800 \u{1F600}",
messages: [
Message.user("user \uDC00"),
Message.assistant([
Message.text("assistant \uD800"),
ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "input \uDC00" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: { output: "result \uD800" } }),
],
http: { body: { metadata: { "key\uD800": ["overlay \uDC00", "valid \u{1F600}"] } } },
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
expect(decodeJson(input.text)).toMatchObject({
messages: [
{ role: "system", content: "system \uFFFD \u{1F600}" },
{ role: "user", content: "user \uFFFD" },
{
role: "assistant",
content: "assistant \uFFFD",
tool_calls: [{ function: { arguments: '{"query":"input \uFFFD"}' } }],
},
{ role: "tool", content: '{"output":"result \uFFFD"}' },
],
metadata: { "key\uFFFD": ["overlay \uFFFD", "valid \u{1F600}"] },
})
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
),
)
it.effect("applies raw body overlays after protocol lowering", () =>
LLMClient.generate(
LLM.request({
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+15
View File
@@ -18,6 +18,21 @@ describe("partial JSON", () => {
expect(() => parse('"hello', ~Allow.STR)).toThrow(PartialJSON)
})
test("repairs invalid escapes and raw control characters", () => {
expect(parse('{"path":"A\\H","text":"first\tsecond"}')).toEqual({
path: "A\\H",
text: "first\tsecond",
})
})
test("preserves prototype keys in partial objects", () => {
const object = parse('{"__proto__":{"safe":true}') as Record<string, unknown>
expect(Object.hasOwn(object, "__proto__")).toBe(true)
expect(Object.getPrototypeOf(object)).toBe(Object.prototype)
expect(object.__proto__).toEqual({ safe: true })
})
test("controls partial collection values independently", () => {
expect(parse('["', Allow.ARR)).toEqual([])
expect(parse('["', Allow.ARR | Allow.STR)).toEqual([""])
+9 -2
View File
@@ -95,7 +95,11 @@ describe("provider package entrypoints", () => {
})
expect(selected.route.defaults.headers).toEqual({ "x-application": "opencode" })
expect(selected.route.defaults.http?.body).toEqual({ service_tier: "priority" })
expect(selected.route.defaults.providerOptions).toEqual({ reasoningEffort: "low", store: true })
expect(selected.route.defaults.providerOptions).toEqual({
reasoningEffort: "low",
store: true,
include: ["reasoning.encrypted_content"],
})
})
test("maps Anthropic-compatible settings onto the executable model", async () => {
@@ -285,7 +289,10 @@ describe("provider package entrypoints", () => {
baseURL: "https://aiplatform.googleapis.com/v1/projects/vertex-project/locations/global/endpoints/openapi",
path: "/responses",
})
expect(responses.route.defaults.providerOptions).toEqual({ store: false })
expect(responses.route.defaults.providerOptions).toEqual({
store: false,
include: ["reasoning.encrypted_content"],
})
})
test("rejects conflicting Vertex auth settings at runtime", async () => {
@@ -770,6 +770,108 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("ignores unknown content block and delta variants", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "future_event", content_block: 42, delta: 42 },
{ type: "content_block_start", index: 0, content_block: { type: "future_block", text: 42 } },
{ type: "content_block_delta", index: 0, delta: { text: "ignored" } },
{ type: "content_block_delta", index: 0, delta: { type: "future_delta", text: 42 } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "hidden" } },
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "hidden" } },
{ type: "content_block_delta", index: 0, delta: { type: "signature_delta", signature: "hidden" } },
{ type: "content_block_stop", index: 0 },
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello" } },
{ type: "content_block_stop", index: 1 },
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 1 } },
{ type: "message_stop" },
),
),
),
)
expect(response.message.content).toEqual([{ type: "text", text: "Hello" }])
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("rejects malformed recognized content block variants", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: 42 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("rejects malformed recognized content delta variants", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "message_start", message: { usage: { input_tokens: 5 } } },
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: 42 } },
),
),
),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
it.effect("rejects malformed payloads on unrelated stream events", () =>
Effect.gen(function* () {
const events = [
{ type: "message_start", message: { usage: { input_tokens: 1 } }, delta: 42 },
{ type: "content_block_start", index: 0 },
{ type: "content_block_delta", index: 0 },
{ type: "content_block_stop", index: 0, content_block: { type: "text", text: 42 } },
{ type: "message_delta" },
{ type: "message_delta", delta: { stop_reason: 42 } },
{ type: "message_stop", delta: { text: 42 } },
{ type: "error", error: { type: "overloaded_error", message: "busy" }, content_block: 42 },
]
yield* Effect.forEach(events, (event) =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents(event))),
Effect.flip,
)
expect(error.reason).toMatchObject({
_tag: "InvalidProviderOutput",
message: "Invalid anthropic/anthropic-messages stream event",
})
}),
)
}),
)
it.effect("rejects malformed recognized SSE events", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -491,7 +491,7 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("emits malformed tool input as an unexecuted tool error", () =>
it.effect("recovers incomplete tool input at finalization", () =>
Effect.gen(function* () {
const body = eventStreamBody(
["messageStart", { role: "assistant" }],
@@ -508,10 +508,10 @@ describe("Bedrock Converse route", () => {
)
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.events.find((event) => event.type === "tool-input-error")).toMatchObject({
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
id: "tool_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "end_turn" })
}),
@@ -716,6 +716,32 @@ describe("Bedrock Converse route", () => {
}),
)
it.effect("ignores unknown normal stream events", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("futureEvent", { message: "Ignore this" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
])
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
expect(response.finishReason).toEqual({ normalized: "stop", raw: "end_turn" })
}),
)
it.effect("fails unknown stream exceptions after message stop", () =>
Effect.gen(function* () {
const body = concat([
eventFrame("messageStart", { role: "assistant" }),
eventFrame("messageStop", { stopReason: "end_turn" }),
exceptionFrame("futureException", { message: "A future provider failure" }),
])
const error = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)), Effect.flip)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "A future provider failure" })
}),
)
it.effect("classifies throttlingException as a rate limit", () =>
Effect.gen(function* () {
const body = concat([
@@ -85,6 +85,28 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits empty and whitespace-only assistant messages", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Before."),
Message.assistant([]),
Message.assistant(""),
Message.assistant(" \n\t "),
Message.assistant("After."),
],
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: "Before." },
{ role: "assistant", content: "After." },
])
}),
)
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -192,6 +214,21 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: "session_123",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
LLMClient.generate(
LLM.request({
@@ -351,6 +388,35 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
Effect.gen(function* () {
const id = `call_${"a".repeat(48)}`
const models = [
model,
Azure.configure({ baseURL: "https://opencode-test.openai.azure.com/openai/", apiKey: "test" }).chat("gpt-4o"),
]
yield* Effect.forEach(models, (selected) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id, name: "lookup", input: {} })]),
Message.tool({ id, name: "lookup", result: "Sunny" }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: id.slice(0, 40) }] },
{ role: "tool", tool_call_id: id.slice(0, 40) },
])
}),
)
}),
)
it.effect("preserves structured tool errors for the model", () =>
Effect.gen(function* () {
const error = { error: { type: "unknown", message: "Tool execution interrupted" } }
@@ -416,6 +482,30 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("bridges image tool results before their synthetic user message when required", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: LanguageModel.update(model, { compatibility: { requireAssistantAfterTool: true } }),
messages: [
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: {} })]),
Message.tool({
id: "call_image",
name: "read",
result: {
type: "content",
value: [{ type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" }],
},
}),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(["assistant", "tool", "assistant", "user"])
expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
it.effect("orders parallel tool responses before one aggregated vision message", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -238,6 +238,85 @@ describe("OpenAI-compatible Chat route", () => {
}),
)
it.effect("normalizes tool call IDs for the selected model family", () =>
Effect.gen(function* () {
const longID = `call_${"a".repeat(48)}`
const cases = [
{ provider: "custom", model: "mistral-small", id: "toolu_01CBhTTz95qkd9LJMdC9sf8t", expected: "toolu01CB" },
{ provider: "custom", model: "devstral-small", id: "abc", expected: "abc000000" },
{ provider: "custom", model: "codestral-latest", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "pixtral-large", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "custom", model: "open-mixtral-8x22b", id: "toolu_01CBhTTz95", expected: "toolu01CB" },
{ provider: "gateway", model: "anthropic/claude-sonnet-4", id: "call|item/+", expected: "call_item__" },
{ provider: "gateway", model: "openai/gpt-4o", id: longID, expected: longID.slice(0, 40) },
{ provider: "custom", model: "ordinary-model", id: "call|item/+", expected: "call|item/+" },
{ provider: "mistral", model: "zai-glm-5-2", id: "call_long_identifier", expected: "call_long_identifier" },
]
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAICompatibleChat.route
.with({ provider: item.provider, endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({ id: item.model }),
messages: [
Message.assistant([ToolCallPart.make({ id: item.id, name: "lookup", input: {} })]),
Message.tool({ id: item.id, name: "lookup", result: { type: "content", value: [] } }),
],
}),
)
expect(prepared.body.messages).toMatchObject([
{ role: "assistant", tool_calls: [{ id: item.expected }] },
{ role: "tool", tool_call_id: item.expected },
])
}),
)
}),
)
it.effect("bridges tool results for Mistral-family models and honors compatibility overrides", () =>
Effect.gen(function* () {
const cases = [
{ id: "mistral-small", bridge: true },
{ id: "devstral-small", bridge: true },
{ id: "codestral-latest", bridge: true },
{ id: "pixtral-large", bridge: true },
{ id: "open-mixtral-8x22b", bridge: true },
{ id: "ordinary-model", bridge: false },
{ id: "ordinary-model", override: true, bridge: true },
{ id: "mistral-small", override: false, bridge: false },
] as const
yield* Effect.forEach(cases, (item) =>
Effect.gen(function* () {
const selected = OpenAICompatibleChat.route
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
.model({
id: item.id,
compatibility: "override" in item ? { requireAssistantAfterTool: item.override } : undefined,
})
const prepared = yield* compileRequest(
LLM.request({
model: selected,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.user("What next?"),
],
}),
)
expect(prepared.body.messages.map((message) => message.role)).toEqual(
item.bridge ? ["assistant", "tool", "assistant", "user"] : ["assistant", "tool", "user"],
)
if (item.bridge) expect(prepared.body.messages[2]).toEqual({ role: "assistant", content: "Done." })
}),
)
}),
)
it.effect("posts to the configured compatible endpoint and parses text usage", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -52,10 +52,28 @@ describe("Open Responses-compatible route", () => {
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
stream: true,
store: false,
include: ["reasoning.encrypted_content"],
})
}),
)
it.effect("allows callers to override stateless encrypted reasoning defaults", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const prepared = yield* compileRequest(
LLM.request({ model, prompt: "Say hello.", providerOptions: { store: true, include: [] } }),
)
expect(prepared.body.store).toBe(true)
expect(prepared.body.include).toBeUndefined()
}),
)
it.effect("lowers chronological system updates as standard developer messages", () =>
Effect.gen(function* () {
const model = configure({
@@ -207,6 +225,105 @@ describe("Open Responses-compatible route", () => {
}),
)
it.effect("routes response deltas by output index", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Say hello." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openresponses: { itemId: "msg_1" } } },
])
}),
)
it.effect("finalizes pending function calls from completed response output", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
provider: "example",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Look it up." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"query":"par' },
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"complete"}',
},
],
},
},
),
),
),
)
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
input: { query: "complete" },
providerMetadata: { openresponses: { itemId: "item_1" } },
})
}),
)
it.effect("preserves terminal reasoning metadata when item completion is missing", () =>
Effect.gen(function* () {
const model = configure({
apiKey: "test-key",
baseURL: "https://responses.example.test/v1",
}).model("example-model")
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think it through." })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_raw", encrypted_content: null },
},
{ type: "response.reasoning_summary_text.delta", item_id: "rs_raw", delta: "Thinking" },
{
type: "response.completed",
response: {
output: [{ type: "reasoning", id: "rs_raw", encrypted_content: "raw-state" }],
},
},
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openresponses: { itemId: "rs_raw", reasoningEncryptedContent: "raw-state" } },
})
}),
)
it.effect("reconciles raw reasoning finals without streamed deltas", () =>
Effect.gen(function* () {
const model = configure({
@@ -117,6 +117,7 @@ describe("OpenAI Responses route", () => {
{ role: "user", content: [{ type: "input_text", text: "Say hello." }] },
],
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
max_output_tokens: 20,
temperature: 0,
@@ -313,7 +314,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.route).toBe("openai-responses")
expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "http-json" })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
expect(prepared.body).toMatchObject({
model: "gpt-4.1-mini",
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
})
}),
)
@@ -385,6 +391,7 @@ describe("OpenAI Responses route", () => {
model: "gpt-4.1-mini",
input: [{ role: "user", content: [{ type: "input_text", text: "Say hello." }] }],
store: false,
include: ["reasoning.encrypted_content"],
})
}),
)
@@ -511,6 +518,56 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("continues a tool call from authoritative completed response output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
],
},
}),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
@@ -759,6 +816,49 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("sanitizes outbound WebSocket requests and HTTP fallback bodies", () =>
Effect.gen(function* () {
const message = yield* Ref.make("")
const body = yield* Ref.make("")
yield* LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say \uD800hello \u{1F600}.",
http: { body: { metadata: { source: "overlay\uDC00" } } },
}),
{
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
yield* exchange.driver
.create(undefined)
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.set(body, input.text)
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
const expected = {
input: [{ role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }],
metadata: { source: "overlay\uFFFD" },
}
expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected)
expect(JSON.parse(yield* Ref.get(body))).toMatchObject(expected)
}),
)
it.effect("builds xAI WebSocket requests without OpenAI handshake headers", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
@@ -1189,6 +1289,7 @@ describe("OpenAI Responses route", () => {
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
],
store: false,
include: ["reasoning.encrypted_content"],
stream: true,
max_output_tokens: undefined,
temperature: undefined,
@@ -1633,11 +1734,11 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits include when no include is set", () =>
it.effect("requests encrypted reasoning by default", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "hi", providerOptions: { store: false } }))
expect(prepared.body.include).toBeUndefined()
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
@@ -1691,6 +1792,21 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: "Hello",
promptCacheKey: "request_cache",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("parses text and usage stream fixtures", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -1937,6 +2053,163 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("routes assistant text by output index when its item id disagrees", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 2, item_id: "wrong_message", delta: "Indexed" },
{ type: "response.output_item.done", output_index: 2, item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Indexed")
expect(response.message.content).toEqual([
{ type: "text", text: "Indexed", providerMetadata: { openai: { itemId: "msg_1" } } },
])
}),
)
it.effect("routes interleaved function calls by output index", () =>
Effect.gen(function* () {
const first = { type: "function_call", id: "fc_1", call_id: "call_1", name: "first", arguments: "" }
const second = { type: "function_call", id: "fc_2", call_id: "call_2", name: "second", arguments: "" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: first },
{ type: "response.output_item.added", output_index: 3, item: second },
{ type: "response.function_call_arguments.delta", output_index: 1, item_id: "fc_2", delta: '{"a":' },
{ type: "response.function_call_arguments.delta", output_index: 3, item_id: "fc_1", delta: '{"b":' },
{
type: "response.function_call_arguments.done",
output_index: 3,
item_id: "fc_1",
arguments: '{"b":2}',
},
{
type: "response.function_call_arguments.done",
output_index: 1,
item_id: "fc_2",
arguments: '{"a":1}',
},
{ type: "response.output_item.done", output_index: 1, item: { ...first, arguments: '{"a":1}' } },
{ type: "response.output_item.done", output_index: 3, item: { ...second, arguments: '{"b":2}' } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.events.filter((event) => event.type === "tool-input-delta")).toMatchObject([
{ id: "call_1", text: '{"a":' },
{ id: "call_2", text: '{"b":' },
{ id: "call_2", text: "2}" },
{ id: "call_1", text: "1}" },
])
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", name: "first", input: { a: 1 } }),
expect.objectContaining({ id: "call_2", name: "second", input: { b: 2 } }),
])
}),
)
it.effect("routes reasoning summary events by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 4,
item: { type: "reasoning", id: "rs_1" },
},
{
type: "response.reasoning_summary_part.added",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.reasoning_summary_text.delta",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Thinking",
},
{
type: "response.reasoning_summary_part.done",
output_index: 4,
item_id: "wrong_reasoning",
summary_index: 0,
},
{
type: "response.output_item.done",
output_index: 4,
item: { type: "reasoning", id: "rs_1", encrypted_content: "state" },
},
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Thinking")
expect(response.message.content).toEqual([
{
type: "reasoning",
text: "Thinking",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "state" } },
},
])
}),
)
it.effect("routes native reasoning text deltas by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.reasoning_text.delta", output_index: 1, item_id: "wrong_reasoning", delta: "Raw" },
{ type: "response.output_item.done", output_index: 1, item: { type: "reasoning", id: "rs_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.reasoning).toBe("Raw")
}),
)
it.effect("falls back to item ids when an output index was not registered", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 9, item_id: "msg_1", delta: "Fallback" },
{ type: "response.output_item.done", item: { type: "message", id: "msg_1" } },
{ type: "response.completed", response: { id: "resp_1" } },
),
),
),
)
expect(response.text).toBe("Fallback")
}),
)
it.effect("rejects output text events without the spec-required item id", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
@@ -1956,6 +2229,25 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("requires item ids even when their output index is known", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", output_index: 0, item: { type: "message", id: "msg_1" } },
{ type: "response.output_text.delta", output_index: 0, delta: "Missing item ID" },
),
),
),
Effect.flip,
)
expect(error.reason._tag).toBe("InvalidProviderOutput")
expect(error.message).toContain("response.output_text.delta is missing item_id")
}),
)
it.effect("ignores deltas without a matching output item", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(
@@ -2171,6 +2463,147 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("preserves terminal reasoning metadata when output item completion is missing", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
{
type: "response.reasoning_summary_text.delta",
item_id: "rs_1",
summary_index: 0,
delta: "Checked the diff.",
},
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "reasoning",
id: "rs_1",
encrypted_content: "terminal-state",
summary: [{ type: "summary_text", text: "Checked the diff." }],
},
],
},
},
),
),
),
)
expect(response.reasoning).toBe("Checked the diff.")
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
{
type: "reasoning-end",
id: "rs_1:0",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
},
])
expect(response.message.content).toContainEqual({
type: "reasoning",
text: "Checked the diff.",
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
})
const prepared = yield* compileRequest(
LLM.request({ model, messages: [response.message], providerOptions: { store: false } }),
)
expect(prepared.body.input).toEqual([
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the diff." }],
encrypted_content: "terminal-state",
},
])
}),
)
it.effect("does not repeat reasoning already finalized by an output item", () =>
Effect.gen(function* () {
const item = { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" }
const response = yield* LLMClient.generate(request).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{ type: "response.output_item.added", item: { ...item, encrypted_content: null } },
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { output: [item] } },
),
),
),
)
expect(response.events.filter((event) => event.type === "reasoning-start")).toHaveLength(1)
expect(response.events.filter((event) => event.type === "reasoning-end")).toHaveLength(1)
expect(response.message.content.filter((part) => part.type === "reasoning")).toHaveLength(1)
expect(response.reasoning).toBe("Thinking")
}),
)
it.effect("reconciles pending reasoning and function calls in completed output order", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLMRequest.update(request, { providerOptions: { store: false } }),
).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
},
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "Thinking" },
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_1", delta: '{"query":"wea' },
{
type: "response.completed",
response: {
output: [
{ type: "reasoning", id: "rs_1", encrypted_content: "terminal-state" },
{
type: "function_call",
id: "fc_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
],
},
},
),
),
),
)
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "terminal-state" } },
})
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
expect.objectContaining({ id: "call_1", input: { query: "weather" } }),
])
expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
response.events.findIndex(LLMEvent.is.toolCall),
)
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("streams each reasoning summary part as a separate block", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
@@ -2724,7 +3157,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
it.effect("replays stateless reasoning without encrypted state", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -2754,6 +3187,12 @@ describe("OpenAI Responses route", () => {
expect(prepared.body).toMatchObject({
input: [
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
{
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
encrypted_content: null,
},
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
],
@@ -3028,6 +3467,163 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("treats empty completed output item arguments as authoritative", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{
type: "response.output_item.done",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.completed", response: { id: "resp_1" } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: {} })
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("uses completed response output when output item completion is missing", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"wea' },
{
type: "response.completed",
response: {
id: "resp_1",
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
},
],
},
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
input: { query: "weather" },
providerMetadata: { openai: { itemId: "fc_item_1" } },
})
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
expect(response.finishReason.normalized).toBe("tool-calls")
}),
)
it.effect("lets completed response output override arguments done", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{
type: "response.function_call_arguments.done",
item_id: "fc_item_1",
arguments: '{"query":"arguments-done"}',
},
{
type: "response.completed",
response: {
output: [
{
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"completed"}',
},
],
},
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: { query: "completed" } })
}),
)
it.effect("preserves explicit empty arguments from completed response output", () =>
Effect.gen(function* () {
const body = sseEvents(
{
type: "response.output_item.added",
item: { type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" },
},
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: '{"query":"streamed"}' },
{
type: "response.completed",
response: {
output: [{ type: "function_call", id: "fc_item_1", call_id: "call_1", name: "lookup", arguments: "" }],
},
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({ input: {} })
}),
)
it.effect("does not repeat function calls already finalized by an output item", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"weather"}',
}
const body = sseEvents(
{ type: "response.output_item.added", item: { ...item, arguments: "" } },
{ type: "response.output_item.done", item },
{ type: "response.completed", response: { output: [item] } },
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter(LLMEvent.is.toolInputEnd)).toHaveLength(1)
expect(response.events.filter(LLMEvent.is.toolCall)).toHaveLength(1)
}),
)
it.effect("does not finalize pending function calls from incomplete response output", () =>
Effect.gen(function* () {
const item = {
type: "function_call",
id: "fc_item_1",
call_id: "call_1",
name: "lookup",
arguments: '{"query":"partial',
}
const body = sseEvents(
{ type: "response.output_item.added", item: { ...item, arguments: "" } },
{ type: "response.function_call_arguments.delta", item_id: "fc_item_1", delta: item.arguments },
{
type: "response.incomplete",
response: { incomplete_details: { reason: "max_output_tokens" }, output: [item] },
},
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
expect(response.finishReason.normalized).toBe("length")
}),
)
it.effect("finalizes a pending function call at response completion", () =>
Effect.gen(function* () {
const body = sseEvents(
@@ -3061,7 +3657,7 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("emits malformed final function arguments as an unexecuted tool error", () =>
it.effect("recovers authoritative incomplete final function arguments", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3087,18 +3683,17 @@ describe("OpenAI Responses route", () => {
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toEqual({
type: "tool-input-error",
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason.normalized).toBe("tool-calls")
expect(response.events.some(LLMEvent.is.toolCall)).toBeFalse()
expect(response.events.some(LLMEvent.is.toolInputError)).toBeFalse()
}),
)
it.effect("settles malformed function arguments when output_item.added is absent", () =>
it.effect("recovers incomplete function arguments when output_item.added is absent", () =>
Effect.gen(function* () {
const body = sseEvents(
{
@@ -3115,10 +3710,10 @@ describe("OpenAI Responses route", () => {
)
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.find(LLMEvent.is.toolInputError)).toMatchObject({
expect(response.events.find(LLMEvent.is.toolCall)).toMatchObject({
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
input: { query: "partial" },
})
expect(response.finishReason.normalized).toBe("tool-calls")
}),
@@ -190,6 +190,21 @@ describe("OpenRouter", () => {
}),
)
it.effect("omits the prompt cache key when caching is disabled", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenRouter.configure({ apiKey: "test-key" }).model("openai/gpt-4o-mini"),
prompt: "Hello",
promptCacheKey: "session_123",
cache: "none",
}),
)
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
it.effect("filters invalid known OpenRouter options while preserving extensions", () =>
Effect.gen(function* () {
const invalid: Record<string, unknown> = {
@@ -21,6 +21,17 @@ describe("xAI Responses route", () => {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello" }))
expect(prepared.protocol).toBe("xai-responses")
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
}),
)
it.effect("allows callers to opt out of encrypted reasoning", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(LLM.request({ model, prompt: "Hello", providerOptions: { include: [] } }))
expect(prepared.body.store).toBe(false)
expect(prepared.body.include).toBeUndefined()
}),
)
@@ -59,6 +70,42 @@ describe("xAI Responses route", () => {
}),
)
it.effect("routes xAI reasoning summaries by output index", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Think" })).pipe(
Effect.provide(
fixedResponse(
sseEvents(
{
type: "response.output_item.added",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1" },
},
{
type: "response.reasoning_summary_text.delta",
output_index: 3,
item_id: "wrong_reasoning",
summary_index: 0,
delta: "Considering.",
},
{
type: "response.output_item.done",
output_index: 3,
item: { type: "reasoning", id: "reasoning_1", encrypted_content: "opaque" },
},
{ type: "response.completed", response: { id: "response_1" } },
),
),
),
)
expect(response.reasoning).toBe("Considering.")
expect(response.message.content.find((part) => part.type === "reasoning")).toMatchObject({
providerMetadata: { xai: { itemId: "reasoning_1", reasoningEncryptedContent: "opaque" } },
})
}),
)
it.effect("parses xAI hosted tool items", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(LLM.request({ model, prompt: "Search X" })).pipe(
@@ -78,6 +78,31 @@ describe("Z.ai Images", () => {
),
)
it.effect("sanitizes unpaired surrogates in outbound image requests", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test", http: { body: { metadata: { source: "default\uDC00" } } } }).image("model"),
prompt: "A red circle \uD800 on a white background \u{1F600}",
}).pipe(
Effect.provide(
ImageClient.layer.pipe(
Layer.provide(
dynamicResponse((input) => {
expect(JSON.parse(input.text)).toMatchObject({
prompt: "A red circle \uFFFD on a white background \u{1F600}",
metadata: { source: "default\uFFFD" },
})
return Effect.succeed(
input.respond(JSON.stringify({ data: [{ url: "https://example.test/image.jpg" }] }), {
headers: { "content-type": "application/json" },
}),
)
}),
),
),
),
),
)
it.effect("lets raw native options override aliases", () =>
Image.generate({
model: ZAI.configure({ apiKey: "test" }).image("model"),
+40 -16
View File
@@ -59,7 +59,7 @@ describe("ToolStream", () => {
}),
)
it.effect("omits partial input when the accumulated value cannot be parsed", () =>
it.effect("defaults partial input to an empty object when the accumulated value cannot be parsed", () =>
Effect.gen(function* () {
const result = ToolStream.appendOrStart(
ADAPTER,
@@ -72,7 +72,7 @@ describe("ToolStream", () => {
expect(result.events).toEqual([
{ type: "tool-input-start", id: "call_1", name: "lookup" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x" },
{ type: "tool-input-delta", id: "call_1", name: "lookup", text: "x", input: {} },
])
}),
)
@@ -132,7 +132,7 @@ describe("ToolStream", () => {
}),
)
it.effect("finalizes malformed local input as a non-executable tool error", () =>
it.effect("finalizes incomplete local input using the partial JSON parser", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
@@ -144,18 +144,46 @@ describe("ToolStream", () => {
expect(finished).toEqual({
tools: {},
events: [
{
type: "tool-input-error",
id: "call_1",
name: "lookup",
raw: '{"query":"partial',
},
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "partial" } },
],
})
}),
)
it.effect("preserves valid siblings when one parallel input is malformed", () =>
it.effect("repairs malformed string escapes in final local input", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: '{"path":"A\\H","text":"first\tsecond"}',
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: { path: "A\\H", text: "first\tsecond" } },
])
}),
)
it.effect("defaults unrecoverable local input to an empty object", () =>
Effect.gen(function* () {
const tools = ToolStream.start(ToolStream.empty<string>(), "item_1", {
id: "call_1",
name: "lookup",
input: "invalid",
})
const finished = yield* ToolStream.finish(ADAPTER, tools, "item_1")
expect(finished.events).toEqual([
{ type: "tool-input-end", id: "call_1", name: "lookup" },
{ type: "tool-call", id: "call_1", name: "lookup", input: {} },
])
}),
)
it.effect("recovers incomplete input alongside valid parallel tool calls", () =>
Effect.gen(function* () {
const valid = ToolStream.start(ToolStream.empty<number>(), 0, {
id: "call_valid",
@@ -174,12 +202,8 @@ describe("ToolStream", () => {
events: [
{ type: "tool-input-end", id: "call_valid", name: "lookup" },
{ type: "tool-call", id: "call_valid", name: "lookup", input: { query: "weather" } },
{
type: "tool-input-error",
id: "call_invalid",
name: "lookup",
raw: '{"query":"partial',
},
{ type: "tool-input-end", id: "call_invalid", name: "lookup" },
{ type: "tool-call", id: "call_invalid", name: "lookup", input: { query: "partial" } },
],
})
}),
@@ -0,0 +1,53 @@
import { expect, test } from "@playwright/test"
import { mockOpenCodeServer } from "../utils/mock-server"
import { expectAppVisible } from "../utils/waits"
const draftID = "draft_new_session_workspace_branch"
const directory = "C:/OpenCode/WorkspaceBranch"
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
test("selects a base branch for a new workspace", async ({ page }) => {
await mockOpenCodeServer(page, {
directory,
project: {
id: "proj_new_session_workspace_branch",
worktree: directory,
vcs: "git",
name: "workspace-branch",
time: { created: 1700000000000, updated: 1700000000000 },
sandboxes: [],
},
provider: { all: [], connected: [], default: {} },
sessions: [],
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.addInitScript(
({ directory, draftID, server }) => {
localStorage.setItem(
"opencode.global.dat:server",
JSON.stringify({
projects: { local: [{ worktree: directory, expanded: true }] },
lastProject: { local: directory },
}),
)
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([{ type: "draft", draftID, server, directory }]),
)
},
{ directory, draftID, server },
)
await page.goto(`/new-session?draftId=${draftID}`)
await expectAppVisible(page.locator('[data-component="composer-editor"]'))
await page.getByRole("button", { name: "Local", exact: true }).click()
await page.getByRole("menuitem", { name: "New workspace", exact: true }).click()
await page.getByRole("button", { name: "from main", exact: true }).click()
await page.getByRole("menuitemradio", { name: "feature/api", exact: true }).click()
const selected = page.getByRole("button", { name: "from feature/api", exact: true })
await expect(selected).toBeVisible()
await selected.click()
await expect(page.getByRole("menuitemradio", { name: "feature/api", exact: true })).toBeChecked()
})
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:file:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
@@ -59,12 +59,12 @@ test("transitions a streaming shell from writing through command execution", asy
await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveCSS("height", "28px")
await expect(tool.locator('[data-component="tool-trigger"]')).toHaveCSS("gap", "6px")
await expect(title).toHaveCSS("font-size", "13px")
await expect(title).toHaveCSS("font-family", "Inter, sans-serif")
await expect(title).toHaveCSS("font-family", /^Inter,/)
await expect(title).toHaveCSS("font-weight", "530")
await expect(title).toHaveCSS("line-height", "16px")
await expect(title).toHaveCSS("color", "rgb(22, 22, 22)")
await expect(subtitle).toHaveCSS("font-size", "13px")
await expect(subtitle).toHaveCSS("font-family", "Inter, sans-serif")
await expect(subtitle).toHaveCSS("font-family", /^Inter,/)
await expect(subtitle).toHaveCSS("font-weight", "440")
await expect(subtitle).toHaveCSS("line-height", "16px")
await expect(subtitle).toHaveCSS("color", "rgb(92, 92, 92)")
@@ -64,6 +64,76 @@ test("transitions shell and question through running error outcomes", async ({ p
await expect(page.locator(`[data-timeline-part-id="${questionID}"]`)).toContainText(/dismissed/i)
})
test("preserves surviving grouped patch state when its first patch fails", async ({ page }) => {
const failed = "prt_grouped_patch_failed"
const surviving = "prt_grouped_patch_surviving"
const timeline = await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage(
[
toolPart(failed, "patch", "running", { patchText: "Update src/failed.ts" }),
toolPart(
surviving,
"patch",
"running",
{ patchText: "Update src/surviving.ts" },
{
metadata: {
files: [
{
file: "src/surviving.ts",
status: "modified",
patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2",
additions: 1,
deletions: 1,
},
],
},
},
),
],
{ completed: false },
),
],
})
const group = page.locator(`[data-timeline-part-ids="${failed},${surviving}"]`)
const file = group.locator('[data-scope="apply-patch"] button')
await expect(file).toBeVisible()
await file.click()
await expect(file).toHaveAttribute("aria-expanded", "true")
await group.evaluate((element) => {
const row = element.closest<HTMLElement>("[data-timeline-key]")
if (row) row.dataset.groupIdentity = "preserved"
})
await timeline.send(
partUpdated(
toolPart(failed, "patch", "error", { patchText: "Update src/failed.ts" }, { error: "Patch failed visibly" }),
),
)
const failedRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${failed}"]`),
})
const survivingRow = page.locator("[data-timeline-key]", {
has: page.locator(`[data-timeline-part-id="${surviving}"]`),
})
await expect(failedRow).toHaveAttribute("data-timeline-key", /^assistant-part:part:/)
await expect(survivingRow).toHaveAttribute("data-timeline-key", /^assistant-part:file:/)
await expect(failedRow.getByText("Patch failed visibly")).toBeVisible()
await expect(survivingRow).toHaveAttribute("data-group-identity", "preserved")
await expect(survivingRow.locator('[data-scope="apply-patch"] button')).toHaveAttribute("aria-expanded", "true")
await expect
.poll(async () => {
const previous = await failedRow.boundingBox()
const next = await survivingRow.boundingBox()
return previous && next ? next.y - (previous.y + previous.height) : Number.NEGATIVE_INFINITY
})
.toBeGreaterThanOrEqual(-0.5)
})
test("labels all web search provider variants", async ({ page }) => {
const parts = [
toolPart(
@@ -68,6 +68,41 @@ test("keyboard navigation follows the visible tab order", async ({ page }) => {
await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
})
test("cramped tabs only show the close button for the active tab", async ({ page }) => {
await page.setViewportSize({ width: 360, height: 720 })
await mockServer(page)
await page.addInitScript(
({ server, sessionA, sessionB, sessionC }) => {
localStorage.setItem(
"opencode.window.browser.dat:tabs",
JSON.stringify([
{ type: "session", server, sessionId: sessionA },
{ type: "session", server, sessionId: sessionB },
{ type: "session", server, sessionId: sessionC },
]),
)
},
{ server, sessionA: sessionA.id, sessionB: sessionB.id, sessionC: sessionC.id },
)
const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}`
const hrefB = `/server/${base64Encode(server)}/session/${sessionB.id}`
await page.goto(hrefA)
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefA}"])`)
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefB}"])`)
await expect(tabA).toHaveAttribute("data-active", "true")
await expect(tabB).toBeVisible()
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
await tabB.locator(`a[href="${hrefB}"]`).click()
await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`))
await expect(tabA.locator('[data-slot="tab-close"]')).toBeHidden()
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
})
function session(id: string, title: string) {
return {
id,
+1
View File
@@ -100,6 +100,7 @@ const Group = HttpApiGroup.make("mock")
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
+2
View File
@@ -21,6 +21,7 @@ export interface MockServerConfig {
cursor?: string
}
vcsDiff?: unknown[]
vcsBranches?: string[]
messageDelay?: number
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
@@ -296,6 +297,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
vcs: () =>
Effect.succeed({ location: location(config), data: { branch: { current: "main", default: "main" } } }),
vcsStatus: () => Effect.succeed({ location: location(config), data: [] }),
vcsBranches: () => Effect.succeed({ location: location(config), data: config.vcsBranches ?? ["main"] }),
vcsDiff: () => Effect.succeed({ location: location(config), data: config.vcsDiff ?? [] }),
fsList: (ctx) =>
Effect.promise(() => Promise.resolve(config.fileList?.(ctx.query.path ?? ""))).pipe(
+11 -3
View File
@@ -1,7 +1,8 @@
import { createEffect, createMemo, For, Show, type JSX } from "solid-js"
import { createEffect, createMemo, createSignal, For, Show, type JSX } from "solid-js"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { useI18n } from "@opencode-ai/ui/context/i18n"
import { Button } from "@opencode-ai/ui/button"
@@ -716,16 +717,23 @@ function ComposerEditorAlternateDelivery(props: { controller: ComposerEditorMode
if (queue.editing()) return "steer" as const
return queue.alternate()
})
const [button, setButton] = createSignal<HTMLButtonElement>()
const presence = createAnimatedPresence(action, () => button() ?? null)
return (
<Show when={action()} keyed>
<Show when={presence.present() && presence.value()} keyed>
{(delivery) => (
<Tooltip placement="top" inactive={delivery !== "steer"} value={i18n.t("ui.promptInput.steerHint")}>
<Button
ref={setButton}
data-action="composer-alternate-delivery"
type="button"
variant="ghost-muted"
size="small"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530]"
class="me-3 gap-1.5 px-1.5 text-v2-text-text-muted ![font-weight:530] duration-150 motion-reduce:animate-none"
classList={{
"animate-in fade-in": presence.animate() && presence.show(),
"animate-out fade-out fill-mode-forwards": presence.animate() && !presence.show(),
}}
onClick={() => props.controller.submit({ alternate: true })}
>
{delivery === "steer" ? i18n.t("ui.promptInput.steer") : i18n.t("ui.promptInput.queue")}
+2 -3
View File
@@ -3,7 +3,7 @@ import { type HomeProjectSelection, useLayout } from "@/shell/state/layout"
import { ServerConnection, useServers } from "@/runtime/server/registry"
import { useTabs } from "@/shell/tabs/tabs"
import { toggleHomeProjectSelection } from "@/shell/layout/helpers"
import { createEffect, createMemo, startTransition } from "solid-js"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const layout = useLayout()
@@ -49,8 +49,7 @@ export function createHomeController() {
selection: {
value: selection,
set: setSelection,
focusServer: (conn: ServerConnection.Any) =>
void startTransition(() => setSelection({ server: ServerConnection.key(conn) })),
focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }),
},
server: {
list: () => servers.visible,
+5 -17
View File
@@ -5,7 +5,6 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, Dialog } from "@opencode-
import { skipToken, useQuery, useQueryClient } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, type JSX, startTransition, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { notifySessionTabsRemoved } from "@/shell/titlebar/session-events"
import { useCommand } from "@/shell/commands/command"
import { loadHomeSessionIndex, mergeHomeSessionIndex, retainHomeSessions } from "@/home/sessions/index"
@@ -43,7 +42,6 @@ export function createHomeSessionsController(home: HomeController) {
const dialog = useDialog()
const language = useLanguage()
const queryClient = useQueryClient()
const [removed, setRemoved] = createStore({ keys: [] as string[] })
const projectDirectories = createMemo(() => {
const selected = home.selection.value().directory
if (!selected) return
@@ -70,10 +68,9 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = home.server.focusedContext()
const conn = home.server.focused()
if (!ctx || !conn) return []
const server = ServerConnection.key(conn)
return retainHomeSessions(
mergeHomeSessionIndex(sessionLoad.data?.() ?? [], ctx.data.session.list()).filter(
(session) => !removed.keys.includes(`${server}\0${session.id}`),
ctx.data.session.apply(
mergeHomeSessionIndex(sessionLoad.isPending ? [] : (sessionLoad.data?.() ?? []), ctx.data.session.list()),
),
HOME_SESSION_LIMIT,
Date.now(),
@@ -192,15 +189,9 @@ export function createHomeSessionsController(home: HomeController) {
const ctx = conn ? home.server.context(conn) : undefined
if (!conn || !ctx) return false
const ids = [...removedSessionIDs(ctx.data.session.list(), session.id)]
await queryClient.cancelQueries({ queryKey: ["home-sessions", conn], exact: true })
return ctx.sdk.api.session
.remove({ sessionID: session.id })
return ctx.data.session
.remove(session.id)
.then(() => {
const removedIDs = new Set(ids)
setRemoved("keys", (current) => [...new Set([...current, ...ids.map((id) => `${server}\0${id}`)])])
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) =>
current?.filter((item) => !removedIDs.has(item.id)),
)
notifySessionTabsRemoved({
server: ServerConnection.key(conn),
directory: session.location.directory,
@@ -216,9 +207,6 @@ export function createHomeSessionsController(home: HomeController) {
return false
})
.finally(() => {
// Always refetch: the pre-mutation cancel may have aborted an
// in-flight index fetch, and a failed delete must not leave the
// index unloaded either.
void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
})
}
@@ -256,7 +244,7 @@ export function createHomeSessionsController(home: HomeController) {
data: {
records,
groups,
loading: () => sessionLoad.isLoading,
loading: () => sessionLoad.isPending,
searchRecords: allRecords,
},
session: {
@@ -12,6 +12,7 @@ export function HomeSessions(props: {
<HomeSessionsView
language={props.sessions.copy.language}
groups={props.sessions.data.groups()}
loading={props.sessions.data.loading()}
showProjectName={props.sessions.session.showProjectName()}
server={props.sessions.session.server()}
canCreateSession={props.sessions.session.canCreate()}
+19 -19
View File
@@ -1,6 +1,6 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Key } from "@solid-primitives/keyed"
import { createMemo, For, Index, onCleanup, Show, Suspense } from "solid-js"
import { createMemo, For, Index, onCleanup, Show } from "solid-js"
import { createStore, type SetStoreFunction } from "solid-js/store"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { Spinner } from "@opencode-ai/ui/spinner"
@@ -44,6 +44,7 @@ function isBackgroundOpen(event: MouseEvent) {
export type HomeSessionsViewProps = {
language: ReturnType<typeof useLanguage>
groups: HomeSessionGroup[]
loading: boolean
showProjectName: boolean
server: ServerConnection.Key
canCreateSession: boolean
@@ -97,22 +98,20 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
>
<div class="sticky top-0 z-30 shrink-0 bg-v2-background-bg-base pb-3 pt-6 lg:pt-12" onWheel={props.onWheel}>
<HomeSessionSearch {...props} />
<Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</Suspense>
<Show when={props.groups.length > 0 && props.canCreateSession}>
<div class="pointer-events-none absolute right-0 top-[84px] z-20 flex lg:top-[108px]">
<Button
data-action="home-new-session"
variant="ghost-muted"
size="normal"
icon="edit"
class="pointer-events-auto h-7 px-2 [font-weight:530]"
onClick={props.onCreateSession}
>
{props.language.t("command.session.new")}
</Button>
</div>
</Show>
</div>
<div class="pointer-events-none sticky top-[84px] z-40 h-0 -mr-3 lg:top-[108px]">
<div
@@ -122,7 +121,8 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
/>
</div>
<div class="-mr-3 min-h-[calc(100cqh-72px)] lg:min-h-[calc(100cqh-96px)]">
<Suspense
<Show
when={!props.loading}
fallback={
<div class="pt-3">
<HomeSessionSkeleton label={props.language.t("common.loading")} />
@@ -164,7 +164,7 @@ export function HomeSessionsView(props: HomeSessionsViewProps) {
</Index>
</div>
</Show>
</Suspense>
</Show>
</div>
</section>
)
@@ -20,6 +20,7 @@ import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/
export function createNewSessionComposerAdapter(props: {
draftID: string
worktree: () => string
branch: () => string | undefined
submitted: () => void
}) {
const route = useSessionKey()
@@ -48,6 +49,7 @@ export function createNewSessionComposerAdapter(props: {
const sessionDirectory = await resolveSessionDirectory({
projectDirectory,
worktree,
branch: props.branch(),
data,
serverSDK,
language,
@@ -73,7 +75,7 @@ export function createNewSessionComposerAdapter(props: {
return { ok: false as const, error }
},
)
const afterCreation = async <T,>(run: () => Promise<T>) => {
const afterCreation = async <T>(run: () => Promise<T>) => {
const result = await creation
if (!result.ok) throw result.error
return run()
@@ -83,7 +85,7 @@ export function createNewSessionComposerAdapter(props: {
SessionRouteKey.fromRoute(base64Encode(sessionDirectory), created.id),
)
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
tabs.updateDraft(props.draftID, { worktree: undefined, branch: undefined })
local.session.promote(sessionDirectory, created.id, {
agent: selection.agent,
model: selection.model,
@@ -161,6 +163,7 @@ function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["
async function resolveSessionDirectory(input: {
projectDirectory: string
worktree: string
branch?: string
data: ReturnType<typeof useData>
serverSDK: ReturnType<typeof useServerSDK>
language: ReturnType<typeof useLanguage>
@@ -172,6 +175,7 @@ async function resolveSessionDirectory(input: {
.create({
projectID: input.data.location.info({ directory: input.projectDirectory })?.project.id ?? "",
strategy: "git",
branch: input.branch,
directory: getDirectory(
input.data.location.info({ directory: input.projectDirectory })?.project.directory ?? input.projectDirectory,
),
@@ -39,6 +39,7 @@ export function createComposerProjectControls(props: { draftId: string }) {
server: ServerConnection.key(connection),
directory: worktree,
worktree: undefined,
branch: undefined,
})
}
const addProject = (title: string, serverKey?: string) => {
@@ -421,7 +421,7 @@ export function PromptProjectSelector(props: {
<span class="min-w-0 flex-1 truncate leading-5">{props.controller.labels.add()}</span>
</Menu.SubTrigger>
<Menu.Portal>
<Menu.SubContent class="min-w-[180px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<Menu.SubContent class="max-h-[224px] min-w-[180px] overflow-y-auto rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<For each={props.controller.servers()}>
{(server) => <ServerAction server={server!} onSelect={selectAction} />}
</For>
+7 -2
View File
@@ -21,15 +21,20 @@ export default function NewSessionPage(props: { draftId: string }) {
tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId),
)
const workspace = createNewSessionWorkspaceController({
selected: () => draftTab()?.worktree,
setSelected: (worktree) => {
selectedWorktree: () => draftTab()?.worktree,
selectedBranch: () => draftTab()?.branch,
setSelectedWorktree: (worktree) => {
if (search.draftId) tabs.updateDraft(search.draftId, { worktree })
},
setSelectedBranch: (branch) => {
if (search.draftId) tabs.updateDraft(search.draftId, { branch })
},
onViewAll: openWorkspaces,
})
const composer = createNewSessionComposerAdapter({
draftID: props.draftId,
worktree: workspace.selection.value,
branch: workspace.bar.branch,
submitted: workspace.selection.remember,
})
const model = createComposerModel(composer.adapter)
+3
View File
@@ -69,9 +69,12 @@ export function NewSessionView(props: {
value={props.workspace.selection.value()}
projectRoot={props.workspace.project.root()}
workspaces={props.workspace.project.workspaces()}
branches={props.workspace.project.branches()}
branch={props.workspace.bar.branch()}
onboarding={onboardingReady() && !onboarding.used}
onChange={select}
onCreate={props.workspace.selection.create}
onSearch={props.workspace.project.searchBranches}
onDone={props.composer.restoreFocus}
onViewAll={props.workspace.project.openAll}
/>
@@ -65,6 +65,17 @@ describe("new session workspace selection", () => {
).toBe(undefined)
})
test("uses a selected branch for a new workspace", () => {
expect(
resolveNewSessionBranch({
worktree: "create",
directory: "/project/feature",
createBranch: "release",
worktreeBranch: () => "feature",
}),
).toBe("release")
})
test("uses location VCS state when the project inventory is stale", () => {
expect(resolveNewSessionGit({ branch: "dev" })).toBe(true)
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
@@ -1,4 +1,6 @@
import { createEffect, createMemo } from "solid-js"
import { debounce } from "@solid-primitives/scheduled"
import { createEffect, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { useWorkspaceLocation } from "@/workspaces/location"
import { useServerSDK } from "@/runtime/server/client"
import { useData } from "@/runtime/server/current"
@@ -32,8 +34,10 @@ export function normalizeNewSessionWorktree(value: string, directory: string, pr
export function resolveNewSessionBranch(input: {
worktree: string
directory: string
createBranch?: string
worktreeBranch: (worktree: string) => string | undefined
}) {
if (input.worktree === "create" && input.createBranch) return input.createBranch
const directory = input.worktree === "main" || input.worktree === "create" ? input.directory : input.worktree
return input.worktreeBranch(directory)
}
@@ -43,14 +47,18 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
}
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
selectedWorktree: () => string | undefined
selectedBranch: () => string | undefined
setSelectedWorktree: (worktree: string | undefined) => void
setSelectedBranch: (branch: string | undefined) => void
onViewAll: () => void
}) {
const sdk = useWorkspaceLocation()
const serverSDK = useServerSDK()
const data = useData()
const settings = useSettings()
const [state, setState] = createStore({ search: "" })
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
const current = projectID ? data.project.get(projectID) : undefined
@@ -64,7 +72,7 @@ export function createNewSessionWorkspaceController(input: {
)
const selected = createMemo(() => {
const project = currentProject()
const worktree = input.selected()
const worktree = input.selectedWorktree()
if (!project || !worktree) return
return isWorkspaceSelection(project, worktree) ? worktree : undefined
})
@@ -86,6 +94,14 @@ export function createNewSessionWorkspaceController(input: {
}),
)
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
const [branches] = createResource(
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branches({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
createEffect(() => {
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(
() => undefined,
@@ -98,6 +114,7 @@ export function createNewSessionWorkspaceController(input: {
resolveNewSessionBranch({
worktree: value(),
directory: sdk().directory,
createBranch: input.selectedBranch(),
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
}),
)
@@ -116,10 +133,19 @@ export function createNewSessionWorkspaceController(input: {
const current = value()
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
}),
reset: () => input.setSelected(undefined),
reset: () => {
input.setSelectedWorktree(undefined)
input.setSelectedBranch(undefined)
},
remember,
set: (worktree: string) => {
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
input.setSelectedBranch(undefined)
input.setSelectedWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
},
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
remember("create")
},
},
project: {
@@ -129,6 +155,15 @@ export function createNewSessionWorkspaceController(input: {
return project ? workspaceDirectories(project) : []
},
git: visible,
branches: () => {
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
const loaded = branches.latest
const list = loaded?.directory === projectRoot() ? loaded.data : []
return [
...new Set([...list, ...(current && current.toLowerCase().includes(state.search.toLowerCase()) ? [current] : [])]),
].slice(0, 50)
},
searchBranches,
openAll: input.onViewAll,
},
bar: {
@@ -1,4 +1,5 @@
import { createMemo, createSignal, For, Show } from "solid-js"
import { createMemo, For, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Menu } from "@opencode-ai/ui/menu"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { Icon } from "@opencode-ai/ui/icon"
@@ -10,20 +11,24 @@ export function PromptWorkspaceSelector(props: {
value: string
projectRoot: string
workspaces: string[]
branches: string[]
branch?: string
onboarding?: boolean
onChange: (value: string) => void
onCreate: (branch: string) => void
onSearch: (search: string) => void
onDone: () => void
onViewAll: () => void
}) {
const language = useLanguage()
const [search, setSearch] = createSignal("")
const [search, setSearch] = createStore({ workspaces: "", branches: "" })
let searchInput: HTMLInputElement | undefined
let branchSearchInput: HTMLInputElement | undefined
let focusSearch = false
let pending: { type: "select"; value: string } | { type: "viewAll" } | undefined
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
const workspaces = createMemo(() => {
const query = search().trim().toLowerCase()
const query = search.workspaces.trim().toLowerCase()
if (!query) return props.workspaces
return props.workspaces.filter((workspace) => getFilename(workspace).toLowerCase().includes(query))
})
@@ -37,12 +42,14 @@ export function PromptWorkspaceSelector(props: {
}
const onOpenChange = (open: boolean) => {
if (open) {
setSearch("")
setSearch({ workspaces: "", branches: "" })
props.onSearch("")
return
}
const action = pending
pending = undefined
if (action?.type === "select") props.onChange(action.value)
if (action?.type === "create") props.onCreate(action.branch)
if (action?.type === "viewAll") {
props.onViewAll()
return
@@ -120,21 +127,7 @@ export function PromptWorkspaceSelector(props: {
</Menu.Item>
<Menu.Item onSelect={() => select("create")}>
<Icon name="workspace-new" />
<Tooltip
placement="right"
openDelay={800}
value={
<span class="flex flex-col gap-0.5">
<span>{language.t("workspace.new")}</span>
<span class="font-[440] text-v2-text-text-muted">
{language.t("session.new.workspace.new.tooltip")}
</span>
</span>
}
class="min-w-0 flex-1"
>
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
</Tooltip>
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
<Show when={selected() === "create"}>
<Icon name="check" size="small" class="shrink-0" />
</Show>
@@ -191,11 +184,11 @@ export function PromptWorkspaceSelector(props: {
ref={(element) => {
searchInput = element
}}
value={search()}
value={search.workspaces}
placeholder={language.t("session.new.workspace.search.placeholder")}
aria-label={language.t("session.new.workspace.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => setSearch(event.currentTarget.value)}
onInput={(event) => setSearch("workspaces", event.currentTarget.value)}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
@@ -232,7 +225,94 @@ export function PromptWorkspaceSelector(props: {
</Menu.Portal>
</Menu>
</Tooltip>
<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />
<Show
when={selected() === "create" && props.branch}
fallback={<PromptGitStatus branch={props.branch} from={selected() === "create"} class="ms-1" />}
>
<Tooltip
placement="top"
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
class="ms-1 min-w-0 max-w-[220px]"
contentClass="max-w-[calc(100vw-32px)] break-all"
>
<Menu
placement="bottom"
gutter={4}
onOpenChange={(open) => {
onOpenChange(open)
if (open) requestAnimationFrame(() => branchSearchInput?.focus())
}}
>
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
</Menu.Trigger>
<Menu.Portal>
<Menu.Content class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 shadow-[var(--v2-elevation-floating)] focus:outline-none">
<div class="flex h-7 shrink-0 items-center gap-2 rounded-sm pl-3 pr-2.5 text-v2-icon-icon-muted">
<Icon name="magnifying-glass" size="small" class="shrink-0" />
<input
ref={(element) => {
branchSearchInput = element
}}
value={search.branches}
placeholder={language.t("session.new.workspace.branch.search.placeholder")}
aria-label={language.t("session.new.workspace.branch.search.placeholder")}
class="h-7 min-w-0 flex-1 border-0 bg-transparent text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base outline-none placeholder:text-v2-text-text-faint"
onInput={(event) => {
setSearch("branches", event.currentTarget.value)
props.onSearch(event.currentTarget.value)
}}
onKeyDown={(event) => {
if (
event.key === "Escape" ||
event.key === "ArrowDown" ||
event.key === "ArrowUp" ||
event.key === "Enter"
)
return
event.stopPropagation()
}}
/>
<Show when={search.branches.trim()}>
<button
type="button"
class="flex size-5 items-center justify-center rounded-sm text-v2-icon-icon-muted hover:bg-v2-overlay-simple-overlay-hover"
onPointerDown={(event) => event.preventDefault()}
onClick={() => {
setSearch("branches", "")
props.onSearch("")
}}
aria-label={language.t("common.clear")}
>
<Icon name="close-small" size="small" />
</button>
</Show>
</div>
<div class="max-h-[224px] overflow-y-auto">
<Menu.RadioGroup value={props.branch}>
<For each={props.branches}>
{(branch) => (
<Menu.RadioItem
value={branch}
class="h-7 gap-2 rounded-sm px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:var(--v2-font-family-sans)] data-[highlighted]:!bg-v2-overlay-simple-overlay-hover"
closeOnSelect
onSelect={() => (pending = { type: "create", branch })}
>
<span class="min-w-0 truncate leading-5">{branch}</span>
</Menu.RadioItem>
)}
</For>
</Menu.RadioGroup>
</div>
</Menu.Content>
</Menu.Portal>
</Menu>
</Tooltip>
</Show>
</>
)
}
@@ -0,0 +1,21 @@
import { createMemo, type Accessor } from "solid-js"
import createPresence from "solid-presence"
export function createAnimatedPresence<T>(value: Accessor<T | undefined>, element: Accessor<HTMLElement | null>) {
const animation = createMemo<{ show: boolean; animate: boolean; value: T | undefined }>((previous) => {
const current = value()
const show = current !== undefined
return {
show,
animate: previous !== undefined && (previous.animate || previous.show !== show),
value: current ?? previous?.value,
}
})
const presence = createPresence({ show: () => animation().show, element })
return {
...presence,
show: () => animation().show,
animate: () => animation().animate,
value: () => animation().value,
}
}
+5 -2
View File
@@ -460,7 +460,8 @@ export const dict = {
"dialog.project.edit.color": "Color",
"dialog.project.edit.color.select": "Select {{color}} color",
"dialog.project.edit.worktree.startup": "Workspace startup script",
"dialog.project.edit.worktree.startup.description": "Runs after creating a new workspace (worktree).",
"dialog.project.edit.worktree.startup.description":
"Runs after creating a new workspace (worktree). Use $OPENCODE_WORKTREE_BASE for the base worktree and $OPENCODE_WORKTREE_PATH for the new worktree.",
"dialog.project.edit.worktree.startup.placeholder": "e.g. bun install",
"dialog.releaseNotes.action.getStarted": "Get started",
@@ -499,7 +500,7 @@ export const dict = {
"context.stats.lastActivity": "Last Activity",
"context.usage.tokens": "Tokens",
"context.usage.usage": "Usage",
"context.usage.usage": "Context Usage",
"context.usage.cost": "Cost",
"context.usage.clickToView": "Click to view context",
"context.usage.view": "View context usage",
@@ -1151,6 +1152,8 @@ export const dict = {
"session.new.workspace.local.tooltip": "Use current checkout",
"session.new.workspace.new.tooltip": "Create isolated checkout",
"session.new.workspace.fromBranch": "from {{branch}}",
"session.new.workspace.createFrom": "Create from branch",
"session.new.workspace.branch.search.placeholder": "Search branches",
"session.new.workspace.trigger.tooltip": "Select where to run session",
"session.new.workspace.search.placeholder": "Search workspaces",
"settings.tab.workspaces": "Workspaces",
@@ -0,0 +1,33 @@
import { expect, test } from "bun:test"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { createSessionMutations } from "./data"
const session = { id: "ses_test" } as SessionInfo
test("keeps a successful removal applied until its event arrives", async () => {
const release = Promise.withResolvers<void>()
const mutation = createSessionMutations(async () => release.promise)
const request = mutation.remove(session.id)
expect(mutation.apply([session])).toEqual([])
release.resolve()
await request
expect(mutation.apply([session])).toEqual([])
mutation.deleted(session.id)
expect(mutation.apply([session])).toEqual([session])
})
test("rolls back a failed removal", async () => {
const release = Promise.withResolvers<void>()
const mutation = createSessionMutations(async () => {
await release.promise
throw new Error("offline")
})
const request = mutation.remove(session.id)
expect(mutation.apply([session])).toEqual([])
release.resolve()
await expect(request).rejects.toThrow("offline")
expect(mutation.apply([session])).toEqual([session])
})
+51
View File
@@ -0,0 +1,51 @@
import type { Data } from "@opencode-ai/client/solid"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string }
export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise<void> }) {
const mutation = createSessionMutations(input.remove)
onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID)))
return {
...input.data,
session: {
...input.data.session,
list: () => mutation.apply(input.data.session.list()),
apply: mutation.apply,
remove: mutation.remove,
},
}
}
export function createSessionMutations(remove: (sessionID: string) => Promise<void>) {
const [store, setStore] = createStore({ session: [] as SessionMutation[] })
const clear = (id: string) => {
setStore("session", (current) => current.filter((mutation) => mutation.id !== id))
}
return {
apply(sessions: readonly SessionInfo[]) {
const removed = new Set(
store.session.flatMap((mutation) => (mutation.type === "remove" ? [mutation.sessionID] : [])),
)
return removed.size === 0 ? [...sessions] : sessions.filter((session) => !removed.has(session.id))
},
remove(sessionID: string) {
const mutation = { id: crypto.randomUUID(), type: "remove" as const, sessionID }
setStore("session", (current) => [...current, mutation])
return Promise.resolve()
.then(() => remove(sessionID))
.catch((error) => {
clear(mutation.id)
throw error
})
},
deleted(sessionID: string) {
setStore("session", (current) => current.filter((mutation) => mutation.sessionID !== sessionID))
},
}
}
+6 -1
View File
@@ -11,6 +11,7 @@ import type { ServerScope } from "@/runtime/server/scope"
import { createPermissionAutoApprover } from "@/session/requests/auto-approve"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { createDesktopData } from "./data"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -134,7 +135,7 @@ function createServerController(
) {
const connKey = ServerConnection.key(conn)
const sdk = createServerSdkContext(conn, scope)
const data = createData({
const source = createData({
api: () => sdk.api,
event: {
on: sdk.event.on,
@@ -143,6 +144,10 @@ function createServerController(
connection: sdk.connection,
directory: "",
})
const data = createDesktopData({
data: source,
remove: (sessionID) => sdk.api.session.remove({ sessionID }),
})
const sync = createServerSyncContext(sdk, data)
createPermissionAutoApprover({ sdk, data })
const notification = createServerNotificationState({ sdk, data, key: connKey })
+2 -2
View File
@@ -66,11 +66,11 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
provider_auth: {},
get path() {
const EMPTY = { state: "", config: "", worktree: "", directory: "", home: "" }
if (pathQuery.isLoading) return EMPTY
if (pathQuery.isPending) return EMPTY
return pathQuery.data ?? EMPTY
},
get config() {
if (configQuery.isLoading) return {}
if (configQuery.isPending) return {}
return configQuery.data ?? {}
},
get reload() {
@@ -168,15 +168,15 @@ export function createTimelineController(input: { session: TimelineSessionSource
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
const index = sessions.findIndex((item) => item.id === id)
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const success = await serverSDK.api.session
.remove({ sessionID: id })
const removed = removedSessionIDs(data.session.list(), id)
const success = await data.session
.remove(id)
.then(() => true)
.catch((error) => {
showToast({ title: language.t("session.delete.failed.title"), description: errorMessage(error) })
return false
})
if (!success) return false
const removed = removedSessionIDs(data.session.list(), id)
void navigateAfterRemoval(id, session.parentID, next?.id)
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
@@ -1,6 +1,6 @@
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js"
import createPresence from "solid-presence"
import { createStore } from "solid-js/store"
import { createAnimatedPresence } from "@/runtime/animated-presence"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
import { Badge } from "@opencode-ai/ui/badge"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
@@ -479,17 +479,7 @@ function MessageTimelineView(
return row.group.ref.partID
})
const [backgroundHintRef, setBackgroundHintRef] = createSignal<HTMLDivElement>()
const backgroundHintVisibility = createMemo<{ show: boolean; animate: boolean }>(
(previous) => {
const show = backgroundHintPartID() !== undefined
return { show, animate: previous.animate || previous.show !== show }
},
{ show: backgroundHintPartID() !== undefined, animate: false },
)
const backgroundHintPresence = createPresence({
show: () => backgroundHintVisibility().show,
element: () => backgroundHintRef() ?? null,
})
const backgroundHintPresence = createAnimatedPresence(backgroundHintPartID, () => backgroundHintRef() ?? null)
return (
<VirtualizedTimeline
workspaceSession={workspaceSession}
@@ -507,9 +497,9 @@ function MessageTimelineView(
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
"animate-in fade-in": backgroundHintPresence.animate() && backgroundHintPresence.show(),
"animate-out fade-out fill-mode-forwards":
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
backgroundHintPresence.animate() && !backgroundHintPresence.show(),
}}
>
<BackgroundMoveHint />
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, test } from "bun:test"
import { monoDefault, monoFontFamily, sansDefault, sansFontFamily, terminalFontFamily } from "./model"
describe("settings font families", () => {
test("defaults normal text to Inter", () => {
expect(sansDefault).toBe("Inter")
expect(sansFontFamily(undefined)).toStartWith('"Inter", ')
expect(sansFontFamily("")).toStartWith('"Inter", ')
expect(sansFontFamily(" ")).toStartWith('"Inter", ')
})
test("keeps custom normal fonts ahead of the default", () => {
expect(sansFontFamily("Custom Sans")).toStartWith('"Custom Sans", "Inter", ')
})
test("defaults monospace text to IBM Plex Mono", () => {
expect(monoDefault).toBe("IBM Plex Mono")
expect(monoFontFamily(undefined)).toStartWith('"IBM Plex Mono", ')
expect(monoFontFamily("")).toStartWith('"IBM Plex Mono", ')
expect(monoFontFamily(" ")).toStartWith('"IBM Plex Mono", ')
})
test("keeps custom monospace fonts ahead of the default", () => {
expect(monoFontFamily("Custom Mono")).toStartWith('"Custom Mono", "IBM Plex Mono", ')
})
test("preserves the separate terminal font default", () => {
expect(terminalFontFamily(undefined)).toStartWith('"JetBrainsMono Nerd Font Mono", ')
})
})
+4 -4
View File
@@ -60,12 +60,12 @@ export interface Settings {
sounds: SoundSettings
}
export const monoDefault = "System Mono"
export const sansDefault = "System Sans"
export const monoDefault = "IBM Plex Mono"
export const sansDefault = "Inter"
export const terminalDefault = "JetBrainsMono Nerd Font Mono"
const monoFallback =
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = 'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
'"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
const sansFallback = '"Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif'
const terminalFallback =
'"JetBrainsMono Nerd Font Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace'
@@ -39,7 +39,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Button type="button" variant="neutral" disabled={model.save.isPending} onClick={model.close}>
{language.t("common.cancel")}
</Button>
<Button type="submit" variant="contrast" disabled={!model.supported || model.save.isPending}>
<Button type="submit" variant="contrast" disabled={model.save.isPending}>
{model.save.isPending ? language.t("common.saving") : language.t("common.save")}
</Button>
</DialogFooter>
@@ -1,7 +1,6 @@
import { getFilename } from "@opencode-ai/util/path"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useMutation } from "@tanstack/solid-query"
import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/runtime/server/runtime"
@@ -9,7 +8,6 @@ import { type LocalProject } from "@/shell/state/layout"
import { ServerConnection } from "@/runtime/server/registry"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
const dialog = useDialog()
const global = useGlobal()
const serverCtx = createMemo(() => global.ensureServerCtx(props.server))
@@ -72,9 +70,14 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
const start = store.startup.trim()
if (props.project.id && props.project.id !== "global") {
// TODO: Restore project edits when the V2 client exposes a project update API.
// await serverCtx().sdk.api.project.update({ projectID: props.project.id, name, icon, commands })
throw new Error(`Project ${props.project.id} cannot be updated`)
await serverCtx().sdk.api.project.update({
projectID: props.project.id,
name,
icon: { color: store.color ?? "", override: store.iconOverride ?? "" },
commands: { start },
})
dialog.close()
return
}
serverCtx().sync.project.meta(props.project.worktree, {
@@ -88,7 +91,7 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
function submit(event: SubmitEvent) {
event.preventDefault()
if (!supported || save.isPending) return
if (save.isPending) return
save.mutate()
}
@@ -98,7 +101,6 @@ export function createEditProjectModel(props: { project: LocalProject; server: S
folderName,
defaultName,
save,
supported,
submit,
drop,
dragOver,
@@ -0,0 +1,26 @@
import { expect, test } from "bun:test"
import type { ServerConnection } from "@/runtime/server/registry"
import type { Tab } from "@/shell/tabs/tabs"
import { openNotificationSession } from "./notification"
test("opens notification sessions through the tab router", () => {
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
const tab = { type: "session" as const, server, sessionId: "session-1" }
const calls: string[] = []
const tabs = {
addSessionTab: (input: Omit<typeof tab, "type">) => {
calls.push(`add:${input.sessionId}`)
return tab
},
rememberSessionRoute: (_tab: typeof tab, sessionID: string) => {
calls.push(`route:${sessionID}`)
},
select: (input: Tab) => {
calls.push(`select:${input.type === "session" ? input.sessionId : input.draftID}`)
},
}
openNotificationSession(tabs, server, "session-1")
expect(calls).toEqual(["add:session-1", "route:session-1", "select:session-1"])
})
@@ -51,6 +51,19 @@ type NotificationIndex = {
}
}
type NotificationTabs = Pick<ReturnType<typeof useTabs>, "addSessionTab" | "rememberSessionRoute" | "select">
export function openNotificationSession(
tabs: NotificationTabs,
server: ServerConnection.Key,
sessionID: string,
) {
const tab = tabs.addSessionTab({ server, sessionId: sessionID })
if (tab.type !== "session") return
tabs.rememberSessionRoute(tab, sessionID)
tabs.select(tab)
}
const MAX_NOTIFICATIONS = 500
const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30
@@ -211,11 +224,6 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
return typeof location !== "undefined" && location.pathname === sessionHref(input.key, sessionID)
}
const navigate = (href: string) => {
history.pushState(null, "", href)
dispatchEvent(new PopStateEvent("popstate"))
}
const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
void lookup(sessionID).then((session) => {
if (meta.disposed) return
@@ -237,10 +245,9 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
session: sessionID,
})
const href = sessionHref(input.key, sessionID)
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
navigate(href),
openNotificationSession(tabs, input.key, sessionID),
)
}
})
@@ -274,9 +281,10 @@ export function createServerNotificationState(input: { sdk: ServerSDK; data: Dat
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionHref(input.key, sessionID)
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
void platform.notify(language.t("notification.session.error.title"), description, () =>
openNotificationSession(tabs, input.key, sessionID),
)
}
})
}
+12 -2
View File
@@ -31,9 +31,19 @@ export function migrateTabs(value: unknown): Tab[] {
tab.type === "draft" &&
typeof tab.draftID === "string" &&
typeof tab.directory === "string" &&
(tab.worktree === undefined || typeof tab.worktree === "string")
(tab.worktree === undefined || typeof tab.worktree === "string") &&
(tab.branch === undefined || typeof tab.branch === "string")
) {
return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }]
return [
{
type: tab.type,
server,
draftID: tab.draftID,
directory: tab.directory,
worktree: tab.worktree,
branch: tab.branch,
},
]
}
return []
})
+1
View File
@@ -29,6 +29,7 @@ export type DraftTab = {
server: ServerConnection.Key
directory: string
worktree?: string
branch?: string
}
export type Tab = SessionTab | DraftTab
@@ -143,6 +143,10 @@
display: none;
}
[data-titlebar-tab]:not([data-active="true"]) [data-slot="tab-close"] {
display: none;
}
[data-slot="tab-close"] {
right: auto;
left: 50%;
@@ -0,0 +1,49 @@
import { expect, test } from "bun:test"
import { createAnimatedPresence } from "../src/runtime/animated-presence"
import { createRoot, createSignal } from "solid-js"
test("animates visibility changes without animating initial presence", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>("steer")
const presence = createAnimatedPresence(value, () => null)
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(false)
expect(presence.value()).toBe("steer")
expect(presence.present()).toBe(true)
setValue("queue")
expect(presence.animate()).toBe(false)
expect(presence.value()).toBe("queue")
setValue(undefined)
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("queue")
setValue("steer")
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("steer")
dispose()
})
})
test("animates the first appearance when initially hidden", () => {
createRoot((dispose) => {
const [value, setValue] = createSignal<string | undefined>()
const presence = createAnimatedPresence(value, () => null)
expect(presence.show()).toBe(false)
expect(presence.animate()).toBe(false)
expect(presence.present()).toBe(false)
setValue("steer")
expect(presence.show()).toBe(true)
expect(presence.animate()).toBe(true)
expect(presence.value()).toBe("steer")
dispose()
})
})
+19 -1
View File
@@ -9,6 +9,7 @@ import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -78,6 +79,23 @@ const appAssetsPlugin: BunPlugin = {
}
for (const item of targets) {
const opencodePty = await resolveOpencodePty({
platform: item.os,
arch: item.arch,
...(item.os === "linux" ? { libc: item.abi ?? "glibc" } : {}),
})
const opencodePtyPlugin: BunPlugin = {
name: "opencode-pty-binary",
setup(build) {
build.onLoad({ filter: /persistent-pty[/\\]pty-binding\.ts$/ }, () => ({
loader: "js",
contents: opencodePty
? `import file from ${JSON.stringify(opencodePty.source)} with { type: "file" }
export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sha256: ${JSON.stringify(opencodePty.sha256)} }`
: "export default undefined",
}))
},
}
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
@@ -105,7 +123,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, opencodePtyPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
+7
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { collectFiles } from "./files"
import { resolveOpencodePty } from "./opencode-pty"
const dir = path.resolve(import.meta.dirname, "..")
@@ -18,6 +19,11 @@ export type NodeAsset = {
}
export async function collectNodeAssets(target: NodeTarget) {
const opencodePty = await resolveOpencodePty({
platform: target.platform,
arch: target.arch,
...(target.platform === "linux" ? { libc: "glibc" as const } : {}),
})
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
const assets: NodeAsset[] = [
@@ -41,6 +47,7 @@ export async function collectNodeAssets(target: NodeTarget) {
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(opencodePty && target.opencodePtyAsset ? [{ key: target.opencodePtyAsset, source: opencodePty.source }] : []),
...(await collectFiles(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
+102
View File
@@ -0,0 +1,102 @@
import { spawnSync } from "node:child_process"
import { createHash } from "node:crypto"
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from "node:fs/promises"
import os from "node:os"
import path from "node:path"
const VERSION = "0.1.5"
const RELEASE = `https://github.com/anomalyco/opencode-pty/releases/download/v${VERSION}`
const SHA256 = {
"aarch64-apple-darwin": "d5156e44a6783381aadbd968dbd27c1d83e7e0f1b6042c7c934e6d33541d334f",
"aarch64-unknown-linux-gnu": "075d99ffb269cbd0846d3d404fdee93965a53cd6eaf046dbd1064785a7ce9351",
"aarch64-unknown-linux-musl": "22fb55c944ff05fbe03e84de67333e9fd037ad4e04ffc93d8a3f0b2193c29421",
"x86_64-apple-darwin": "773e363b5385c1bd56021e69ada95132efd615ed5b9c3734f878ad644ae22b01",
"x86_64-unknown-linux-gnu": "d9cac2a7c09d013188f696c45ded5eb5764d308e52dd31cb2de68bf4fc675624",
"x86_64-unknown-linux-musl": "2a176302de3d24f8ae3fbacf0b4afce7b4af3e00abd619906187a487b5e50bd6",
} as const
export type OpencodePtyAsset = {
readonly source: string
readonly version: string
readonly sha256: string
}
type Target = {
readonly platform: string
readonly arch: string
readonly libc?: "glibc" | "musl"
}
const pending = new Map<string, Promise<OpencodePtyAsset | undefined>>()
export function resolveOpencodePty(target: Target) {
const rustTarget = targetName(target)
if (!rustTarget) return Promise.resolve(undefined)
const existing = pending.get(rustTarget)
if (existing) return existing
const result = acquire(rustTarget).catch((error) => {
pending.delete(rustTarget)
throw error
})
pending.set(rustTarget, result)
return result
}
async function acquire(target: keyof typeof SHA256): Promise<OpencodePtyAsset> {
const root = path.resolve(import.meta.dirname, "../.cache/opencode-pty", VERSION, target)
const executable = path.join(root, "opencode-pty")
const cached = await readFile(executable).catch(() => undefined)
if (cached)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(cached).digest("hex"),
}
await mkdir(root, { recursive: true })
const archiveName = `opencode-pty-${VERSION}-${target}.tar.gz`
const response = await fetch(`${RELEASE}/${archiveName}`)
if (!response.ok) throw new Error(`Failed to download ${archiveName}: ${response.status}`)
const archive = new Uint8Array(await response.arrayBuffer())
const actual = createHash("sha256").update(archive).digest("hex")
if (actual !== SHA256[target]) throw new Error(`Checksum mismatch for ${archiveName}`)
const temporary = await mkdtemp(path.join(os.tmpdir(), "opencode-pty-build-"))
try {
const archivePath = path.join(temporary, archiveName)
await writeFile(archivePath, archive)
run("tar", ["-xzf", archivePath, "-C", temporary])
const source = path.join(temporary, `opencode-pty-${VERSION}-${target}`, "opencode-pty")
const bytes = await readFile(source)
const staged = path.join(root, `opencode-pty.${process.pid}.${crypto.randomUUID()}.tmp`)
await writeFile(staged, bytes, { flag: "wx", mode: 0o755 })
await rename(staged, executable).catch(async (error) => {
await rm(staged, { force: true })
if (!(await readFile(executable).catch(() => undefined))) throw error
})
const installed = await readFile(executable)
return {
source: executable,
version: VERSION,
sha256: createHash("sha256").update(installed).digest("hex"),
}
} finally {
await rm(temporary, { recursive: true, force: true })
}
}
function targetName(target: Target): keyof typeof SHA256 | undefined {
const arch = target.arch === "arm64" ? "aarch64" : target.arch === "x64" ? "x86_64" : undefined
if (!arch) return undefined
if (target.platform === "darwin") return arch === "aarch64" ? "aarch64-apple-darwin" : "x86_64-apple-darwin"
if (target.platform === "linux" && target.libc === "musl")
return arch === "aarch64" ? "aarch64-unknown-linux-musl" : "x86_64-unknown-linux-musl"
if (target.platform === "linux") return arch === "aarch64" ? "aarch64-unknown-linux-gnu" : "x86_64-unknown-linux-gnu"
return undefined
}
function run(command: string, args: readonly string[]) {
const result = spawnSync(command, args, { stdio: "inherit" })
if (result.error) throw result.error
if (result.status !== 0) throw new Error(`${command} exited with status ${result.status ?? "unknown"}`)
}
+27
View File
@@ -0,0 +1,27 @@
import { Schema } from "effect"
import { format } from "prettier"
import { Info, SchemaURL } from "../src/config/schema"
const target = process.argv[2]
if (!target) throw new Error("A schema output path is required")
const document = Schema.toJsonSchemaDocument(Info)
const content = await format(
JSON.stringify({
$schema: "https://json-schema.org/draft/2020-12/schema",
$id: SchemaURL,
...document.schema,
...(Object.keys(document.definitions).length ? { $defs: document.definitions } : {}),
}),
{ parser: "json", printWidth: 120 },
)
if (process.argv.includes("--check")) {
if ((await Bun.file(target).text()) !== content) {
console.error("Generated CLI config schema is stale. Run `bun run generate` from packages/www.")
process.exit(1)
}
process.exit(0)
}
await Bun.write(target, content)
+1
View File
@@ -79,6 +79,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
commands: [
Spec.make("agents", { description: "List all agents" }),
Spec.make("config", { description: "List configuration sources" }),
Spec.make("paths", { description: "Show global paths (data, config, cache, state)" }),
],
}),
Spec.make("console", {
@@ -0,0 +1,17 @@
import { EOL } from "os"
import { Effect } from "effect"
import { Global } from "@opencode-ai/util/global"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
export default Runtime.handler(
Commands.commands.debug.commands.paths,
Effect.fn("cli.debug.paths")(function* () {
const global = yield* Global.Service
process.stdout.write(
Object.entries(global)
.map(([key, value]) => `${key.padEnd(10)} ${value}${EOL}`)
.join(""),
)
}),
)
@@ -3,10 +3,13 @@ import { Service } from "@opencode-ai/client/effect/service"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { ServiceConfig } from "../../../services/service-config"
import { ServerConnection } from "../../../services/server-connection"
export default Runtime.handler(
Commands.commands.service.commands.stop,
Effect.fn("cli.service.stop")(function* () {
yield* Service.stop(yield* ServiceConfig.options())
const options = yield* ServiceConfig.options()
yield* ServerConnection.shutdownPersistentPty(options).pipe(Effect.ignore)
yield* Service.stop(options)
}),
)
+4 -2
View File
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
import path from "path"
import { ConfigMigration } from "./migrate"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
export * from "./schema"
@@ -87,7 +87,9 @@ export const layer = Layer.effect(
const next = produce(current, update)
const edits = changes(current, next)
if (!edits.length) return current
const text = yield* fs.readFileString(file).pipe(Effect.orElseSucceed(() => "{}"))
const text = yield* fs
.readFileString(file)
.pipe(Effect.orElseSucceed(() => JSON.stringify({ $schema: SchemaURL }, null, 2)))
const updated = edits.reduce(
(text, edit) =>
applyEdits(
+4 -3
View File
@@ -7,7 +7,7 @@ import { Effect, FileSystem, Option, Schema } from "effect"
import { randomUUID } from "crypto"
import { applyEdits, createScanner, modify, parse, parseTree, type Node, type ParseError } from "jsonc-parser"
import path from "path"
import { Info } from "./schema"
import { Info, SchemaURL } from "./schema"
const decodeV1 = Schema.decodeUnknownOption(TuiConfigV1.Info)
const decodeInfo = Schema.decodeUnknownOption(Info)
@@ -100,8 +100,9 @@ export const run = Effect.fn("cli.config.migrate")(function* (input: {
const legacyValue = yield* readJson(path.join(input.config, "tui.json"))
const legacy = Option.getOrUndefined(decodeV1(legacyValue))
const kv = yield* readJson(path.join(input.state, "kv.json"))
const migrated = migrateV1(legacy, kv ?? {})
if (!Object.keys(migrated).length) return
const values = migrateV1(legacy, kv ?? {})
if (!Object.keys(values).length) return
const migrated = { $schema: SchemaURL, ...values }
const result = yield* persist(JSON.stringify(migrated, null, 2) + "\n", migrated)
if (result.cause === undefined)
+6 -1
View File
@@ -1,5 +1,10 @@
import { Config } from "@opencode-ai/tui/config"
import { Schema } from "effect"
export const Info = Schema.Struct({ ...Config.Info.fields })
export const SchemaURL = "https://opencode.ai/v2/cli.json"
export const Info = Schema.Struct({
$schema: Schema.optional(Schema.String).annotate({ description: "JSON Schema for CLI configuration" }),
...Config.Info.fields,
})
export type Info = Schema.Schema.Type<typeof Info>
+1
View File
@@ -27,6 +27,7 @@ const Handlers = Runtime.handlers(Commands, {
debug: {
agents: () => import("./commands/handlers/debug/agents"),
config: () => import("./commands/handlers/debug/config"),
paths: () => import("./commands/handlers/debug/paths"),
},
console: {
login: () => import("./commands/handlers/console/login"),
+2
View File
@@ -13,6 +13,7 @@ export function nodeTarget(platform: string, arch: string) {
const parcelWatcherPackage = `@parcel/watcher-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-glibc" : ""}`
const fffPackage = `@ff-labs/fff-bin-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : ""}`
const fffFfiPackage = `@yuuang/ffi-rs-${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}`
const opencodePtyAsset = targetPlatform === "win32" ? undefined : "opencode-pty/opencode-pty"
return {
platform: targetPlatform,
@@ -25,6 +26,7 @@ export function nodeTarget(platform: string, arch: string) {
fffAsset: `${fffPackage}/${targetPlatform === "darwin" ? "libfff_c.dylib" : targetPlatform === "win32" ? "fff_c.dll" : "libfff_c.so"}`,
fffFfiPackage,
fffFfiAsset: `${fffFfiPackage}/ffi-rs.${targetPlatform}-${targetArch}${targetPlatform === "linux" ? "-gnu" : targetPlatform === "win32" ? "-msvc" : ""}.node`,
opencodePtyAsset,
}
}
@@ -62,6 +62,15 @@ function managedService(options: EnsureOptions) {
}
}
export const shutdownPersistentPty = Effect.fn("cli.server-connection.shutdown-persistent-pty")(function* (
options: EnsureOptions,
) {
const endpoint = yield* Service.discover({ ...options, version: undefined })
if (!endpoint) return
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
yield* Effect.tryPromise(() => client.experimental.persistentPty.shutdown())
})
const resolveManaged = Effect.fnUntraced(function* (options: EnsureOptions, mismatch: NonNullable<Args["mismatch"]>) {
if (mismatch === "replace") return yield* Service.ensure(options)
if (mismatch === "ignore") return yield* Service.ensure({ ...options, version: undefined })
+70 -2
View File
@@ -1,7 +1,7 @@
import { NodeFileSystem } from "@effect/platform-node"
import { Flock } from "@opencode-ai/util/flock"
import { Global } from "@opencode-ai/util/global"
import { Effect, FileSystem, Option } from "effect"
import { Effect, FileSystem, Option, Schema } from "effect"
import { expect, test } from "bun:test"
import { parse } from "jsonc-parser"
import path from "path"
@@ -17,6 +17,67 @@ function run<A, E>(directory: string, effect: Effect.Effect<A, E, Config.Service
)
}
test("generates reusable keybind schemas and preserves descriptions and numeric constraints", () => {
const document = Schema.toJsonSchemaDocument(Config.Info)
expect(document).toHaveProperty(["definitions", "TuiKeybind.BindingValue"])
expect(document).toHaveProperty(["schema", "properties", "keybinds", "anyOf", 0, "properties", "app.exit"], {
anyOf: [{ $ref: "#/$defs/TuiKeybind.BindingValue" }, { type: "null" }],
description: "Exit the application",
})
expect(document).toHaveProperty(["schema", "properties", "scroll", "anyOf", 0, "properties", "speed", "anyOf", 0], {
type: "number",
minimum: 0.001,
})
expect(document).toHaveProperty(
["schema", "properties", "attention", "anyOf", 0, "properties", "volume", "anyOf", 0],
{ type: "number", minimum: 0, maximum: 1 },
)
})
test("includes the published schema when creating cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("preserves the schema in an existing cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
const file = path.join(directory, "cli.json")
await Bun.write(file, JSON.stringify({ $schema: "https://opencode.ai/v2/cli.json", animations: true }))
try {
const config = await run(
directory,
Effect.gen(function* () {
const service = yield* Config.Service
return yield* service.update((draft) => {
draft.animations = false
})
}),
)
expect(config).toEqual({ $schema: "https://opencode.ai/v2/cli.json", animations: false })
expect(await Bun.file(file).json()).toEqual(config)
} finally {
await Bun.$`rm -rf ${directory}`
}
})
test("migrates tui and kv config into cli.json", async () => {
const directory = await Bun.$`mktemp -d`.text().then((value) => value.trim())
await Bun.write(
@@ -73,6 +134,7 @@ test("migrates tui and kv config into cli.json", async () => {
)
expect(config).toMatchObject({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy", mode: "light" },
keybinds: {
leader: "ctrl+o",
@@ -128,8 +190,14 @@ test("migrates before the first update and does not remigrate afterward", async
}),
)
expect(config).toEqual({ theme: { name: "legacy" }, animations: false, mouse: false })
expect(config).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
})
expect(await Bun.file(path.join(directory, "cli.json")).json()).toEqual({
$schema: "https://opencode.ai/v2/cli.json",
theme: { name: "legacy" },
animations: false,
mouse: false,
+66
View File
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
describe("debug paths command", () => {
test("is included in troubleshooting help", async () => {
const [debug, paths] = await Promise.all([cli(["debug", "--help"]), cli(["debug", "paths", "--help"])])
expect(debug.exitCode).toBe(0)
expect(debug.stdout).toContain("paths")
expect(debug.stdout).toContain("Show global paths (data, config, cache, state)")
expect(paths.exitCode).toBe(0)
expect(paths.stdout).toContain("opencode debug paths [flags]")
})
test("prints resolved global paths without starting a server", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-debug-paths-"))
try {
const result = await cli(["debug", "paths"], {
XDG_DATA_HOME: path.join(root, "data"),
XDG_CONFIG_HOME: path.join(root, "config"),
XDG_CACHE_HOME: path.join(root, "cache"),
XDG_STATE_HOME: path.join(root, "state"),
})
const paths = Object.fromEntries(
result.stdout
.trim()
.split("\n")
.map((line) => line.trim().split(/\s+/, 2)),
)
expect({ exitCode: result.exitCode, stderr: result.stderr }).toEqual({ exitCode: 0, stderr: "" })
expect(paths).toMatchObject({
home: os.homedir(),
data: path.join(root, "data", "opencode"),
config: path.join(root, "config", "opencode"),
cache: path.join(root, "cache", "opencode"),
state: path.join(root, "state", "opencode"),
bin: path.join(root, "cache", "opencode", "bin"),
log: path.join(root, "data", "opencode", "log"),
repos: path.join(root, "data", "opencode", "repos"),
})
expect(paths.tmp).toBeTruthy()
expect(await Bun.file(path.join(root, "state", "opencode", "service-local.json")).exists()).toBe(false)
} finally {
await fs.rm(root, { recursive: true, force: true })
}
})
})
async function cli(args: string[], env?: Record<string, string>) {
const child = Bun.spawn([process.execPath, "run", path.join(import.meta.dir, "../src/index.ts"), ...args], {
cwd: path.join(import.meta.dir, ".."),
env: { ...process.env, ...env },
stdout: "pipe",
stderr: "pipe",
})
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
])
return { stdout, stderr, exitCode }
}
+1
View File
@@ -8,6 +8,7 @@ test("collects each SEA asset key once", async () => {
const keys = assets.map((asset) => asset.key)
expect(new Set(keys).size).toBe(keys.length)
if (process.platform !== "win32") expect(keys.filter((key) => key === "opencode-pty/opencode-pty")).toHaveLength(1)
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
{
key: shellParserWasmAssets.runtime,
+6 -2
View File
@@ -120,6 +120,7 @@ function nodePrelude(input: NodeBuildInput) {
input.target.platform === "darwin"
? `${input.target.nodePtyPackage}/prebuilds/darwin-${input.target.arch}/spawn-helper`
: undefined
const opencodePtyAsset = input.target.opencodePtyAsset
const promiseModule = `const sdk = globalThis[Symbol.for("opencode.plugin.v2.promise")]
if (!sdk) throw new Error("OpenCode Promise plugin SDK is unavailable")
export const Agent = sdk.Agent
@@ -200,13 +201,17 @@ if (__ocIsSea()) {
const __ocAssetRoot = __ocIsSea()
? __ocPath.join(__ocCacheRoot, ${JSON.stringify(`${input.assetHash}-${input.target.platform}-${input.target.arch}`)})
: __ocFileURLToPath(new URL("./assets/", import.meta.url))
const __ocPersistentPty = ${JSON.stringify(opencodePtyAsset)}
if (__ocIsSea()) {
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
for (const __ocKey of __ocAssetKeys()) {
const __ocTarget = __ocPath.join(__ocAssetRoot, __ocKey)
if (__ocExists(__ocTarget)) continue
__ocMkdir(__ocPath.dirname(__ocTarget), { recursive: true })
const __ocTemporary = \`${"${__ocTarget}"}.${"${process.pid}"}.${"${crypto.randomUUID()}"}.tmp\`
__ocWrite(__ocTemporary, new Uint8Array(__ocRawAsset(__ocKey)))
if ((__ocKey === __ocPtySpawnHelper || __ocKey === __ocPersistentPty) && process.platform !== "win32")
__ocChmod(__ocTemporary, 0o755)
try {
__ocRename(__ocTemporary, __ocTarget)
} catch (__ocError) {
@@ -214,8 +219,6 @@ if (__ocIsSea()) {
if (!__ocExists(__ocTarget)) throw __ocError
}
}
const __ocPtySpawnHelper = ${JSON.stringify(nodePtySpawnHelper)}
if (__ocPtySpawnHelper) __ocChmod(__ocPath.join(__ocAssetRoot, __ocPtySpawnHelper), 0o755)
}
process.env.OPENCODE_NODE_ASSETS_DIR = __ocAssetRoot
process.env.OTUI_ASSET_ROOT = __ocAssetRoot
@@ -227,6 +230,7 @@ process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, $
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
if (__ocPersistentPty && !process.env.OPENCODE_PTY_BIN) process.env.OPENCODE_PTY_BIN = __ocPath.join(__ocAssetRoot, __ocPersistentPty)
try {
globalThis.__OPENCODE_FFF_FFI = require(process.env.OPENCODE_FFF_FFI_PATH)
} catch {}
+177
View File
@@ -1333,6 +1333,15 @@ export type CredentialUpdateOperation<E = never> = (
input: CredentialUpdateInput,
) => Effect.Effect<CredentialUpdateOutput, E>
export type CredentialActivateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
export type CredentialActivateOutput = void
export type CredentialActivateOperation<E = never> = (
input: CredentialActivateInput,
) => Effect.Effect<CredentialActivateOutput, E>
export type CredentialRemoveInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
@@ -1344,12 +1353,22 @@ export type CredentialRemoveOperation<E = never> = (
export interface CredentialApi<E = never> {
readonly update: CredentialUpdateOperation<E>
readonly activate: CredentialActivateOperation<E>
readonly remove: CredentialRemoveOperation<E>
}
export type ProjectListOutput = ReadonlyArray<Project.Info>
export type ProjectListOperation<E = never> = () => Effect.Effect<ProjectListOutput, E>
export type ProjectUpdateInput = {
readonly projectID: Project.ID
readonly name?: string | undefined
readonly icon?: Project.Icon | undefined
readonly commands?: Project.Commands | undefined
}
export type ProjectUpdateOutput = Project.Info
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
export type ProjectCurrentInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1358,6 +1377,7 @@ export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) =
export interface ProjectApi<E = never> {
readonly list: ProjectListOperation<E>
readonly update: ProjectUpdateOperation<E>
readonly current: ProjectCurrentOperation<E>
}
@@ -1582,6 +1602,152 @@ export interface PtyApi<E = never> {
readonly connect: { readonly token: PtyConnectTokenOperation<E> }
}
export type ExperimentalPersistentPtyListInput = { readonly sessionID: Session.ID }
export type ExperimentalPersistentPtyListOutput = ReadonlyArray<{
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}>
export type ExperimentalPersistentPtyListOperation<E = never> = (
input: ExperimentalPersistentPtyListInput,
) => Effect.Effect<ExperimentalPersistentPtyListOutput, E>
export type ExperimentalPersistentPtyCreateInput = {
readonly sessionID: Session.ID
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly title: string
readonly env: { readonly [x: string]: string }
readonly size?: { readonly cols: number; readonly rows: number } | undefined
}
export type ExperimentalPersistentPtyCreateOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ExperimentalPersistentPtyCreateOperation<E = never> = (
input: ExperimentalPersistentPtyCreateInput,
) => Effect.Effect<ExperimentalPersistentPtyCreateOutput, E>
export type ExperimentalPersistentPtyShutdownOutput = void
export type ExperimentalPersistentPtyShutdownOperation<E = never> = () => Effect.Effect<
ExperimentalPersistentPtyShutdownOutput,
E
>
export type ExperimentalPersistentPtyGetInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyGetOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ExperimentalPersistentPtyGetOperation<E = never> = (
input: ExperimentalPersistentPtyGetInput,
) => Effect.Effect<ExperimentalPersistentPtyGetOutput, E>
export type ExperimentalPersistentPtyUpdateInput = {
readonly ptyID: Pty.ID
readonly attachmentID?: string | undefined
readonly size: { readonly cols: number; readonly rows: number }
}
export type ExperimentalPersistentPtyUpdateOutput = {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
export type ExperimentalPersistentPtyUpdateOperation<E = never> = (
input: ExperimentalPersistentPtyUpdateInput,
) => Effect.Effect<ExperimentalPersistentPtyUpdateOutput, E>
export type ExperimentalPersistentPtySnapshotInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtySnapshotOutput = {
readonly info: {
readonly id: Pty.ID
readonly title: string
readonly command: string
readonly args: ReadonlyArray<string>
readonly cwd: string
readonly status: "running" | "exited"
readonly pid: number
readonly exitCode?: number | undefined
readonly sessionID: Session.ID
readonly foregroundProcess: string | null
readonly size: { readonly cols: number; readonly rows: number }
readonly output: { readonly head: number; readonly tail: number }
}
readonly text: string
readonly checkpoint: globalThis.Uint8Array
readonly cursor: { readonly x: number; readonly y: number }
}
export type ExperimentalPersistentPtySnapshotOperation<E = never> = (
input: ExperimentalPersistentPtySnapshotInput,
) => Effect.Effect<ExperimentalPersistentPtySnapshotOutput, E>
export type ExperimentalPersistentPtyRemoveInput = { readonly ptyID: Pty.ID }
export type ExperimentalPersistentPtyRemoveOutput = void
export type ExperimentalPersistentPtyRemoveOperation<E = never> = (
input: ExperimentalPersistentPtyRemoveInput,
) => Effect.Effect<ExperimentalPersistentPtyRemoveOutput, E>
export type ExperimentalPersistentPtyConnectTokenInput = {
readonly ptyID: Pty.ID
readonly "x-opencode-ticket"?: string | undefined
}
export type ExperimentalPersistentPtyConnectTokenOutput = PtyTicket.ConnectToken
export type ExperimentalPersistentPtyConnectTokenOperation<E = never> = (
input: ExperimentalPersistentPtyConnectTokenInput,
) => Effect.Effect<ExperimentalPersistentPtyConnectTokenOutput, E>
export interface ExperimentalApi<E = never> {
readonly persistentPty: {
readonly list: ExperimentalPersistentPtyListOperation<E>
readonly create: ExperimentalPersistentPtyCreateOperation<E>
readonly shutdown: ExperimentalPersistentPtyShutdownOperation<E>
readonly get: ExperimentalPersistentPtyGetOperation<E>
readonly update: ExperimentalPersistentPtyUpdateOperation<E>
readonly snapshot: ExperimentalPersistentPtySnapshotOperation<E>
readonly remove: ExperimentalPersistentPtyRemoveOperation<E>
readonly connectToken: ExperimentalPersistentPtyConnectTokenOperation<E>
}
}
export type ShellListInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
}
@@ -1664,6 +1830,7 @@ export type WorktreeCreateInput = {
readonly projectID: Project.ID
readonly strategy: Worktree.StrategyID
readonly from?: AbsolutePath | undefined
readonly branch?: string | undefined
readonly directory: AbsolutePath
readonly name?: string | undefined
}
@@ -1720,6 +1887,14 @@ export type VcsStatusInput = {
export type VcsStatusOutput = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
export type VcsBranchesInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}
export type VcsBranchesOutput = { readonly location: Location.Info; readonly data: Vcs.BranchList }
export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
readonly mode: Vcs.Mode
@@ -1731,6 +1906,7 @@ export type VcsDiffOperation<E = never> = (input: VcsDiffInput) => Effect.Effect
export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly diff: VcsDiffOperation<E>
}
@@ -1822,6 +1998,7 @@ export interface AppApi<E = never> {
readonly skill: SkillApi<E>
readonly event: EventApi<E>
readonly pty: PtyApi<E>
readonly experimental: ExperimentalApi<E>
readonly shell: ShellApi<E>
readonly reference: ReferenceApi<E>
readonly worktree: WorktreeApi<E>
+149 -1
View File
@@ -138,9 +138,13 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -192,6 +196,21 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
ExperimentalPersistentPtyUpdateOutput,
ExperimentalPersistentPtySnapshotInput,
ExperimentalPersistentPtySnapshotOutput,
ExperimentalPersistentPtyRemoveInput,
ExperimentalPersistentPtyRemoveOutput,
ExperimentalPersistentPtyConnectTokenInput,
ExperimentalPersistentPtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -222,6 +241,8 @@ import type {
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -916,6 +937,14 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
preserveEffect<CredentialActivateOutput>()(
raw["credential.activate"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
preserveEffect<CredentialRemoveOutput>()(
raw["credential.remove"]({
@@ -926,12 +955,21 @@ const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
update: EndpointCredentialUpdate(raw),
activate: EndpointCredentialActivate(raw),
remove: EndpointCredentialRemove(raw),
})
const EndpointProjectList = (raw: RawClient["server.project"]) => () =>
preserveEffect<ProjectListOutput>()(raw["project.list"]({}).pipe(Effect.mapError(mapClientError)))
const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: ProjectUpdateInput) =>
preserveEffect<ProjectUpdateOutput>()(
raw["project.update"]({
params: { projectID: input["projectID"] },
payload: { name: input["name"], icon: input["icon"], commands: input["commands"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
preserveEffect<ProjectCurrentOutput>()(
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -939,6 +977,7 @@ const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: Pr
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
list: EndpointProjectList(raw),
update: EndpointProjectUpdate(raw),
current: EndpointProjectCurrent(raw),
})
@@ -1179,6 +1218,100 @@ const adaptGroupPty = (raw: RawClient["server.pty"]) => ({
connect: { token: EndpointPtyConnectToken(raw) },
})
const EndpointExperimentalPersistentPtyList =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyListInput) =>
preserveEffect<ExperimentalPersistentPtyListOutput>()(
raw["persistentPty.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyCreate =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyCreateInput) =>
preserveEffect<ExperimentalPersistentPtyCreateOutput>()(
raw["persistentPty.create"]({
params: { sessionID: input["sessionID"] },
payload: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyShutdown = (raw: RawClient["server.experimental"]) => () =>
preserveEffect<ExperimentalPersistentPtyShutdownOutput>()(
raw["persistentPty.shutdown"]({}).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyGet =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyGetInput) =>
preserveEffect<ExperimentalPersistentPtyGetOutput>()(
raw["persistentPty.get"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyUpdate =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyUpdateInput) =>
preserveEffect<ExperimentalPersistentPtyUpdateOutput>()(
raw["persistentPty.update"]({
params: { ptyID: input["ptyID"] },
payload: { attachmentID: input["attachmentID"], size: input["size"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtySnapshot =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtySnapshotInput) =>
preserveEffect<ExperimentalPersistentPtySnapshotOutput>()(
raw["persistentPty.snapshot"]({ params: { ptyID: input["ptyID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointExperimentalPersistentPtyRemove =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyRemoveInput) =>
preserveEffect<ExperimentalPersistentPtyRemoveOutput>()(
raw["persistentPty.remove"]({ params: { ptyID: input["ptyID"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointExperimentalPersistentPtyConnectToken =
(raw: RawClient["server.experimental"]) => (input: ExperimentalPersistentPtyConnectTokenInput) =>
preserveEffect<ExperimentalPersistentPtyConnectTokenOutput>()(
raw["persistentPty.connectToken"]({
params: { ptyID: input["ptyID"] },
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const adaptGroupExperimental = (raw: RawClient["server.experimental"]) => ({
persistentPty: {
list: EndpointExperimentalPersistentPtyList(raw),
create: EndpointExperimentalPersistentPtyCreate(raw),
shutdown: EndpointExperimentalPersistentPtyShutdown(raw),
get: EndpointExperimentalPersistentPtyGet(raw),
update: EndpointExperimentalPersistentPtyUpdate(raw),
snapshot: EndpointExperimentalPersistentPtySnapshot(raw),
remove: EndpointExperimentalPersistentPtyRemove(raw),
connectToken: EndpointExperimentalPersistentPtyConnectToken(raw),
},
})
const EndpointShellList = (raw: RawClient["server.shell"]) => (input?: ShellListInput) =>
preserveEffect<ShellListOutput>()(
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
@@ -1248,7 +1381,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: Wo
preserveEffect<WorktreeCreateOutput>()(
raw["worktree.create"]({
params: { projectID: input["projectID"] },
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
payload: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1300,6 +1439,13 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) =>
preserveEffect<VcsBranchesOutput>()(
raw["vcs.branches"]({
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput) =>
preserveEffect<VcsDiffOutput>()(
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
@@ -1310,6 +1456,7 @@ const EndpointVcsDiff = (raw: RawClient["server.vcs"]) => (input: VcsDiffInput)
const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
status: EndpointVcsStatus(raw),
branches: EndpointVcsBranches(raw),
diff: EndpointVcsDiff(raw),
})
@@ -1377,6 +1524,7 @@ const adaptClient = (raw: RawClient) => ({
skill: adaptGroupSkill(raw["server.skill"]),
event: adaptGroupEvent(raw["server.event"]),
pty: adaptGroupPty(raw["server.pty"]),
experimental: adaptGroupExperimental(raw["server.experimental"]),
shell: adaptGroupShell(raw["server.shell"]),
reference: adaptGroupReference(raw["server.reference"]),
worktree: adaptGroupWorktree(raw["server.worktree"]),
+3
View File
@@ -4,15 +4,18 @@ export type AgentApi = Client["agent"]
export type CommandApi = Client["command"]
export type ConfigApi = Client["config"]
export type EventApi = Client["event"]
export type GenerateApi = Client["generate"]
export type IntegrationApi = Client["integration"]
export type McpApi = Client["mcp"]
export type ModelApi = Client["model"]
export type PluginApi = Client["plugin"]
export type PermissionApi = Client["permission"]
export type ProviderApi = Client["provider"]
export type ReferenceApi = Client["reference"]
export type WebSearchApi = Client["websearch"]
export type SessionApi = Client["session"]
export type SkillApi = Client["skill"]
export type VcsApi = Client["vcs"]
export interface CatalogApi {
readonly provider: ProviderApi
@@ -132,9 +132,13 @@ import type {
McpResourceCatalogOutput,
CredentialUpdateInput,
CredentialUpdateOutput,
CredentialActivateInput,
CredentialActivateOutput,
CredentialRemoveInput,
CredentialRemoveOutput,
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
ProjectCurrentInput,
ProjectCurrentOutput,
FormRequestListInput,
@@ -188,6 +192,21 @@ import type {
PtyRemoveOutput,
PtyConnectTokenInput,
PtyConnectTokenOutput,
ExperimentalPersistentPtyListInput,
ExperimentalPersistentPtyListOutput,
ExperimentalPersistentPtyCreateInput,
ExperimentalPersistentPtyCreateOutput,
ExperimentalPersistentPtyShutdownOutput,
ExperimentalPersistentPtyGetInput,
ExperimentalPersistentPtyGetOutput,
ExperimentalPersistentPtyUpdateInput,
ExperimentalPersistentPtyUpdateOutput,
ExperimentalPersistentPtySnapshotInput,
ExperimentalPersistentPtySnapshotOutput,
ExperimentalPersistentPtyRemoveInput,
ExperimentalPersistentPtyRemoveOutput,
ExperimentalPersistentPtyConnectTokenInput,
ExperimentalPersistentPtyConnectTokenOutput,
ShellListInput,
ShellListOutput,
ShellCreateInput,
@@ -218,6 +237,8 @@ import type {
VcsGetOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -1251,6 +1272,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
activate: (input: CredentialActivateInput, requestOptions?: RequestOptions) =>
request<CredentialActivateOutput>(
{
method: "POST",
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [401, 400],
empty: true,
},
requestOptions,
),
remove: (input: CredentialRemoveInput, requestOptions?: RequestOptions) =>
request<CredentialRemoveOutput>(
{
@@ -1270,6 +1303,18 @@ export function make(options: ClientOptions) {
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [401, 400], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
request<ProjectUpdateOutput>(
{
method: "PATCH",
path: `/api/project/${encodeURIComponent(input.projectID)}`,
body: { name: input["name"], icon: input["icon"], commands: input["commands"] },
successStatus: 200,
declaredStatuses: [404, 401, 400],
empty: false,
},
requestOptions,
),
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
request<ProjectCurrentOutput>(
{
@@ -1621,6 +1666,108 @@ export function make(options: ClientOptions) {
),
},
},
experimental: {
persistentPty: {
list: (input: ExperimentalPersistentPtyListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyListOutput }>(
{
method: "GET",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal`,
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: ExperimentalPersistentPtyCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyCreateOutput }>(
{
method: "POST",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/terminal`,
body: {
command: input["command"],
args: input["args"],
cwd: input["cwd"],
title: input["title"],
env: input["env"],
size: input["size"],
},
successStatus: 200,
declaredStatuses: [400, 503, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
shutdown: (requestOptions?: RequestOptions) =>
request<ExperimentalPersistentPtyShutdownOutput>(
{
method: "POST",
path: `/api/experimental/persistent-pty/shutdown`,
successStatus: 204,
declaredStatuses: [503, 401, 400],
empty: true,
},
requestOptions,
),
get: (input: ExperimentalPersistentPtyGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyGetOutput }>(
{
method: "GET",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
update: (input: ExperimentalPersistentPtyUpdateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyUpdateOutput }>(
{
method: "PUT",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
body: { attachmentID: input["attachmentID"], size: input["size"] },
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
snapshot: (input: ExperimentalPersistentPtySnapshotInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtySnapshotOutput }>(
{
method: "GET",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/snapshot`,
successStatus: 200,
declaredStatuses: [404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
remove: (input: ExperimentalPersistentPtyRemoveInput, requestOptions?: RequestOptions) =>
request<ExperimentalPersistentPtyRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}`,
successStatus: 204,
declaredStatuses: [404, 503, 401, 400],
empty: true,
},
requestOptions,
),
connectToken: (input: ExperimentalPersistentPtyConnectTokenInput, requestOptions?: RequestOptions) =>
request<{ readonly data: ExperimentalPersistentPtyConnectTokenOutput }>(
{
method: "POST",
path: `/api/experimental/persistent-pty/${encodeURIComponent(input.ptyID)}/connect-token`,
headers: { "x-opencode-ticket": input["x-opencode-ticket"] },
successStatus: 200,
declaredStatuses: [403, 404, 503, 401, 400],
empty: false,
},
requestOptions,
).then((value) => value.data),
},
},
shell: {
list: (input?: ShellListInput, requestOptions?: RequestOptions) =>
request<ShellListOutput>(
@@ -1736,6 +1883,7 @@ export function make(options: ClientOptions) {
body: {
strategy: input["strategy"],
from: input["from"],
branch: input["branch"],
directory: input["directory"],
name: input["name"],
},
@@ -1819,6 +1967,18 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) =>
request<VcsBranchesOutput>(
{
method: "GET",
path: `/api/vcs/branches`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
request<VcsDiffOutput>(
{

Some files were not shown because too many files have changed in this diff Show More