mirror of
https://github.com/anomalyco/opencode.git
synced 2026-10-16 11:20:42 +00:00
Compare commits
164
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4c44206ae2 | ||
|
|
4397ee44d0 | ||
|
|
b47c6802da | ||
|
|
ae8d59f8b5 | ||
|
|
7ced591c3c | ||
|
|
b55f220a17 | ||
|
|
fbf5f164d9 | ||
|
|
3aa26f1676 | ||
|
|
a8fc567c5c | ||
|
|
7b5f1bd9e1 | ||
|
|
bdf4793286 | ||
|
|
37df62f4a1 | ||
|
|
d099d2cb2a | ||
|
|
97c201931c | ||
|
|
328f74c213 | ||
|
|
32b1a1bf85 | ||
|
|
2ae7ec8a16 | ||
|
|
ccd3aa787a | ||
|
|
e05cc4a18d | ||
|
|
3943b84ef0 | ||
|
|
1e63a3bb73 | ||
|
|
7291f3e07a | ||
|
|
13afea6a1a | ||
|
|
8ffbcc2c81 | ||
|
|
35bb8e44ef | ||
|
|
0ccfe69ff3 | ||
|
|
07c0ff5914 | ||
|
|
fe1717b309 | ||
|
|
943dc005f7 | ||
|
|
cddf1dd3e9 | ||
|
|
b474b84259 | ||
|
|
c0d41d2b26 | ||
|
|
c458ad72d3 | ||
|
|
f44c40b4d3 | ||
|
|
5fa0a42386 | ||
|
|
d3e1de326b | ||
|
|
81d57087a4 | ||
|
|
34d8ded0c5 | ||
|
|
3a69bae845 | ||
|
|
a7ad408dfe | ||
|
|
6cc58b9094 | ||
|
|
165cd2e832 | ||
|
|
5aabb619c0 | ||
|
|
def5d948ef | ||
|
|
f1632d052d | ||
|
|
f576da508d | ||
|
|
1c28c2fec5 | ||
|
|
52ba7d630f | ||
|
|
6dc4d4e758 | ||
|
|
6a7cedcbf1 | ||
|
|
89e043b681 | ||
|
|
73517183b3 | ||
|
|
9de9b746e3 | ||
|
|
d2cedffd71 | ||
|
|
de417edf24 | ||
|
|
293a5bab9a | ||
|
|
41f656730e | ||
|
|
4ea42c47df | ||
|
|
43bb7ba3a2 | ||
|
|
7b33bcea4e | ||
|
|
46e4695b96 | ||
|
|
3ec856b6c2 | ||
|
|
2a593c0d1b | ||
|
|
a3f7f42ac9 | ||
|
|
7497b89b6d | ||
|
|
80d43eb707 | ||
|
|
d0a2ce32de | ||
|
|
8396de1759 | ||
|
|
1163e6cd91 | ||
|
|
573fd296d0 | ||
|
|
84e095526c | ||
|
|
e9def88a77 | ||
|
|
0af756b30f | ||
|
|
55c863daf3 | ||
|
|
d8e31d846a | ||
|
|
a00475a4dd | ||
|
|
7410b1c13c | ||
|
|
23e3493ab2 | ||
|
|
82833dac90 | ||
|
|
1015cb67d0 | ||
|
|
65f8907708 | ||
|
|
c1615b1db9 | ||
|
|
4ffede4941 | ||
|
|
be674e3158 | ||
|
|
5b22e915d8 | ||
|
|
c5aabae288 | ||
|
|
044d8b7d2e | ||
|
|
c7bddc38b3 | ||
|
|
5a4dd5b430 | ||
|
|
7e858f474d | ||
|
|
417f120ee3 | ||
|
|
bf8c48de73 | ||
|
|
de65b9f9cc | ||
|
|
836c0e5e65 | ||
|
|
9693c31233 | ||
|
|
4836431048 | ||
|
|
b8f925e51c | ||
|
|
e65080dd08 | ||
|
|
d8129ca1e0 | ||
|
|
b4f19bfe74 | ||
|
|
d78267503d | ||
|
|
06425d2bd7 | ||
|
|
460c626acd | ||
|
|
738f77e470 | ||
|
|
ba82b370c1 | ||
|
|
b4a57f6849 | ||
|
|
d7cfdaa3c7 | ||
|
|
15b24ae12d | ||
|
|
69a71426ce | ||
|
|
6a12383d3e | ||
|
|
bd23808adf | ||
|
|
3731aace4a | ||
|
|
126b7b633b | ||
|
|
f7ac58f267 | ||
|
|
67f5c2197a | ||
|
|
df63a72da4 | ||
|
|
2bf145b931 | ||
|
|
5673bfa4a8 | ||
|
|
d46588fe90 | ||
|
|
039eb85ba1 | ||
|
|
a22754f761 | ||
|
|
1438073271 | ||
|
|
80fb31ebb7 | ||
|
|
22326d4bc3 | ||
|
|
c57c448d31 | ||
|
|
1c74b9f020 | ||
|
|
7bac892c73 | ||
|
|
5683367e27 | ||
|
|
5b7bad23b9 | ||
|
|
e41e7e9064 | ||
|
|
0cb434775c | ||
|
|
bcf23b6775 | ||
|
|
25053bbdc7 | ||
|
|
a34961825b | ||
|
|
60a212c076 | ||
|
|
337e933d4a | ||
|
|
3e78cd58e0 | ||
|
|
6d1be26251 | ||
|
|
d306a15cf6 | ||
|
|
8ede63f4f6 | ||
|
|
b9ca4d21b4 | ||
|
|
773d8621d6 | ||
|
|
854b3bb9d7 | ||
|
|
e5f4bf4c24 | ||
|
|
03cb9b9946 | ||
|
|
4d9678a79d | ||
|
|
4d3d8fd769 | ||
|
|
e5a557efee | ||
|
|
35ef229454 | ||
|
|
707de58326 | ||
|
|
ca19780de1 | ||
|
|
47e5e71599 | ||
|
|
1c63fe9afe | ||
|
|
727fd603be | ||
|
|
4fbdea2d13 | ||
|
|
38454c6e9c | ||
|
|
82dd3e3dd3 | ||
|
|
83f2b6f67a | ||
|
|
d21b0790a6 | ||
|
|
e31136bdbd | ||
|
|
0d61729b3f | ||
|
|
4be28c9749 | ||
|
|
823ac87f58 | ||
|
|
7b3d4ce3a7 |
No files matched your search
@@ -0,0 +1,40 @@
|
||||
name: deploy-changelog
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- services/changelog/**
|
||||
- .github/workflows/deploy-changelog.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-changelog
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'dev'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: services/changelog
|
||||
run: bun typecheck
|
||||
|
||||
- name: Test
|
||||
working-directory: services/changelog
|
||||
run: bun test
|
||||
|
||||
- name: Deploy
|
||||
working-directory: services/changelog
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -0,0 +1,33 @@
|
||||
name: deploy-update
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- production
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: deploy-update-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'production'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Typecheck
|
||||
working-directory: services/update
|
||||
run: bun typecheck
|
||||
|
||||
- name: Deploy
|
||||
working-directory: services/update
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
@@ -49,13 +49,6 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Deploy update service
|
||||
if: github.ref_name == 'dev'
|
||||
working-directory: services/update
|
||||
run: bun run deploy
|
||||
env:
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
- name: Setup git committer
|
||||
id: committer
|
||||
uses: ./.github/actions/setup-git-committer
|
||||
@@ -119,6 +112,7 @@ jobs:
|
||||
BUN_COMPILE_RELEASE: bun-v1.4.2
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
OPENCODE_RELEASE_NOTES: ${{ (github.ref_name == 'dev' && inputs.release_notes) || '' }}
|
||||
GH_REPO: ${{ needs.version.outputs.repo }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
|
||||
@@ -265,6 +259,7 @@ jobs:
|
||||
env:
|
||||
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
|
||||
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
|
||||
OPENCODE_RELEASE_NOTES: ${{ (github.ref_name == 'dev' && inputs.release_notes) || '' }}
|
||||
|
||||
- name: Verify service lifecycle
|
||||
if: matrix.settings.target != 'windows-arm64'
|
||||
@@ -376,6 +371,7 @@ jobs:
|
||||
- host: "windows-2025"
|
||||
target: aarch64-pc-windows-msvc
|
||||
platform_flag: --win --arm64
|
||||
bun_install_flags: --cpu=*
|
||||
- host: "blacksmith-4vcpu-windows-2025"
|
||||
target: x86_64-pc-windows-msvc
|
||||
platform_flag: --win
|
||||
@@ -478,6 +474,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
GH_TOKEN: ${{ steps.committer.outputs.token }}
|
||||
CSC_KEYCHAIN: build.keychain
|
||||
APPLE_API_KEY: ${{ runner.temp }}/apple-api-key.p8
|
||||
@@ -491,6 +488,7 @@ jobs:
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }}
|
||||
OPENCODE_CLI_TARGET: ${{ matrix.settings.target }}
|
||||
|
||||
- name: Create macOS .app.tar.gz
|
||||
if: runner.os == 'macOS' && needs.version.outputs.release
|
||||
@@ -656,6 +654,7 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
OPENCODE_DESKTOP_DIST: /tmp/desktop
|
||||
WINGET_GITHUB_TOKEN: ${{ secrets.WINGET_GITHUB_TOKEN }}
|
||||
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@
|
||||
"**/.sst",
|
||||
"**/*.d.ts",
|
||||
"**/sdk.gen.ts",
|
||||
"packages/plugins/figma/skills/**",
|
||||
".agent/**",
|
||||
".agents/**",
|
||||
".claude/**",
|
||||
|
||||
@@ -2,3 +2,4 @@ sst-env.d.ts
|
||||
packages/desktop/src/bindings.ts
|
||||
packages/client/src/generated/
|
||||
packages/client/src/generated-effect/
|
||||
packages/plugins/figma/skills/
|
||||
@@ -1,19 +1,16 @@
|
||||
- After changing the public Protocol or Server `HttpApi`, run `bun run generate` from `packages/client`. Do not edit generated client files directly.
|
||||
- Keep runtime dependencies directed from Schema to Core and Protocol, then from Core and Protocol to Server. Client runtime code may depend on Schema and Protocol but never Core or Server; `sdk` composes Client, Core, and Server.
|
||||
- Current implementation changes belong in `packages/core`, `packages/cli`, `packages/server`, `packages/protocol`, `packages/schema`, and related generated client surfaces when required.
|
||||
- This repository does not use Changesets. Do not add `.changeset` files; follow the existing release workflow instead.
|
||||
- The default branch in this repo is `dev`.
|
||||
- Default new branches and worktrees to `dev`, or `origin/dev` when the local `dev` ref is unavailable, and default pull requests to target `dev`. Use another base or target branch when the requester explicitly instructs it.
|
||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
||||
- The default branch is `dev`. Base branches and worktrees on `origin/dev`, target PRs at `dev`, and diff against `origin/dev` unless told otherwise.
|
||||
|
||||
## Live V2 TUI Testing
|
||||
## Live TUI Testing
|
||||
|
||||
- Run `bun run dev:live` from a development worktree to test its TUI against the currently elected `opencode` background server and live sessions.
|
||||
- Pass a directory after the script when needed, for example `bun run dev:live /path/to/project`.
|
||||
- The script discovers the server with `opencode service status`, injects its private local credential from `opencode service get password`, and uses the `dev` TUI storage channel so tabs and other client-local state match the installed client.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## V2 TUI Stories
|
||||
## TUI Stories
|
||||
|
||||
- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`.
|
||||
- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly.
|
||||
@@ -54,7 +51,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi
|
||||
- Use Bun APIs when possible, like `Bun.file()`
|
||||
- Rely on type inference when possible; avoid explicit type annotations or interfaces unless necessary for exports or clarity
|
||||
- Prefer functional array methods (flatMap, filter, map) over for loops; use type guards on filter to maintain type inference downstream
|
||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent"`) when adding a new config module.
|
||||
- In `src/config`, follow the existing self-export pattern at the top of the file (for example `export * as ConfigAgent from "./agent.js"`) when adding a new config module.
|
||||
- In Effect generators, bind services to named variables before calling methods. Do not use nested service yields such as `yield* (yield* Foo.Service).bar()`.
|
||||
|
||||
Reduce total variable count by inlining when a value is only used once.
|
||||
@@ -175,18 +172,15 @@ const table = sqliteTable("session", {
|
||||
- Run `bun run check` from the repository root as the canonical full lint and type-check verification.
|
||||
- During focused iteration, run `bun typecheck` from the affected package directory (for example, `packages/core`). Never run `tsc` directly.
|
||||
|
||||
## V2 Session Core
|
||||
## Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a user or synthetic inbox item ID is idempotent when Session and type match: the first admission wins and the retried payload, metadata, and delivery mode are ignored, whether the item is still pending or already delivered (reconciled from the projected message without retained enqueue history). Cross-Session or cross-type reuse fails. Control items keep their operation-specific conflict behavior.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. Interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Each step is one explicit `llm.stream` call that continues from reloaded history. Do not add an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
+223
@@ -2,6 +2,229 @@
|
||||
|
||||
Release notes for OpenCode V2, newest first.
|
||||
|
||||
## v2.0.29 — 2026-10-14
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Remote Device Pairing**: In **Settings → Pairing**, select **Pair device** to enable OpenTunnel remote access and get a QR code or copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
|
||||
- **Provider Account Management**: Use the connect dialog or **Settings → Providers** to switch, rename, remove, or add provider accounts. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
|
||||
- **Automatic Response Continuation**: Responses that reach their output-token limit now continue automatically, preserving partial output instead of stopping abruptly. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
|
||||
- **Trackpad Image Navigation**: Pinch to zoom and use two-finger scrolling to pan image and SVG previews. Select **Fit** to reset the view. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Automatically continue responses after an output-token limit, with bounded retries and clearer recovery for truncated tool calls. ([#53876](https://github.com/anomalyco/opencode/pull/53876))
|
||||
- Stabilize access policies under the top-level `policies` configuration key while retaining compatibility with `experimental.policies`. ([#55297](https://github.com/anomalyco/opencode/pull/55297))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Give centrally managed configuration precedence over local and user configuration. ([#55267](https://github.com/anomalyco/opencode/pull/55267))
|
||||
- Allow migrated V1 npm provider configuration to target built-in provider routes. ([#55254](https://github.com/anomalyco/opencode/pull/55254))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Report working sessions and sessions awaiting permission or input through terminal program-status notifications. ([#55172](https://github.com/anomalyco/opencode/pull/55172))
|
||||
|
||||
### Desktop
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Pair devices from **Settings → Pairing** through OpenTunnel remote access, with a QR code and copyable link. ([#55306](https://github.com/anomalyco/opencode/pull/55306))
|
||||
- Manage provider accounts from the connect dialog and **Settings → Providers**, including switching, renaming, removal, and adding another account. ([#54101](https://github.com/anomalyco/opencode/pull/54101))
|
||||
- Show session context tokens and accumulated family cost in the session view. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
- Pinch to zoom and use two-finger scrolling to pan image and SVG previews; select **Fit** to reset. ([#54590](https://github.com/anomalyco/opencode/pull/54590))
|
||||
- Cycle side-panel tabs with the configured previous-tab and next-tab shortcuts while the side panel is focused. ([#54899](https://github.com/anomalyco/opencode/pull/54899))
|
||||
- Refresh the question composer and clarify timeline notices that apply only to a model variant. ([#54822](https://github.com/anomalyco/opencode/pull/54822), [#54880](https://github.com/anomalyco/opencode/pull/54880))
|
||||
- Refine diff colors and move unread indicators into session tabs. ([#52551](https://github.com/anomalyco/opencode/pull/52551), [#53570](https://github.com/anomalyco/opencode/pull/53570))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Show steered messages as pending immediately and render inline attachments before their server echo arrives. ([#55251](https://github.com/anomalyco/opencode/pull/55251), [#54650](https://github.com/anomalyco/opencode/pull/54650))
|
||||
- Preserve raw media data up to 256 MiB so artifact previews remain available. ([#55018](https://github.com/anomalyco/opencode/pull/55018))
|
||||
- Retain session model selections when the model catalog refreshes. ([#54481](https://github.com/anomalyco/opencode/pull/54481))
|
||||
- Close browser tabs from the address field and suppress incorrect missing-file notices for restored or already-open tabs. ([#54501](https://github.com/anomalyco/opencode/pull/54501), [#55022](https://github.com/anomalyco/opencode/pull/55022))
|
||||
- Prevent a project-store eviction loop during launch and remove hover gaps between vertical tabs. ([#55078](https://github.com/anomalyco/opencode/pull/55078), [#54866](https://github.com/anomalyco/opencode/pull/54866))
|
||||
|
||||
### SDK
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Export `lastAssistantWithUsage` from the Solid client to consistently locate the assistant step that determines current context usage. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Include all paginated child sessions when loading a session family for usage totals. ([#53785](https://github.com/anomalyco/opencode/pull/53785))
|
||||
- Admit inline data-URL files into optimistic pending messages so attachments render immediately. ([#54650](https://github.com/anomalyco/opencode/pull/54650))
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Add `ctx.permission.assert` for Effect and Promise plugins, allowing plugins to use configured permission rules and the native approval prompt. ([#54930](https://github.com/anomalyco/opencode/pull/54930))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Use Figma's public endpoint and default OAuth configuration, and support Git installations and development releases. ([#55252](https://github.com/anomalyco/opencode/pull/55252), [#55304](https://github.com/anomalyco/opencode/pull/55304))
|
||||
- Resolve the Figma plugin peer dependency from its workspace. ([#55269](https://github.com/anomalyco/opencode/pull/55269))
|
||||
- Associate each rejected Promise hook with its own tool call. Thanks [@1052326311](https://github.com/1052326311). ([#55263](https://github.com/anomalyco/opencode/pull/55263))
|
||||
|
||||
## v2.0.28 — 2026-10-13
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Per-Project Auto-Accept**: Set **Settings → Projects → Auto-accept permissions** to override the default for one project. You can also press `mod+shift+a` in a session. ([#54839](https://github.com/anomalyco/opencode/pull/54839))
|
||||
- **Release Highlights in the TUI**: After updating, the home screen shows the release highlights once. Run `/highlights` to reopen them, or disable automatic display with `highlights: false` in `cli.json`. ([#54969](https://github.com/anomalyco/opencode/pull/54969))
|
||||
- **Native Windows ARM64 Support**: Windows ARM64 downloads, CLI installs, and desktop updates now use native ARM64 builds instead of x64 emulation. ([#54689](https://github.com/anomalyco/opencode/pull/54689), [#54971](https://github.com/anomalyco/opencode/pull/54971))
|
||||
- **Continuous Gemini Responses**: Long Gemini and Vertex Gemini responses split by the provider now continue automatically as one response, including recovery from a dropped stream after a continuation checkpoint. ([#55056](https://github.com/anomalyco/opencode/pull/55056))
|
||||
- **Official WinGet Packages**: Install V2 with `winget install --id Anomaly.OpenCode -e` or `winget install --id Anomaly.OpenCode.Desktop -e`. OpenCode upgrades WinGet installations through WinGet. ([#54808](https://github.com/anomalyco/opencode/pull/54808))
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Continued split Gemini and Vertex Gemini responses automatically using provider continuation tokens. ([#55056](https://github.com/anomalyco/opencode/pull/55056))
|
||||
- Added `settings.encoding` with `zstd`, `gzip`, and `none` values for provider, model, and variant HTTP request bodies. ChatGPT authentication defaults to Zstandard compression when no custom `baseURL` is set. Thanks [@esafak](https://github.com/esafak). ([#54932](https://github.com/anomalyco/opencode/pull/54932))
|
||||
- Added provider-level compatibility defaults and `compatibility.cacheFormat` for Anthropic- and Alibaba-style prompt-cache markers. ([#54911](https://github.com/anomalyco/opencode/pull/54911), [#54945](https://github.com/anomalyco/opencode/pull/54945))
|
||||
- Added official WinGet packages and WinGet-aware upgrade and uninstall support. ([#54808](https://github.com/anomalyco/opencode/pull/54808))
|
||||
- Raised the default streamed-chunk timeout from 5 to 15 minutes for models that pause while reasoning. ([#55032](https://github.com/anomalyco/opencode/pull/55032))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Disabled Bun’s fetch idle timeout for provider requests, preventing long-running streams from ending early. ([#55007](https://github.com/anomalyco/opencode/pull/55007))
|
||||
- Preserved mixed CRLF and LF line endings when the edit tool changes files. ([#53166](https://github.com/anomalyco/opencode/pull/53166))
|
||||
- Preserved unknown V1 OpenAI-compatible model options in migrated request bodies. ([#54694](https://github.com/anomalyco/opencode/pull/54694))
|
||||
- Stopped querying Azure resources during startup; Azure connections now ask for the resource name directly. ([#54870](https://github.com/anomalyco/opencode/pull/54870))
|
||||
- Loaded the system configuration directory at the lowest precedence. Thanks [@rntdrts](https://github.com/rntdrts). ([#54921](https://github.com/anomalyco/opencode/pull/54921))
|
||||
- Installed the native CLI and extracted ripgrep without PowerShell on Windows ARM64. ([#54955](https://github.com/anomalyco/opencode/pull/54955), [#54981](https://github.com/anomalyco/opencode/pull/54981))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added the post-update highlights dialog, `/highlights`, and the `highlights` alert setting. ([#54969](https://github.com/anomalyco/opencode/pull/54969))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Restricted slash-command autocomplete to the start of the prompt. Thanks [@OpeOginni](https://github.com/OpeOginni). ([#54857](https://github.com/anomalyco/opencode/pull/54857))
|
||||
- Reported x64 emulation correctly when the TUI runs on Windows ARM64. ([#54984](https://github.com/anomalyco/opencode/pull/54984))
|
||||
- Removed the stale `todowrite` label from tool output. ([#53966](https://github.com/anomalyco/opencode/pull/53966))
|
||||
|
||||
### Desktop
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added per-project auto-accept permission overrides under **Settings → Projects**, with `mod+shift+a` controlling the current project. ([#54839](https://github.com/anomalyco/opencode/pull/54839))
|
||||
- Added a native **Windows (ARM64)** option to the download page. ([#54971](https://github.com/anomalyco/opencode/pull/54971))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Bundled native ARM64 terminal and CLI binaries in Windows ARM64 desktop builds, and moved emulated x64 installations to native ARM64 updates. Thanks [@mweinbach](https://github.com/mweinbach). ([#54689](https://github.com/anomalyco/opencode/pull/54689), [#54983](https://github.com/anomalyco/opencode/pull/54983))
|
||||
- Included subagent sessions in displayed session costs. ([#55052](https://github.com/anomalyco/opencode/pull/55052))
|
||||
- Loaded image and audio attachments in the review panel and opened video previews in an in-window theatre view. ([#55015](https://github.com/anomalyco/opencode/pull/55015), [#54696](https://github.com/anomalyco/opencode/pull/54696))
|
||||
- Showed submitted prompts immediately and kept the **Working** marker above undelivered steers. ([#54047](https://github.com/anomalyco/opencode/pull/54047), [#55023](https://github.com/anomalyco/opencode/pull/55023))
|
||||
- Fixed tab closing when collapsed side-panel tabs exist and restored focus correctly after stacked dialogs close. ([#54695](https://github.com/anomalyco/opencode/pull/54695), [#55038](https://github.com/anomalyco/opencode/pull/55038))
|
||||
|
||||
### SDK
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added request-body encoding and model/provider cache-format fields to generated client types. ([#54932](https://github.com/anomalyco/opencode/pull/54932), [#54945](https://github.com/anomalyco/opencode/pull/54945))
|
||||
- Exported `CredentialApi` from the Promise client API. ([#52797](https://github.com/anomalyco/opencode/pull/52797))
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added an optional official Figma plugin with its MCP connection and six bundled Figma skills. ([#55049](https://github.com/anomalyco/opencode/pull/55049), [#55061](https://github.com/anomalyco/opencode/pull/55061))
|
||||
- Exposed `credential.activate` to Effect and Promise server plugins. Thanks [@remorses](https://github.com/remorses). ([#52797](https://github.com/anomalyco/opencode/pull/52797))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Converted rejected Promise-based plugin tools into normal tool failures instead of crashing their execution path. ([#54913](https://github.com/anomalyco/opencode/pull/54913))
|
||||
- Fixed the VS Code extension launching the V2 CLI with the removed `--port` argument. Thanks [@uyencss](https://github.com/uyencss). ([#49084](https://github.com/anomalyco/opencode/pull/49084))
|
||||
|
||||
## v2.0.27 — 2026-10-12
|
||||
|
||||
### Highlights
|
||||
|
||||
- **Built-In Worktree Tools**: Ask OpenCode to use `worktree_create`, `worktree_list`, and `worktree_remove` in Code Mode. You can create isolated worktrees, move sessions into them, and remove them with normal edit approval. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
|
||||
- **Safer Remote Pairing**: The first `opencode pair --remote` now warns that it will expose your server and asks for confirmation. For non-interactive use, pass `opencode pair --remote --yes`. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
|
||||
- **Installable Web App**: Use Chrome or Edge’s **Install** button to install the OpenCode web app, including when it runs behind an authenticating proxy. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
|
||||
- **Clear Session Failures**: Desktop and TUI timelines now show execution errors that happen before or between model steps instead of ending without an explanation. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
|
||||
- **Automatic Project Cleanup**: Projects whose directories no longer exist are automatically hidden from the project list. Opening the directory again restores the project. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
|
||||
|
||||
### Core
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added the built-in Code Mode tools `opencode.worktree_create`, `opencode.worktree_list`, and `opencode.worktree_remove`. Removing a worktree requires edit approval, and `force: true` can discard modified or untracked files. ([#53981](https://github.com/anomalyco/opencode/pull/53981))
|
||||
- Added confirmation before `opencode pair --remote` first enables internet access, plus `--yes`/`-y` for scripts and other non-interactive use. ([#54561](https://github.com/anomalyco/opencode/pull/54561))
|
||||
- Automatically archived projects when all known directories disappear. Reopening a project restores it, while surviving clones and linked worktrees keep it active. ([#54354](https://github.com/anomalyco/opencode/pull/54354))
|
||||
- Raised the ChatGPT OAuth input limit for `gpt-6.1-sol` and `gpt-6.1-sol-fast` to 500,000 tokens. ([#54668](https://github.com/anomalyco/opencode/pull/54668))
|
||||
- Added Vertex AI routes for Interactions and Mistral models. ([#54175](https://github.com/anomalyco/opencode/pull/54175), [#54058](https://github.com/anomalyco/opencode/pull/54058))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Fixed updater detection for symlinked home directories and package-manager installations, increased probe timeouts, and now report unavailable updates clearly when the installation method cannot be detected. ([#54609](https://github.com/anomalyco/opencode/pull/54609), [#54622](https://github.com/anomalyco/opencode/pull/54622), [#54672](https://github.com/anomalyco/opencode/pull/54672))
|
||||
- Fixed GitHub Copilot routing for Gemini, Anthropic, and Responses models when live model metadata is unavailable, and accepted interleaved Claude reasoning values. ([#54208](https://github.com/anomalyco/opencode/pull/54208), [#54210](https://github.com/anomalyco/opencode/pull/54210), [#54201](https://github.com/anomalyco/opencode/pull/54201))
|
||||
- Fixed Google Vertex Application Default Credentials when `CLOUDSDK_CONFIG` points to a custom gcloud configuration directory. Thanks [@H-Max](https://github.com/H-Max). ([#54164](https://github.com/anomalyco/opencode/pull/54164))
|
||||
- Prevented ChatGPT token-sharing sessions from sending unsupported mid-session reasoning-effort updates. ([#54085](https://github.com/anomalyco/opencode/pull/54085))
|
||||
- Blocked `file://` access through Code Mode’s `fetch`, which now returns a clear filesystem-access error. Thanks [@ferdinandyb](https://github.com/ferdinandyb). ([#54510](https://github.com/anomalyco/opencode/pull/54510))
|
||||
- Fixed remote workspace file operations so driver-provided filesystem overrides are used correctly.
|
||||
- Preserved subagent notifications when location services are evicted, and propagated well-known configuration updates across locations with bounded HTTP timeouts. ([#54072](https://github.com/anomalyco/opencode/pull/54072), [#53821](https://github.com/anomalyco/opencode/pull/53821))
|
||||
|
||||
### TUI
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
|
||||
- Preserved a saved session’s unavailable model selection instead of silently replacing it with a fallback. ([#53822](https://github.com/anomalyco/opencode/pull/53822))
|
||||
- Added syntax highlighting for C++ module interface files such as `.cppm`, `.ixx`, and `.cxxm`. ([#53853](https://github.com/anomalyco/opencode/pull/53853))
|
||||
- Summarized integrations with multiple credentials as a connection count, preventing long labels from crowding the integration name. ([#54054](https://github.com/anomalyco/opencode/pull/54054))
|
||||
|
||||
### Desktop
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Made the web app installable from Chrome and Edge, including deployments behind cookie-based authenticating proxies. ([#53905](https://github.com/anomalyco/opencode/pull/53905))
|
||||
- Updated the session column layout and text wrapping for clearer long content. ([#54020](https://github.com/anomalyco/opencode/pull/54020))
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Displayed session execution failures that occur before or between model steps directly in the timeline. ([#53826](https://github.com/anomalyco/opencode/pull/53826))
|
||||
- Fixed command-palette selection so newly arriving higher-ranked results become active until you explicitly choose a result. ([#54275](https://github.com/anomalyco/opencode/pull/54275))
|
||||
- Preserved bold, code, emphasis, and strike-through formatting inside Markdown link labels. ([#54451](https://github.com/anomalyco/opencode/pull/54451))
|
||||
- Fixed timeline file links and complete patch rendering, including unusual filenames and patches whose original alignment can be reused. ([#53641](https://github.com/anomalyco/opencode/pull/53641), [#54017](https://github.com/anomalyco/opencode/pull/54017))
|
||||
- Showed complete tool error text when an error is expanded. ([#53816](https://github.com/anomalyco/opencode/pull/53816))
|
||||
- Fixed browser-panel controls, page visibility, corner masks, and closed side-region overlays. ([#53907](https://github.com/anomalyco/opencode/pull/53907), [#54038](https://github.com/anomalyco/opencode/pull/54038), [#52461](https://github.com/anomalyco/opencode/pull/52461), [#54052](https://github.com/anomalyco/opencode/pull/54052))
|
||||
- Made the third-party notices dialog scrollable. ([#53911](https://github.com/anomalyco/opencode/pull/53911))
|
||||
|
||||
### SDK
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Added startup plugin seeding to Effect embedded hosts and workerd through `OpenCode.create({ plugins })` and `OpenCodeWorkerd.create({ plugins })`.
|
||||
- Re-exported `WorkspaceDriver`, `EnvironmentDriver`, and `EnvironmentFiles` from `@opencode/sdk/effect`.
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Ensured embedded hosts activate supplied plugins before model discovery, generation, and recovery begin. ([#54219](https://github.com/anomalyco/opencode/pull/54219))
|
||||
|
||||
### Extensions
|
||||
|
||||
#### Improvements
|
||||
|
||||
- Extended `BridgeLayout` with viewport dimensions and optional border information so native extension surfaces can track window resizing and preserve rounded card borders.
|
||||
|
||||
#### Bug fixes
|
||||
|
||||
- Kept desktop browser-panel corner masks and borders aligned while resizing or using custom themes. ([#52461](https://github.com/anomalyco/opencode/pull/52461))
|
||||
- Kept the browser site button clickable after the embedded page takes focus. ([#53907](https://github.com/anomalyco/opencode/pull/53907))
|
||||
|
||||
## v2.0.26 — 2026-10-08
|
||||
|
||||
### Highlights
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">وكيل برمجة بالذكاء الاصطناعي مفتوح المصدر.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# مديري الحزم
|
||||
npm i -g opencode-ai@latest # او bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # او bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS و Linux (موصى به، دائما محدث)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">ওপেন সোর্স এআই কোডিং এজেন্ট।</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Package managers
|
||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # or bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">O agente de programação com IA de código aberto.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Gerenciadores de pacotes
|
||||
npm i -g opencode-ai@latest # ou bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # ou bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS e Linux (recomendado, sempre atualizado)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">OpenCode je open source AI agent za programiranje.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Package manageri
|
||||
npm i -g opencode-ai@latest # ili bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # ili bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS i Linux (preporučeno, uvijek ažurno)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">Den open source AI-kodeagent.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Pakkehåndteringer
|
||||
npm i -g opencode-ai@latest # eller bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # eller bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalet, altid up to date)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">Der Open-Source KI-Coding-Agent.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Paketmanager
|
||||
npm i -g opencode-ai@latest # oder bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # oder bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS und Linux (empfohlen, immer aktuell)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">El agente de programación con IA de código abierto.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Gestores de paquetes
|
||||
npm i -g opencode-ai@latest # o bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # o bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS y Linux (recomendado, siempre al día)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">L'agent de codage IA open source.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Gestionnaires de paquets
|
||||
npm i -g opencode-ai@latest # ou bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # ou bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS et Linux (recommandé, toujours à jour)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">Ο πράκτορας τεχνητής νοημοσύνης ανοικτού κώδικα για προγραμματισμό.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Διαχειριστές πακέτων
|
||||
npm i -g opencode-ai@latest # ή bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # ή bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS και Linux (προτείνεται, πάντα ενημερωμένο)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">L’agente di coding AI open source.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Package manager
|
||||
npm i -g opencode-ai@latest # oppure bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # oppure bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">オープンソースのAIコーディングエージェント。</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# パッケージマネージャー
|
||||
npm i -g opencode-ai@latest # bun/pnpm/yarn でもOK
|
||||
npm i -g @opencode/cli@latest # bun/pnpm/yarn でもOK
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS と Linux(推奨。常に最新)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">오픈 소스 AI 코딩 에이전트.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# 패키지 매니저
|
||||
npm i -g opencode-ai@latest # bun/pnpm/yarn 도 가능
|
||||
npm i -g @opencode/cli@latest # bun/pnpm/yarn 도 가능
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS 및 Linux (권장, 항상 최신)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<p align="center">The open source AI coding agent.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Package managers
|
||||
npm i -g opencode-ai@latest # or bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # or bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS and Linux (recommended, always up to date)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">AI-kodeagent med åpen kildekode.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Pakkehåndterere
|
||||
npm i -g opencode-ai@latest # eller bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # eller bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS og Linux (anbefalt, alltid oppdatert)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">Otwartoźródłowy agent kodujący AI.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Menedżery pakietów
|
||||
npm i -g opencode-ai@latest # albo bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # albo bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS i Linux (polecane, zawsze aktualne)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">Открытый AI-агент для программирования.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Менеджеры пакетов
|
||||
npm i -g opencode-ai@latest # или bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # или bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS и Linux (рекомендуем, всегда актуально)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">เอเจนต์การเขียนโค้ดด้วย AI แบบโอเพนซอร์ส</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="สถานะการสร้าง" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# ตัวจัดการแพ็กเกจ
|
||||
npm i -g opencode-ai@latest # หรือ bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # หรือ bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS และ Linux (แนะนำ อัปเดตเสมอ)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">Açık kaynaklı yapay zeka kodlama asistanı.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Paket yöneticileri
|
||||
npm i -g opencode-ai@latest # veya bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # veya bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS ve Linux (önerilir, her zaman güncel)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">AI-агент для програмування з відкритим кодом.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Менеджери пакетів
|
||||
npm i -g opencode-ai@latest # або bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # або bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS і Linux (рекомендовано, завжди актуально)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">Trợ lý lập trình AI mã nguồn mở.</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# Các trình quản lý gói (Package managers)
|
||||
npm i -g opencode-ai@latest # hoặc bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # hoặc bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS và Linux (khuyên dùng, luôn cập nhật)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">开源的 AI Coding Agent。</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# 软件包管理器
|
||||
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # 也可使用 bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS 和 Linux(推荐,始终保持最新)
|
||||
|
||||
+2
-2
@@ -10,7 +10,7 @@
|
||||
<p align="center">開源的 AI Coding Agent。</p>
|
||||
<p align="center">
|
||||
<a href="https://opencode.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
|
||||
<a href="https://www.npmjs.com/package/opencode-ai"><img alt="npm" src="https://img.shields.io/npm/v/opencode-ai?style=flat-square" /></a>
|
||||
<a href="https://www.npmjs.com/package/@opencode/cli"><img alt="npm" src="https://img.shields.io/npm/v/@opencode/cli?style=flat-square" /></a>
|
||||
<a href="https://github.com/anomalyco/opencode/actions/workflows/publish.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/anomalyco/opencode/publish.yml?style=flat-square&branch=dev" /></a>
|
||||
</p>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
curl -fsSL https://opencode.ai/install | bash
|
||||
|
||||
# 套件管理員
|
||||
npm i -g opencode-ai@latest # 也可使用 bun/pnpm/yarn
|
||||
npm i -g @opencode/cli@latest # 也可使用 bun/pnpm/yarn
|
||||
scoop install opencode # Windows
|
||||
choco install opencode # Windows
|
||||
brew install anomalyco/tap/opencode # macOS 與 Linux(推薦,始終保持最新)
|
||||
|
||||
+16
-257
@@ -2,225 +2,7 @@ import { domain } from "./stage"
|
||||
import { EMAILOCTOPUS_API_KEY } from "./app"
|
||||
import { SECRET } from "./secret"
|
||||
|
||||
////////////////
|
||||
// DATABASE
|
||||
////////////////
|
||||
|
||||
const cluster = planetscale.getDatabaseOutput({
|
||||
name: "opencode",
|
||||
organization: "anomalyco",
|
||||
})
|
||||
|
||||
const branch =
|
||||
$app.stage === "production"
|
||||
? planetscale.getBranchOutput({
|
||||
name: "production",
|
||||
organization: cluster.organization,
|
||||
database: cluster.name,
|
||||
})
|
||||
: new planetscale.Branch("DatabaseBranch", {
|
||||
database: cluster.name,
|
||||
organization: cluster.organization,
|
||||
name: $app.stage,
|
||||
parentBranch: "production",
|
||||
})
|
||||
const password = new planetscale.Password("DatabasePassword", {
|
||||
name: $app.stage,
|
||||
database: cluster.name,
|
||||
organization: cluster.organization,
|
||||
branch: branch.name,
|
||||
})
|
||||
|
||||
export const database = new sst.Linkable("Database", {
|
||||
properties: {
|
||||
host: password.accessHostUrl,
|
||||
database: cluster.name,
|
||||
username: password.username,
|
||||
password: password.plaintext,
|
||||
port: 3306,
|
||||
},
|
||||
})
|
||||
|
||||
new sst.x.DevCommand("Studio", {
|
||||
link: [database],
|
||||
dev: {
|
||||
command: "bun db studio",
|
||||
directory: "packages/console/core",
|
||||
autostart: true,
|
||||
},
|
||||
})
|
||||
|
||||
////////////////
|
||||
// AUTH
|
||||
////////////////
|
||||
|
||||
const GITHUB_CLIENT_ID_CONSOLE = new sst.Secret("GITHUB_CLIENT_ID_CONSOLE")
|
||||
const GITHUB_CLIENT_SECRET_CONSOLE = new sst.Secret("GITHUB_CLIENT_SECRET_CONSOLE")
|
||||
const GOOGLE_CLIENT_ID = new sst.Secret("GOOGLE_CLIENT_ID")
|
||||
const authStorage = new sst.cloudflare.Kv("AuthStorage")
|
||||
export const auth = new sst.cloudflare.Worker("AuthApi", {
|
||||
domain: `auth.${domain}`,
|
||||
handler: "packages/console/function/src/auth.ts",
|
||||
url: true,
|
||||
link: [database, authStorage, GITHUB_CLIENT_ID_CONSOLE, GITHUB_CLIENT_SECRET_CONSOLE, GOOGLE_CLIENT_ID],
|
||||
})
|
||||
|
||||
////////////////
|
||||
// GATEWAY
|
||||
////////////////
|
||||
|
||||
export const stripeWebhook = new stripe.WebhookEndpoint("StripeWebhookEndpoint", {
|
||||
url: $interpolate`https://${domain}/stripe/webhook`,
|
||||
enabledEvents: [
|
||||
"checkout.session.async_payment_failed",
|
||||
"checkout.session.async_payment_succeeded",
|
||||
"checkout.session.completed",
|
||||
"checkout.session.expired",
|
||||
"charge.refunded",
|
||||
"invoice.payment_succeeded",
|
||||
"invoice.payment_failed",
|
||||
"invoice.payment_action_required",
|
||||
"customer.created",
|
||||
"customer.deleted",
|
||||
"customer.updated",
|
||||
"customer.discount.created",
|
||||
"customer.discount.deleted",
|
||||
"customer.discount.updated",
|
||||
"customer.source.created",
|
||||
"customer.source.deleted",
|
||||
"customer.source.expiring",
|
||||
"customer.source.updated",
|
||||
"customer.subscription.created",
|
||||
"customer.subscription.deleted",
|
||||
"customer.subscription.paused",
|
||||
"customer.subscription.pending_update_applied",
|
||||
"customer.subscription.pending_update_expired",
|
||||
"customer.subscription.resumed",
|
||||
"customer.subscription.trial_will_end",
|
||||
"customer.subscription.updated",
|
||||
],
|
||||
})
|
||||
|
||||
const zenLiteProduct = new stripe.Product("ZenLite", {
|
||||
name: "OpenCode Go",
|
||||
})
|
||||
const zenLiteCouponFirstMonth50 = new stripe.Coupon("ZenLiteCouponFirstMonth50", {
|
||||
name: "First month 50% off",
|
||||
percentOff: 50,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "once",
|
||||
})
|
||||
const zenLiteCouponFirstMonth100 = new stripe.Coupon("ZenLiteCouponFirstMonth100", {
|
||||
name: "First month 100% off",
|
||||
percentOff: 100,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "once",
|
||||
})
|
||||
const zenLiteCouponThreeMonths100 = new stripe.Coupon("ZenLiteCoupon3Months100", {
|
||||
name: "3 months 100% off",
|
||||
percentOff: 100,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "repeating",
|
||||
durationInMonths: 3,
|
||||
})
|
||||
const zenLiteCouponSixMonths100 = new stripe.Coupon("ZenLiteCoupon6Months100", {
|
||||
name: "6 months 100% off",
|
||||
percentOff: 100,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "repeating",
|
||||
durationInMonths: 6,
|
||||
})
|
||||
const zenLiteCouponTwelveMonths100 = new stripe.Coupon("ZenLiteCoupon12Months100", {
|
||||
name: "12 months 100% off",
|
||||
percentOff: 100,
|
||||
appliesToProducts: [zenLiteProduct.id],
|
||||
duration: "repeating",
|
||||
durationInMonths: 12,
|
||||
})
|
||||
const zenLitePrice = new stripe.Price("ZenLitePrice", {
|
||||
product: zenLiteProduct.id,
|
||||
currency: "usd",
|
||||
recurring: {
|
||||
interval: "month",
|
||||
intervalCount: 1,
|
||||
},
|
||||
unitAmount: 1000,
|
||||
})
|
||||
const ZEN_LITE_PRICE = new sst.Linkable("ZEN_LITE_PRICE", {
|
||||
properties: {
|
||||
// Use existing Go resources in dev's checkout Stripe account.
|
||||
product: $app.stage === "dev" ? "prod_U1tUscpmwtV2bG" : zenLiteProduct.id,
|
||||
price: $app.stage === "dev" ? "price_1T3phhE7fOCwHSD4zS6w2NPy" : zenLitePrice.id,
|
||||
priceInr: 92900,
|
||||
firstMonth50Coupon: zenLiteCouponFirstMonth50.id,
|
||||
firstMonth100Coupon: zenLiteCouponFirstMonth100.id,
|
||||
threeMonths100Coupon: zenLiteCouponThreeMonths100.id,
|
||||
sixMonths100Coupon: zenLiteCouponSixMonths100.id,
|
||||
twelveMonths100Coupon: zenLiteCouponTwelveMonths100.id,
|
||||
},
|
||||
})
|
||||
|
||||
const zenBlackProduct = new stripe.Product("ZenBlack", {
|
||||
name: "OpenCode Black",
|
||||
})
|
||||
const zenBlackPriceProps = {
|
||||
product: zenBlackProduct.id,
|
||||
currency: "usd",
|
||||
recurring: {
|
||||
interval: "month",
|
||||
intervalCount: 1,
|
||||
},
|
||||
}
|
||||
const zenBlackPrice200 = new stripe.Price("ZenBlackPrice", { ...zenBlackPriceProps, unitAmount: 20000 })
|
||||
const zenBlackPrice100 = new stripe.Price("ZenBlack100Price", { ...zenBlackPriceProps, unitAmount: 10000 })
|
||||
const zenBlackPrice20 = new stripe.Price("ZenBlack20Price", { ...zenBlackPriceProps, unitAmount: 2000 })
|
||||
const ZEN_BLACK_PRICE = new sst.Linkable("ZEN_BLACK_PRICE", {
|
||||
properties: {
|
||||
product: zenBlackProduct.id,
|
||||
plan200: zenBlackPrice200.id,
|
||||
plan100: zenBlackPrice100.id,
|
||||
plan20: zenBlackPrice20.id,
|
||||
},
|
||||
})
|
||||
|
||||
const ZEN_MODELS = [
|
||||
new sst.Secret("ZEN_MODELS1"),
|
||||
new sst.Secret("ZEN_MODELS2"),
|
||||
new sst.Secret("ZEN_MODELS3"),
|
||||
new sst.Secret("ZEN_MODELS4"),
|
||||
new sst.Secret("ZEN_MODELS5"),
|
||||
new sst.Secret("ZEN_MODELS6"),
|
||||
new sst.Secret("ZEN_MODELS7"),
|
||||
new sst.Secret("ZEN_MODELS8"),
|
||||
new sst.Secret("ZEN_MODELS9"),
|
||||
new sst.Secret("ZEN_MODELS10"),
|
||||
new sst.Secret("ZEN_MODELS11"),
|
||||
new sst.Secret("ZEN_MODELS12"),
|
||||
new sst.Secret("ZEN_MODELS13"),
|
||||
new sst.Secret("ZEN_MODELS14"),
|
||||
new sst.Secret("ZEN_MODELS15"),
|
||||
new sst.Secret("ZEN_MODELS16"),
|
||||
new sst.Secret("ZEN_MODELS17"),
|
||||
new sst.Secret("ZEN_MODELS18"),
|
||||
new sst.Secret("ZEN_MODELS19"),
|
||||
new sst.Secret("ZEN_MODELS20"),
|
||||
new sst.Secret("ZEN_MODELS21"),
|
||||
new sst.Secret("ZEN_MODELS22"),
|
||||
new sst.Secret("ZEN_MODELS23"),
|
||||
new sst.Secret("ZEN_MODELS24"),
|
||||
new sst.Secret("ZEN_MODELS25"),
|
||||
new sst.Secret("ZEN_MODELS26"),
|
||||
new sst.Secret("ZEN_MODELS27"),
|
||||
new sst.Secret("ZEN_MODELS28"),
|
||||
new sst.Secret("ZEN_MODELS29"),
|
||||
new sst.Secret("ZEN_MODELS30"),
|
||||
]
|
||||
const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY")
|
||||
const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY")
|
||||
const AUTH_API_URL = new sst.Linkable("AUTH_API_URL", {
|
||||
properties: { value: auth.url.apply((url) => url!) },
|
||||
})
|
||||
// Preview branches have independent databases; do not send their workspaces to shared dev.
|
||||
// Preview stages have no new Console or inference deployment to forward to.
|
||||
const migrationDomain =
|
||||
$app.stage === "production" ? "opencode.ai" : $app.stage === "dev" ? "dev.opencode.ai" : undefined
|
||||
const consoleMigration = new sst.Linkable("ConsoleMigration", {
|
||||
@@ -229,16 +11,14 @@ const consoleMigration = new sst.Linkable("ConsoleMigration", {
|
||||
inferenceUrl: migrationDomain ? `https://${migrationDomain}/inference` : "",
|
||||
},
|
||||
})
|
||||
const STRIPE_WEBHOOK_SECRET = new sst.Linkable("STRIPE_WEBHOOK_SECRET", {
|
||||
properties: { value: stripeWebhook.secret },
|
||||
})
|
||||
|
||||
////////////////
|
||||
// CONSOLE
|
||||
////////////////
|
||||
|
||||
const bucket = new sst.cloudflare.Bucket("ZenData")
|
||||
const bucketNew = new sst.cloudflare.Bucket("ZenDataNew")
|
||||
// Archived Zen request/response dumps (writer removed May 2026). Retained on purpose; nothing reads or writes them.
|
||||
new sst.cloudflare.Bucket("ZenData")
|
||||
new sst.cloudflare.Bucket("ZenDataNew")
|
||||
|
||||
const DISCORD_INCIDENT_WEBHOOK_URL = new sst.Secret("DISCORD_INCIDENT_WEBHOOK_URL")
|
||||
const AWS_SES_ACCESS_KEY_ID = new sst.Secret("AWS_SES_ACCESS_KEY_ID")
|
||||
@@ -248,6 +28,10 @@ const ENTERPRISE_SALES_INBOX_EMAIL = new sst.Secret("ENTERPRISE_SALES_INBOX_EMAI
|
||||
const SALESFORCE_CLIENT_ID = new sst.Secret("SALESFORCE_CLIENT_ID")
|
||||
const SALESFORCE_CLIENT_SECRET = new sst.Secret("SALESFORCE_CLIENT_SECRET")
|
||||
const SALESFORCE_INSTANCE_URL = new sst.Secret("SALESFORCE_INSTANCE_URL")
|
||||
const CONSOLE_CRM_TOKEN = new sst.Secret("CONSOLE_CRM_TOKEN")
|
||||
const CONSOLE_CRM_LOOKUP_URL = new sst.Linkable("CONSOLE_CRM_LOOKUP_URL", {
|
||||
properties: { value: `https://${domain}/console/api/internal/console-account` },
|
||||
})
|
||||
|
||||
const logProcessor = new sst.cloudflare.Worker("LogProcessor", {
|
||||
handler: "packages/console/function/src/log-processor.ts",
|
||||
@@ -258,18 +42,9 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
domain,
|
||||
path: "packages/console/app",
|
||||
link: [
|
||||
bucket,
|
||||
bucketNew,
|
||||
database,
|
||||
SECRET.UpstashRedisRestUrl,
|
||||
SECRET.UpstashRedisRestToken,
|
||||
AUTH_API_URL,
|
||||
consoleMigration,
|
||||
STRIPE_WEBHOOK_SECRET,
|
||||
SECRET.SupportApiKey,
|
||||
DISCORD_INCIDENT_WEBHOOK_URL,
|
||||
SECRET.HoneycombWebhookSecret,
|
||||
STRIPE_SECRET_KEY,
|
||||
EMAILOCTOPUS_API_KEY,
|
||||
AWS_SES_ACCESS_KEY_ID,
|
||||
AWS_SES_SECRET_ACCESS_KEY,
|
||||
@@ -277,24 +52,12 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
SALESFORCE_CLIENT_ID,
|
||||
SALESFORCE_CLIENT_SECRET,
|
||||
SALESFORCE_INSTANCE_URL,
|
||||
ZEN_BLACK_PRICE,
|
||||
ZEN_LITE_PRICE,
|
||||
new sst.Secret("ZEN_LIMITS"),
|
||||
new sst.Secret("ZEN_SESSION_SECRET"),
|
||||
new sst.Secret("CLOUDFLARE_ACCESS_CLIENT_ID"),
|
||||
...ZEN_MODELS,
|
||||
...($dev
|
||||
? [
|
||||
new sst.Secret("CLOUDFLARE_DEFAULT_ACCOUNT_ID", process.env.CLOUDFLARE_DEFAULT_ACCOUNT_ID!),
|
||||
new sst.Secret("CLOUDFLARE_API_TOKEN", process.env.CLOUDFLARE_API_TOKEN!),
|
||||
]
|
||||
: []),
|
||||
CONSOLE_CRM_TOKEN,
|
||||
CONSOLE_CRM_LOOKUP_URL,
|
||||
],
|
||||
environment: {
|
||||
//VITE_DOCS_URL: web.url.apply((url) => url!),
|
||||
//VITE_API_URL: gateway.url.apply((url) => url!),
|
||||
VITE_AUTH_URL: auth.url.apply((url) => url!),
|
||||
VITE_STRIPE_PUBLISHABLE_KEY: STRIPE_PUBLISHABLE_KEY.value,
|
||||
VITE_CONSOLE_MARKETING_ENABLED: $app.stage === "dev" ? "true" : "false",
|
||||
},
|
||||
transform: {
|
||||
@@ -306,19 +69,15 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
...(flags ?? []),
|
||||
"global_fetch_strictly_public",
|
||||
])
|
||||
// Same-zone fetch to inference re-enters as a public request and loses the client IP.
|
||||
if (migrationDomain)
|
||||
args.bindings = $resolve(args.bindings).apply((bindings) => [
|
||||
...(bindings ?? []),
|
||||
{ type: "service", name: "INFERENCE", service: `opencode-inference-${$app.stage}` },
|
||||
])
|
||||
args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }]
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
////////////////
|
||||
// HELPERS
|
||||
////////////////
|
||||
|
||||
export const stat = new sst.cloudflare.Worker("Stat", {
|
||||
handler: "packages/console/function/src/stat.ts",
|
||||
link: [database],
|
||||
url: true,
|
||||
})
|
||||
@@ -10,6 +10,4 @@ export const SECRET = {
|
||||
HoneycombApiKey: new sst.Secret("HONEYCOMB_API_KEY"),
|
||||
HoneycombWebhookSecret: new random.RandomPassword("HoneycombWebhookSecret", { length: 24 }),
|
||||
SupportApiKey: new sst.Secret("SUPPORT_API_KEY"),
|
||||
UpstashRedisRestUrl: new sst.Secret("UpstashRedisRestUrl"),
|
||||
UpstashRedisRestToken: new sst.Secret("UpstashRedisRestToken"),
|
||||
}
|
||||
@@ -100,9 +100,17 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# Git Bash and MSYS2 run as emulated x64 on Windows ARM64, so `uname -m` reports x86_64.
|
||||
# The host CPU still shows in the `uname -s` suffix and in PROCESSOR_IDENTIFIER.
|
||||
if [ "$os" = "windows" ] && [ "$arch" = "x64" ]; then
|
||||
if [[ "$raw_os" == *-ARM64 || "${PROCESSOR_IDENTIFIER:-}" == ARM* ]]; then
|
||||
arch="arm64"
|
||||
fi
|
||||
fi
|
||||
|
||||
combo="$os-$arch"
|
||||
case "$combo" in
|
||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64)
|
||||
linux-x64|linux-arm64|darwin-x64|darwin-arm64|windows-x64|windows-arm64)
|
||||
;;
|
||||
*)
|
||||
echo -e "${RED}Unsupported OS/Arch: $os/$arch${NC}"
|
||||
@@ -235,17 +243,6 @@ check_version() {
|
||||
fi
|
||||
}
|
||||
|
||||
unbuffered_sed() {
|
||||
if echo | sed -u -e "" >/dev/null 2>&1; then
|
||||
sed -nu "$@"
|
||||
elif echo | sed -l -e "" >/dev/null 2>&1; then
|
||||
sed -nl "$@"
|
||||
else
|
||||
local pad="$(printf "\n%512s" "")"
|
||||
sed -ne "s/$/\\${pad}/" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
print_progress() {
|
||||
local bytes="$1"
|
||||
local length="$2"
|
||||
@@ -275,52 +272,34 @@ download_with_progress() {
|
||||
exec 4>/dev/null
|
||||
fi
|
||||
|
||||
local tmp_dir=${TMPDIR:-/tmp}
|
||||
local basename="${tmp_dir}/opencode_install_$$"
|
||||
local tracefile="${basename}.trace"
|
||||
|
||||
rm -f "$tracefile"
|
||||
mkfifo "$tracefile"
|
||||
local headers="${output}.headers"
|
||||
|
||||
# Hide cursor
|
||||
printf "\033[?25l" >&4
|
||||
|
||||
trap "trap - RETURN; rm -f \"$tracefile\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
|
||||
trap "trap - RETURN; rm -f \"$headers\"; printf '\033[?25h' >&4; exec 4>&-" RETURN
|
||||
|
||||
(
|
||||
curl --trace-ascii "$tracefile" -fsL -o "$output" "$url"
|
||||
) &
|
||||
# Poll the file size on a timer so rendering cost stays fixed no matter how many bytes arrive.
|
||||
# Read the size with ls because busybox wc -c reads the whole file.
|
||||
curl -fsL -D "$headers" -o "$output" "$url" &
|
||||
local curl_pid=$!
|
||||
|
||||
unbuffered_sed \
|
||||
-e 'y/ACDEGHLNORTV/acdeghlnortv/' \
|
||||
-e '/^0000: content-length:/p' \
|
||||
-e '/^<= recv data/p' \
|
||||
"$tracefile" | \
|
||||
{
|
||||
local length=0
|
||||
local bytes=0
|
||||
local length bytes
|
||||
while kill -0 "$curl_pid" 2>/dev/null; do
|
||||
if [ -f "$output" ]; then
|
||||
length=$(grep -i '^content-length:' "$headers" 2>/dev/null | tail -n 1 | tr -dc '0-9' || true)
|
||||
bytes=$(ls -ln "$output" | awk '{print $5}')
|
||||
print_progress "$bytes" "${length:-0}"
|
||||
fi
|
||||
sleep 0.1 2>/dev/null || sleep 1
|
||||
done
|
||||
|
||||
while IFS=" " read -r -a line; do
|
||||
[ "${#line[@]}" -lt 2 ] && continue
|
||||
local tag="${line[0]} ${line[1]}"
|
||||
|
||||
if [ "$tag" = "0000: content-length:" ]; then
|
||||
length="${line[2]}"
|
||||
length=$(echo "$length" | tr -d '\r')
|
||||
bytes=0
|
||||
elif [ "$tag" = "<= recv" ]; then
|
||||
local size="${line[3]}"
|
||||
bytes=$(( bytes + size ))
|
||||
if [ "$length" -gt 0 ]; then
|
||||
print_progress "$bytes" "$length"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
wait $curl_pid
|
||||
local ret=$?
|
||||
local ret=0
|
||||
wait "$curl_pid" || ret=$?
|
||||
if [ "$ret" -eq 0 ]; then
|
||||
bytes=$(ls -ln "$output" | awk '{print $5}')
|
||||
print_progress "$bytes" "$bytes"
|
||||
fi
|
||||
echo "" >&4
|
||||
return $ret
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-xUEdCdS+BbEWb2TsUdr088KLYLGqIOBdknGObo3xc+o=",
|
||||
"aarch64-linux": "sha256-f/MB/0TkxBmW1Q+w2qJe39jiOu5UUHX36VwRBYQWmjs=",
|
||||
"aarch64-darwin": "sha256-h9An+psN04P9QRfFpkvZByJPdoRrOSJUQIzYu+WJGd0="
|
||||
"x86_64-linux": "sha256-wmSDJ8NZOJyfSaBD8hnAAtbYrp3qnMS4eJzgFdyrhFc=",
|
||||
"aarch64-linux": "sha256-6WE4BHW11/gAQ75ktPbWU2Aw7uqivueMrElDAqu7QdE=",
|
||||
"aarch64-darwin": "sha256-6aAic4o1Zx6QiMk8IFU6lV7typClc3yXoWH7Qiq+hFs="
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.26",
|
||||
"version": "2.0.29",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -38,6 +38,7 @@
|
||||
"workspaces": {
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/plugins/*",
|
||||
"services/*",
|
||||
"packages/console/*",
|
||||
"packages/stats/*"
|
||||
@@ -68,7 +69,6 @@
|
||||
"@tsconfig/node22": "22.0.2",
|
||||
"@tsconfig/bun": "1.0.9",
|
||||
"@cloudflare/workers-types": "4.20251008.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@pierre/diffs": "1.5.1",
|
||||
"opentui-spinner": "0.0.7",
|
||||
"@solid-primitives/event-listener": "2.4.6",
|
||||
|
||||
+12
-316
@@ -1,324 +1,20 @@
|
||||
# AI Package Guide
|
||||
|
||||
## Effect
|
||||
## Principles
|
||||
|
||||
- Prefer `HttpClient.HttpClient` / `HttpClientResponse.HttpClientResponse` over web `fetch` / `Response` at package boundaries.
|
||||
- Use `Stream.Stream` for streaming data flow. Avoid ad hoc async generators or manual web reader loops unless an Effect `Stream` API cannot model the behavior.
|
||||
- Use Effect Schema codecs for JSON encode/decode (`Schema.fromJsonString(...)`) instead of direct `JSON.parse` / `JSON.stringify` in implementation code.
|
||||
- In `Effect.gen`, yield yieldable errors directly (`return yield* new MyError(...)`) instead of `Effect.fail(new MyError(...))`.
|
||||
- Use `Effect.void` instead of `Effect.succeed(undefined)` when the successful value is intentionally void.
|
||||
- **Forward compatibility.** Providers ship new models, options, and values constantly. Accept unknown pass-through values (`Known | (string & {})` for string options); use closed literals only when OpenCode branches on the value.
|
||||
- **One canonical model.** The Schema classes in `src/schema/` are the data model. Do not add a parallel shape or a second construction path.
|
||||
- **Independent of Session.** Auth, permissions, plugins, and orchestration belong in Core. `LLM.stream`/`LLM.generate` run exactly one model call.
|
||||
- **Reuse protocols.** Providers compose existing protocols with `Route.make(...)` instead of cloning them.
|
||||
|
||||
## Conventions
|
||||
## Code style
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
|
||||
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream`, and the same for `Video`, `Speech`, and `Transcription`. Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
|
||||
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.requireInlineMedia` / `inlineRequired` / `mediaUrl` / `mediaReference` and `MediaInput.inlineBytes` / `refID` rather than re-deriving base64 or URL handling.
|
||||
|
||||
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
|
||||
|
||||
Nothing in `src/*` except `src/promise.ts` may know about Promises. `@opencode/ai/promise` (`AI.make({ layer? })`, default `ai`) is the single Promise/`AsyncIterable` surface for LLM and media; it runs the Effect APIs in one `ManagedRuntime` and rethrows `AIError` unchanged.
|
||||
|
||||
- Prefer forward compatibility for provider-defined options that OpenCode only passes through. For pass-through string enums, expose known values for autocomplete while accepting future values with `Known | (string & {})`, and accept any string at runtime. Closed literals are appropriate when OpenCode branches on a value, transforms its associated structure, or otherwise cannot correctly handle an unknown variant. New options whose shape or behavior requires implementation remain unsupported until they are handled; do not blindly forward unknown structures.
|
||||
- Order reasoning-effort values from lowest to highest: `none`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max`. Provider-specific subsets follow the same relative order in types, schemas, option lists, and tests.
|
||||
- Use `HttpClient`, `Stream`, and Schema codecs instead of `fetch`, async generators, and `JSON.parse`/`JSON.stringify`.
|
||||
- `return yield* new MyError(...)` instead of `Effect.fail(...)`; `Effect.void` instead of `Effect.succeed(undefined)`.
|
||||
- Only `src/promise.ts` may use Promises.
|
||||
|
||||
## Tests
|
||||
|
||||
- Use `testEffect(...)` from `test/lib/effect.ts` for tests requiring Effect layers.
|
||||
- Keep provider tests fixture-first. Live provider calls must stay behind `RECORD=true` and required API-key checks.
|
||||
|
||||
## Errors
|
||||
|
||||
- `AIError` wraps a union of tagged reason errors. It stores only `reason`, derives `message` from the reason, and exposes the reason as its `cause`.
|
||||
- Each reason owns its readable `message`, category-specific fields, and optional `body`, `http`, and underlying exception in `cause`.
|
||||
- `reason.body` is the sole original-response or triggering-event payload field. Preserve original text before schema decoding removes fields; do not replace the complete event with only its nested error.
|
||||
- `reason.http` describes an observed HTTP response with required `url`, `status`, and response `headers`. Do not invent status codes or derive a separate request ID from headers.
|
||||
- Reclassification and transport recovery must preserve the reason's body, HTTP context, and underlying cause. Error `message` and `cause` are non-enumerable: copy them explicitly when constructing an enriched reason with its constructor or `AIErrorReason.make`.
|
||||
|
||||
## Architecture
|
||||
|
||||
This package is an Effect Schema-first LLM core. The Schema classes in `src/schema/` are the canonical runtime data model. Convenience functions in `src/llm.ts` are thin constructors that return those same Schema class instances; they should improve callsites without creating a second model.
|
||||
|
||||
Session integration lives in `packages/core/src/session`: `runner/llm.ts` owns orchestration, `model-request.ts` lowers Session state into `LLMRequest`, and `model-transport.ts` selects transport behavior.
|
||||
|
||||
Keep this package independent of Session concerns. Session auth, permissions, plugins, telemetry headers, and runtime selection belong in Core.
|
||||
|
||||
### Request Flow
|
||||
|
||||
The intended callsite is:
|
||||
|
||||
```ts
|
||||
const request = LLM.request({
|
||||
model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"),
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
|
||||
const response = yield * LLMClient.generate(request) // inside Effect.gen
|
||||
```
|
||||
|
||||
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
|
||||
|
||||
Use `LLMClient.stream(request)` when callers want incremental `LLMEvent`s. Use `LLMClient.generate(request)` when callers want those same events collected into an `LLMResponse`.
|
||||
|
||||
Filter or narrow `LLMEvent` streams with `LLMEvent.is.*` (camelCase guards, e.g. `events.filter(LLMEvent.is.toolCall)`). The kebab-case `LLMEvent.guards["tool-call"]` form also works but prefer `is.*` in new code.
|
||||
|
||||
### Routes
|
||||
|
||||
A route is the runnable composition of four orthogonal pieces:
|
||||
|
||||
- **`Protocol`** (`src/route/protocol.ts`) — semantic API contract. Owns request body construction (`body.from`), the body schema (`body.schema`), the streaming-event schema (`stream.event`), and the event-to-`LLMEvent` state machine (`stream.step`). `Route.make(...)` validates and JSON-encodes the body from `body.schema` and decodes frames with `stream.event`. Examples: `OpenAIChat.protocol`, `OpenResponses.protocol`, `OpenAIResponses.protocol`, `AnthropicMessages.protocol`, `Gemini.protocol`, `BedrockConverse.protocol`.
|
||||
- **`Endpoint`** (`src/route/endpoint.ts`) — URL construction. The host, path, and route query live on the endpoint. `Endpoint.path("/chat/completions", { baseURL })` is the common case; pass a function for paths that embed the model id or a body field (e.g. `Endpoint.path(({ body }) => `/model/${body.modelId}/converse-stream`)`).
|
||||
- **`Auth`** (`src/route/auth.ts`) — per-request transport authentication. Provider facades configure credentials onto the route before model selection, usually via `Auth.bearer(apiKey)` or `Auth.header(name, apiKey)`. Routes that need per-request signing (Bedrock SigV4, future Vertex IAM, Azure AAD) implement `Auth` as a function that signs the body and merges signed headers into the result.
|
||||
- **`Framing`** (`src/route/framing.ts`) — bytes → frames. SSE (`Framing.sse`) is shared; Bedrock keeps its AWS event-stream framing as a typed `Framing<object>` value alongside its protocol.
|
||||
|
||||
Compose them via `Route.make(...)`:
|
||||
|
||||
```ts
|
||||
export const route = Route.make({
|
||||
id: "openai-chat",
|
||||
provider: "openai",
|
||||
protocol: OpenAIChat.protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", {
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
}),
|
||||
auth: Auth.bearer(Auth.config("OPENAI_API_KEY")),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
```
|
||||
|
||||
Route defaults are request-shaping defaults such as `headers`, `limits`, `generation`, `providerOptions`, and `http`. Endpoint host/query belongs on the route endpoint. Selected `LanguageModel` values carry only model id, provider id, and the configured route value. Model capability/catalog metadata lives outside this package; protocol support is enforced by request lowering and typed `AIError`s.
|
||||
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider owns a small `Route.make(...)` composition instead of a protocol clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
|
||||
When a provider supports multiple physical transports, selection remains execution policy below its semantic route. `OpenResponsesChannel.transport(...)` owns the provider-neutral Responses WebSocket concept: it prepares one final request, executes HTTP by default, strips WebSocket-disallowed fields, and passes a generic channel exchange to a per-call `WebSocketChannelExecutor` when supplied. Provider-specific Responses routes opt in with handshake and connection-age policy. `Route.streamPrepared` owns decoding and acknowledges channel completion only after successful full consumption.
|
||||
|
||||
### Media Routes
|
||||
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
|
||||
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`; a `failed` generation maps the provider's error code through a per-protocol `FAILURE` table via `MediaProtocol.failure` so rejected inputs are not reported as retryable `ProviderInternal`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
|
||||
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
|
||||
|
||||
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram and ElevenLabs are inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
|
||||
|
||||
### URL Construction
|
||||
|
||||
`Endpoint` owns `{ baseURL, path, query }`. Each protocol route includes a canonical endpoint when the provider has one (e.g. `https://api.openai.com/v1`); provider helpers override endpoint fields by configuring the route before selecting a model. Generic OpenAI-compatible routes have no canonical URL and require configuration before execution.
|
||||
|
||||
For providers where the URL is derived from typed inputs (Azure resource name, Bedrock region), the provider helper configures the route endpoint before calling `.model(...)`. Use `AtLeastOne<T>` from `route/auth-options.ts` for inputs that accept either of two derivation paths (Azure: `resourceName` or `baseURL`).
|
||||
|
||||
### Provider Facades
|
||||
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, `.speech(id)`, `.transcription(id)`, `google.video(id)`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
|
||||
|
||||
```ts
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
const model = openai.responses("gpt-4o-mini")
|
||||
const image = openai.image("gpt-image-2")
|
||||
|
||||
const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" })
|
||||
const deployment = azure.responses("my-deployment")
|
||||
|
||||
const gateway = CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey })
|
||||
const proxied = gateway.model("openai/gpt-4o-mini")
|
||||
```
|
||||
|
||||
Keep provider facades small and explicit:
|
||||
|
||||
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
|
||||
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses` and `chat`.
|
||||
- Put provider-specific setup on `.configure(...)`; do not add `model(id, overrides)` as a duplicate construction path.
|
||||
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
|
||||
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
|
||||
- Resolve `apiKey` → `Auth` with `AuthOptions.bearer(options, "<PROVIDER>_API_KEY")` (it honors an explicit `auth` override and falls back to `Auth.config(envVar)` so missing keys surface a typed `Authentication` error rather than a runtime crash).
|
||||
- Use separate top-level facades for products with different required setup, such as `CloudflareAIGateway` and `CloudflareWorkersAI`.
|
||||
- Give every named provider its own file and top-level export. Keep its endpoint, auth defaults, and route setup in that file. Compose shared protocols directly; do not nest named provider presets under generic compatible facades or keep their endpoints in a shared provider profile registry.
|
||||
|
||||
`Provider.make(...)` remains available for simple static provider definitions, but new built-in providers should prefer plain configured facades unless a helper removes real duplication without adding runtime behavior.
|
||||
|
||||
### Provider Package Entrypoints
|
||||
|
||||
Catalog-selected native providers use package-like export paths from `@opencode/ai`. They are internal entrypoints in one npm package, not separately published provider packages. Every entrypoint implements `ProviderPackage.Definition` and exposes `model(modelID, settings)`, where settings are one flat serializable object: the connection keys the entrypoint declares (`apiKey`, `baseURL`, `region`, …), the common `headers` and `body` overlays, and the protocol's request options (`reasoningEffort`, `thinking`, …) side by side. Each entrypoint destructures its own connection keys and passes the rest to the route as `providerOptions`; there is no nested `providerOptions` at the entrypoint.
|
||||
|
||||
```ts
|
||||
import { model } from "@opencode/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
reasoningEffort: "high",
|
||||
})
|
||||
```
|
||||
|
||||
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Transport is execution policy: OpenAI Responses uses HTTP by default and may receive a per-call WebSocket channel executor through `StreamOptions` without changing model or route identity.
|
||||
|
||||
Do not expose `Route` in provider package settings. Route composition stays an implementation detail behind `model(...)`.
|
||||
|
||||
The dependency arrow points down: `providers/*.ts` files import protocol routes and auth-option utilities; protocol modules import `endpoint`, `auth`, `framing`, and transport pieces. Protocols do not import provider facades. Lower-level modules know nothing about provider catalog metadata. `OpenAIResponses` composes the provider-neutral `OpenResponses` protocol; the baseline never imports the OpenAI extension.
|
||||
|
||||
### Shared protocol helpers
|
||||
|
||||
`ProviderShared` exports a small toolkit used inside protocol implementations to keep them focused on provider-native shapes:
|
||||
|
||||
- `joinText(parts)` — joins an array of `TextPart` (or anything with a `.text`) with newlines. Use this anywhere a protocol flattens text content into a single string for a provider field.
|
||||
- `parseToolInput(route, name, raw)` — Schema-decodes a tool-call argument string with the canonical "Invalid JSON input for `<route>` tool call `<name>`" error message. Treats empty input as `{}`.
|
||||
- `parseJson(route, raw, message)` — generic JSON-via-Schema decode for non-tool bodies.
|
||||
- `eventError(route, message, ...)` — typed `InvalidProviderOutput` constructor for stream-time decode failures.
|
||||
- `validateWith(decoder)` — maps Schema decode errors to `InvalidRequest`. `Route.make(...)` uses this for body validation; lower-level routes can reuse it.
|
||||
- `matchToolChoice(provider, choice, branches)` — branches over `LLMRequest["toolChoice"]` for provider-specific lowering.
|
||||
|
||||
If you find yourself copying a 3-to-5-line snippet between two protocols, lift it into `ProviderShared` next to these helpers rather than duplicating.
|
||||
|
||||
### Chronological System Updates
|
||||
|
||||
`LLMRequest.system` is the initial privileged prompt that applies ahead of the conversation. `Message.system(...)` is a separate, provider-neutral chronological operator update inside `LLMRequest.messages`; it applies only from its position in history onward and accepts text content only.
|
||||
|
||||
Native chronological system messages are route/model-specific. Open Responses lowers them to standard `developer` messages, while Anthropic Messages lowers them to native system messages for Claude Opus 4.8 (`claude-opus-4-8`). Other routes and models intentionally lower the update in place into ordinary user-compatible text using this stable escaped representation:
|
||||
|
||||
```text
|
||||
<system-update>
|
||||
...
|
||||
</system-update>
|
||||
```
|
||||
|
||||
The wrapped-user fallback preserves ordering while visibly lowering authority. Never silently pass a raw chronological `role: "system"` through a route that might reject it. Do not insert raw retrieved documents, tool output, or web content into privileged chronological system updates; keep untrusted content in ordinary user/tool channels.
|
||||
|
||||
### Effort Updates
|
||||
|
||||
`Message.effort({ effort, previous })` is a chronological "reasoning effort changed here" marker (`undefined` means the model default). Changing a top-level effort invalidates the whole provider prompt cache, so protocols with a native per-message update (`Protocol.supportsEffortUpdates`) keep the top-level effort at the first marker's `previous` and lower each marker in place: Anthropic Messages emits an empty `role: "system"` message with `output_config.effort` plus the `mid-conversation-output-config-2026-07-01` beta, and OpenAI Responses emits `configuration_update` items. `applyEffortUpdates` runs in `prepareRequest` and strips the markers for every other route, so a protocol without support keeps today's plain top-level behaviour. When the last marker disagrees with the effort the request asks for (reverted or forked history), `resolveEffortUpdates` strips the markers and falls back to a plain top-level change.
|
||||
|
||||
### Tools
|
||||
|
||||
Tool loops are represented in common messages and events:
|
||||
|
||||
```ts
|
||||
const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })
|
||||
const result = Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } })
|
||||
|
||||
const followUp = LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Weather?"), Message.assistant([call]), result],
|
||||
})
|
||||
```
|
||||
|
||||
Routes lower these into provider-native assistant tool-call messages and tool-result messages. Streaming providers should emit `tool-input-delta` events while arguments arrive, then a final `tool-call` event with parsed input.
|
||||
|
||||
### Tool dispatch
|
||||
|
||||
`LLM.stream(request)` and `LLM.generate(request)` each run exactly one model call. Add tool schemas to `request.tools` with `Tool.toDefinitions(tools)`. When a caller wants the package's typed one-call execution behavior, pass each canonical local `tool-call` event to `ToolRuntime.dispatch(tools, call)`.
|
||||
|
||||
```ts
|
||||
const get_weather = Tool.make({
|
||||
description: "Get current weather for a city",
|
||||
parameters: Schema.Struct({ city: Schema.String }),
|
||||
success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }),
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const data = yield* WeatherApi.fetch(input.city)
|
||||
return { temperature: data.temp, condition: data.cond }
|
||||
}),
|
||||
})
|
||||
|
||||
const tools = { get_weather, get_time, ... }
|
||||
const events = yield* LLM.stream(
|
||||
LLMRequest.update(request, { tools: Tool.toDefinitions(tools) }),
|
||||
).pipe(Stream.runCollect)
|
||||
|
||||
const call = Array.from(events).find(LLMEvent.is.toolCall)
|
||||
if (call && !call.providerExecuted) {
|
||||
const dispatched = yield* ToolRuntime.dispatch(tools, call)
|
||||
// Persist call + dispatched.result, then construct the next request explicitly.
|
||||
}
|
||||
```
|
||||
|
||||
The dispatcher:
|
||||
|
||||
- On `tool-call`: looks up the named tool, decodes input against `parameters` Schema, dispatches to the typed `execute`, encodes the result against `success` Schema, and returns canonical `tool-result` events.
|
||||
- Does not stream providers, construct Session events, schedule fibers, append history, count steps, or continue model rounds.
|
||||
- Leaves persistence and continuation to the enclosing product flow.
|
||||
|
||||
Handler dependencies (services, permissions, plugin hooks, abort handling) are closed over by the consumer at tool-construction time. Build the tools record inside an `Effect.gen` once and reuse it across many dispatches.
|
||||
|
||||
Errors must be expressed as `ToolFailure`. The runtime catches it and emits a `tool-error` event, then a `tool-result` of `type: "error"`, so the model can self-correct on the next step. Anything that is not a `ToolFailure` is treated as a defect and fails the stream. Three recoverable error paths produce `tool-error` events:
|
||||
|
||||
- The model called an unknown tool name.
|
||||
- Input failed the `parameters` Schema.
|
||||
- The handler returned a `ToolFailure`.
|
||||
|
||||
Provider-defined / hosted tools (Anthropic `web_search` / `code_execution` / `web_fetch`, OpenAI Responses `web_search_call` / `file_search_call` / `code_interpreter_call` / `mcp_call` / `image_generation_call` / `computer_use_call`) pass through the runtime untouched:
|
||||
|
||||
- Routes surface the model's call as a `tool-call` event with `providerExecuted: true`, and the provider's result as a matching `tool-result` event with `providerExecuted: true`.
|
||||
- Callers detect `providerExecuted` on `tool-call` and **skip local dispatch** — no handler is invoked and no `tool-error` is raised for "unknown tool". The provider already executed it.
|
||||
- Callers that continue should retain both events in explicit history when the protocol requires it. Anthropic encodes them back as `server_tool_use` + `web_search_tool_result` (or `code_execution_tool_result` / `web_fetch_tool_result`) blocks; OpenAI Responses callers typically use `previous_response_id` instead of resending hosted-tool items.
|
||||
|
||||
Add provider-defined tools to `request.tools` (no runtime entry needed). The matching route must know how to lower the tool definition into the provider-native shape; right now Anthropic accepts `web_search` / `code_execution` / `web_fetch` and OpenAI Responses accepts the hosted tool names listed above.
|
||||
|
||||
## Protocol File Style
|
||||
|
||||
Protocol files should look self-similar. Provider quirks belong behind named helpers so a new route can be reviewed by comparing the same sections across files.
|
||||
|
||||
### Section order
|
||||
|
||||
Use this order for every protocol module:
|
||||
|
||||
1. Public model input
|
||||
2. Request body schema
|
||||
3. Streaming event schema
|
||||
4. Parser state
|
||||
5. Request body construction (`fromRequest`)
|
||||
6. Stream parsing (`step` and per-event handlers)
|
||||
7. Protocol and route
|
||||
8. Protocol route export
|
||||
|
||||
### Rules
|
||||
|
||||
- Keep protocol files focused on the protocol. Move provider-specific projection, signing, media normalization, or other bulky transformations into `src/protocols/utils/*`.
|
||||
- Send `tool.inputSchema` as given. `prepareRequest` applies the tool schema rules (`ToolSchemaProjection.tools`) once per request, including tools in namespaces. A protocol whose API needs a model family's rules for every model declares `sanitizer` instead of transforming schemas itself.
|
||||
- Use `Effect.fn("Provider.fromRequest")` for request body construction entrypoints. Use `Effect.fn(...)` for event handlers that yield effects; keep purely synchronous handlers as plain functions returning a `StepResult` that the dispatcher lifts via `Effect.succeed(...)`.
|
||||
- Parser state owns terminal information. The state machine records finish reason, usage, and pending tool calls; emit one terminal `finish` event (or `provider-error`) for each completed response. If a provider splits reason and usage across events, merge them in parser state before flushing.
|
||||
- Emit exactly one terminal `finish` event for a completed response, normally after a matching `step-finish`. Use `stream.terminal` to stop reading when the provider has a completion sentinel; use `stream.onHalt` when the final event must be flushed after the framed stream ends.
|
||||
- Use shared helpers for repeated protocol policy such as text joining, usage totals, JSON parsing, and tool-call accumulation. `ToolStream` (`protocols/utils/tool-stream.ts`) accumulates streamed tool-call arguments uniformly.
|
||||
- Make intentional provider differences explicit in helper names or comments. If two protocol files differ visually, the reason should be obvious from the names.
|
||||
- Prefer dispatched per-event handlers (`onMessageStart`, `onContentBlockDelta`, ...) called from a small top-level `step` switch over a long if-chain. The dispatcher keeps the event surface visible at a glance.
|
||||
- Keep tests in the same conceptual order as the protocol: basic prepare, tools prepare, unsupported lowering, text/usage parsing, tool streaming, finish reasons, provider errors.
|
||||
|
||||
### Review checklist
|
||||
|
||||
- Can the file be skimmed side-by-side with `openai-chat.ts` without hunting for equivalent sections?
|
||||
- Are provider quirks named, isolated, and covered by focused tests?
|
||||
- Does request body construction validate unsupported common content at the protocol boundary?
|
||||
- Does stream parsing emit stable common events without leaking provider event order to callers?
|
||||
- Does `toolChoice: "none"` behavior read as intentional?
|
||||
|
||||
## Recording Tests
|
||||
|
||||
Recorded tests use one cassette file per scenario. A cassette holds an ordered array of `{ request, response }` interactions, so multi-step flows (tool loops, retries, polling) record into a single file. Use `recordedTests({ prefix, requires })` and let the helper derive cassette names from test names:
|
||||
|
||||
```ts
|
||||
const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] })
|
||||
|
||||
recorded.effect("streams text", () =>
|
||||
Effect.gen(function* () {
|
||||
// test body
|
||||
}),
|
||||
)
|
||||
```
|
||||
|
||||
Replay is the default. `RECORD=true` records fresh cassettes locally and requires the listed env vars; unset `CI` before recording because CI always forces replay. Cassettes are written as pretty-printed JSON so multi-interaction diffs stay reviewable.
|
||||
|
||||
Pass `provider`, `protocol`, and optional `tags` to `recordedTests(...)` / `recorded.effect.with(...)` so cassettes carry searchable metadata. Use recorded-test filters to replay or record a narrow subset without rewriting a whole file:
|
||||
|
||||
- `RECORDED_PROVIDER=openai` matches tests tagged with `provider:openai`; comma-separated values are allowed.
|
||||
- `RECORDED_PREFIX=openai-chat` matches cassette groups by `recordedTests({ prefix })`; comma-separated values are allowed.
|
||||
- `RECORDED_TAGS=tool` requires all listed tags to be present, e.g. `RECORDED_TAGS=provider:togetherai,tool`.
|
||||
- `RECORDED_TEST="streams text"` matches by test name, kebab-case test id, or cassette path.
|
||||
|
||||
Filters apply in replay and record mode. Combine them with `RECORD=true` when refreshing only one provider or scenario.
|
||||
|
||||
**Binary response bodies.** Most providers stream text (SSE, JSON). The recorder treats known textual media types (`text/*`, JSON/XML structured types, JavaScript, forms, YAML, and SVG) as text and stores every other response as base64 with `bodyEncoding: "base64"`. This preserves binary formats such as AWS event-stream frames without a lossy UTF-8 round trip.
|
||||
|
||||
**Matching strategy.** A runtime request atomically claims the first unused recorded interaction that matches its method, URL, allow-listed headers, and canonical JSON body. Distinct requests may replay in any order or concurrently. Repeated identical requests consume their matching responses in cassette order, preserving deterministic retry and polling behavior. `scriptedResponses` (in `test/lib/http.ts`) is the deterministic counterpart for tests that don't need a live provider; it scripts response bodies in order without reading from disk.
|
||||
|
||||
Do not blanket re-record an entire test file when adding one cassette. `RECORD=true` rewrites every recorded case that runs, and provider streams contain volatile IDs, timestamps, fingerprints, and obfuscation fields. Prefer deleting the one cassette you intend to refresh, or run a focused test pattern that only registers the scenario you want to record. Keep stable existing cassettes unchanged unless their request shape or expected behavior changed.
|
||||
- Use `testEffect(...)` from `test/lib/effect.ts` for tests that need Effect layers.
|
||||
- Provider tests replay cassettes: recorded HTTP exchanges stored as JSON in `test/fixtures/recordings/`, one file per scenario. Replay is the default and needs no API keys.
|
||||
- To record, unset `CI` and run with `RECORD=true` plus the provider's API key. Re-record only the cassette you are changing; recordings contain volatile IDs that create noisy diffs.
|
||||
@@ -182,7 +182,7 @@ console.log(response.answers.department.choice)
|
||||
console.log(response.answers.refund.probability)
|
||||
```
|
||||
|
||||
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCodeZen` exposes the same selector and reads
|
||||
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCode` exposes the same selector and reads
|
||||
`OPENCODE_API_KEY`. OpenRouter and Vercel AI Gateway use the same provider shape:
|
||||
|
||||
```ts
|
||||
|
||||
@@ -1,484 +0,0 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
`@opencode/ai` becomes the one package you reach for to generate anything: text, images, video, speech, transcripts, and later music and realtime. The LLM surface already exists and is shaped by three constraints: Effect-first, used by OpenCode Core, usable externally. Media has a different priority order: **external DX first**, Effect and Promise as peers, Core as one consumer among many.
|
||||
|
||||
The design below is derived from a survey of the raw provider APIs (OpenAI, Gemini/Veo/Imagen, xAI, Stability, BFL, fal, Replicate, Runway, Luma, Kling, MiniMax, ElevenLabs, Deepgram, Cartesia, AssemblyAI, Lyria) and of existing multi-provider SDKs.
|
||||
|
||||
## What the survey forces
|
||||
|
||||
1. **Three execution shapes, everywhere.** Inline sync (OpenAI images, all TTS, Gemini), async job with polling or webhook (every video provider, BFL, fal, Replicate, AssemblyAI), and bidirectional streams (ElevenLabs/Cartesia/Deepgram WS, realtime). Video has no sync provider at all.
|
||||
2. **Output is never just bytes.** base64, signed URLs with TTLs from 10 minutes (BFL, so its route downloads before returning via `PollContext.materialize`) to 2 days (Veo), URLs that need auth plus redirect (Veo), separate download endpoints (Sora `/content?variant=`), raw bodies (Stability, TTS). Multi-output is the norm.
|
||||
3. **Inputs have roles.** First/last frame, mask, style/subject reference, source video for edit/extend, reference audio, prior generation id, provider-side file handles (`file_id`, `gs://`, `runway://`, `mm_file://`).
|
||||
4. **Partial streaming is modality-specific.** Images: a few whole partial frames. Audio: ordered chunks plus timestamp events. Jobs: status/progress/logs. Video: none.
|
||||
5. **Usage is a union**: tokens, seconds, characters (often only in headers), credits, compute time.
|
||||
6. **Moderation can be partial success** (Veo strips audio but returns video). Deprecations are constant (Sora API shuts down 2026-09-24; Imagen is shut down on the Gemini API and past its 2026-06-30 discontinuation date on Vertex).
|
||||
|
||||
## Where existing SDKs are weak and we should not be
|
||||
|
||||
- No streaming TTS.
|
||||
- Video handles are experimental start/status pairs; the polling loop lives inside the generate call.
|
||||
- Unsupported inputs become silent warnings arrays, so a request can succeed while dropping your mask.
|
||||
- `n` is fanned out into hidden parallel calls, which obscures cost and idempotency.
|
||||
- Each modality has its own bespoke result type; the file abstraction is a lazy base64/bytes pair with no URL, expiry, or provider ref.
|
||||
- Effect's own `unstable/ai` has no media generation. Nothing in the Effect ecosystem owns this.
|
||||
|
||||
## Design principles
|
||||
|
||||
- **Same shape as LLM.** `X.request(...)` → Schema class; `X.generate(request)` / `X.stream(request)`; `XClient.Service` + `layer`; typed `AIError`. If you know `LLM`, you know `Video`.
|
||||
- **Execution shape is route policy, not API shape.** `Image.generate` returns an image whether the provider is inline or queued. Job control is available uniformly when you want it.
|
||||
- **Errors, not warnings.** Unsupported common fields fail at the protocol boundary with a typed `AIError`, as the LLM routes do today. Provider-side partial results (filtered audio, moderated sample) surface as `notices` on the response, never as silent drops.
|
||||
- **One asset type in, one asset type out**, shared with LLM messages and tool results.
|
||||
- **Typed per-model options**, no hidden fan-out, no implicit retries that spend money.
|
||||
- **Promise API is one mechanism for the whole package**, not a media-only wrapper.
|
||||
- **One construction path per model.** Media models come from per-modality selectors on the configured facade (`openai.image("gpt-image-2")`), the same shape as `openai.responses("gpt-5")`.
|
||||
|
||||
## Public API
|
||||
|
||||
### Model selection
|
||||
|
||||
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `.image(id)`, `.video(id)`, `.speech(id)`, `.transcription(id)` on the facades that offer each — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
|
||||
|
||||
```ts
|
||||
import { OpenAI, Google } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey }) // OpenAI(...) alone uses env auth (OPENAI_API_KEY)
|
||||
|
||||
LLM.request({ model: openai.responses("gpt-5"), prompt })
|
||||
Image.request({ model: openai.image("gpt-image-2"), prompt })
|
||||
Video.request({ model: google.video("veo-3.1-generate-preview"), prompt })
|
||||
Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
|
||||
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
|
||||
```
|
||||
|
||||
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). The facade selector (`openai.image(id)`) is the public path for media models. Modality-specific package entrypoints (`model(modelID, settings)` beside today's LLM paths such as `@opencode/ai/providers/openai/responses`) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only `LanguageModel` today.
|
||||
|
||||
### `Media` — the asset type
|
||||
|
||||
Replaces `MediaPart.data: string | Uint8Array`, `ImageInput`, `GeneratedImage`, and aligns `Tool.FileContent`.
|
||||
|
||||
```ts
|
||||
import { Media } from "@opencode/ai"
|
||||
|
||||
Media.Source =
|
||||
| { type: "bytes"; data: Uint8Array; mediaType: string }
|
||||
| { type: "base64"; data: string; mediaType: string }
|
||||
| { type: "url"; url: string; mediaType?: string; expiresAt?: number }
|
||||
| { type: "ref"; provider: ProviderID; id: string; mediaType?: string } // file_id, gs://, runway://, prior generation
|
||||
|
||||
class Media.Asset {
|
||||
readonly source: Media.Source
|
||||
readonly mediaType: string // always resolved (sniffed when the provider omits it)
|
||||
readonly kind: "image" | "video" | "audio" | "document" | "other"
|
||||
readonly info?: { width?; height?; durationSeconds?; sampleRate?; channels?; encoding?; format? }
|
||||
readonly expiresAt?: number
|
||||
readonly providerMetadata?: ProviderMetadata
|
||||
readonly headers?: Record<string, string> // transient download credentials (Veo); never in source/JSON
|
||||
|
||||
bytes(): Effect<Uint8Array, AIError, RequestExecutor.Service> // downloads/decodes lazily, cached
|
||||
base64(): Effect<string, AIError, RequestExecutor.Service>
|
||||
dataUrl(): Effect<string, AIError, RequestExecutor.Service>
|
||||
materialize(): Effect<Media.Asset, AIError, RequestExecutor.Service> // url/ref → bytes, before the URL dies
|
||||
}
|
||||
|
||||
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
|
||||
Media.url(url, options?) Media.ref(provider, id)
|
||||
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
|
||||
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
|
||||
```
|
||||
|
||||
`Media.file` and `Media.write` stay Effect-only: bring your platform's `FileSystem` layer. The Promise client owns the
|
||||
runtime path: `ai.file(path)` and `ai.write(asset, path)` read and write through `node:fs/promises` (loaded on first
|
||||
use) with the same media-type sniffing and `InvalidRequest` failures, and `ai.bytes`, `ai.base64`, and
|
||||
`ai.materialize` run the asset methods in its runtime.
|
||||
|
||||
A `ref` source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
|
||||
yet, so `bytes()` and `materialize()` on a ref fail by design until a producer exists.
|
||||
|
||||
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
|
||||
|
||||
### Modality namespaces
|
||||
|
||||
Each namespace mirrors `LLM` exactly.
|
||||
|
||||
```ts
|
||||
import { Image, Video, Speech, Transcription } from "@opencode/ai"
|
||||
import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
|
||||
```
|
||||
|
||||
#### Image
|
||||
|
||||
```ts
|
||||
Effect.gen(function* () {
|
||||
const request = Image.request({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
images: [yield* Media.file("./ref.png")], // references / edit sources
|
||||
mask: yield* Media.file("./mask.png"),
|
||||
n: 2,
|
||||
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
|
||||
format: "webp",
|
||||
providerOptions: { quality: "high", background: "transparent" }, // typed per model
|
||||
})
|
||||
|
||||
const response = yield* Image.generate(request) // ImageResponse
|
||||
response.image // Media.Asset (first)
|
||||
response.images // Media.Asset[]
|
||||
response.usage // Usage union (see below)
|
||||
response.notices // moderation / partial-result notices
|
||||
|
||||
Image.stream(request) // Stream<ImageEvent>
|
||||
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
|
||||
})
|
||||
```
|
||||
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the portability table
|
||||
in the README's Image generation section.
|
||||
|
||||
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
|
||||
|
||||
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`, like every modality route. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
|
||||
|
||||
#### Video
|
||||
|
||||
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
|
||||
|
||||
```ts
|
||||
Effect.gen(function* () {
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
|
||||
references: [yield* Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url (expiresAt on Veo and Runway; transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none (xAI's usage.cost_in_usd_ticks is not decoded)
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; Veo and xAI succeed without a request
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
|
||||
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
})
|
||||
```
|
||||
|
||||
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
|
||||
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
|
||||
lowering per provider: Veo takes inline media only, rejects `audio: false` and `n > 1`, and requires `frames.first`
|
||||
when `frames.last` is set; xAI rejects `n`, `seed`, and `negativePrompt` and routes a `video` input to edits or
|
||||
(`providerOptions.mode: "extend"`) extensions; fal rejects `n`, plus `durationSeconds`, `references`, and `frames.last`
|
||||
because the field names and enums differ per model; Runway passes `aspectRatio` through as its pixel `ratio` and
|
||||
rejects `n`.
|
||||
|
||||
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
|
||||
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
|
||||
|
||||
#### Speech (TTS)
|
||||
|
||||
Shipped in phase 3 (`src/speech.ts`, `src/speech-client.ts`, protocols `openai-speech`, `google-speech`,
|
||||
`elevenlabs-speech`, `cartesia-speech`, `deepgram-speech`; new `ElevenLabs`, `Cartesia`, and `Deepgram` facades).
|
||||
|
||||
```ts
|
||||
const request = Speech.request({
|
||||
model: elevenlabs.speech("eleven_flash_v2_5"),
|
||||
text: "Hello from OpenCode.",
|
||||
voice: "JBFqnCBsd6RMkjVDRZzb", // provider-native identifier, or { id }
|
||||
format: "mp3", // mp3 | wav | pcm | opus | aac | flac | (string & {})
|
||||
speed: 1.0,
|
||||
language: "en",
|
||||
instructions: "Warm, unhurried.", // only OpenAI; elsewhere fails typed
|
||||
timestamps: true, // request alignment; routes without it fail typed
|
||||
providerOptions: { voice_settings: { stability: 0.5 } },
|
||||
})
|
||||
|
||||
const response = yield* Speech.generate(request) // SpeechResponse: audio: Media.Asset, timestamps?, usage?, providerMetadata?
|
||||
yield* Speech.stream(request) // Stream<SpeechEvent>: generation-queued | generation-progress | audio-delta { chunk } | timestamps { items } | finish { audio, usage? }
|
||||
```
|
||||
|
||||
Execution is `MediaProtocol.stream` for every provider: one request whose body is framed and folded by a `step`
|
||||
state machine, with `generate` running the same stream and collecting it. The route submits the request with its
|
||||
`mode` (`"generate" | "stream"`), which lets one provider stay one protocol — OpenAI adds `stream_format: "sse"` (except `tts-1`/`tts-1-hd`, which stream raw bytes), ElevenLabs appends
|
||||
`/stream`, Cartesia switches `/tts/bytes` to `/tts/sse`, Gemini switches `generateContent` to
|
||||
`streamGenerateContent`. The terminal `finish` event carries the assembled asset (every provider's stream is
|
||||
concatenable chunks), so stream consumers also get the whole file and `generate` is just "take `finish`, gather
|
||||
`timestamps`". The cost is memory: a stream holds every chunk until `finish`, so even a consumer that only plays deltas
|
||||
keeps the whole clip in memory. That is bounded by the providers' input text limits (a few minutes of audio); a
|
||||
long-form or session API would need an opt-out.
|
||||
|
||||
**Voice.** `voice?: string | { id: string }`. A string is passed through as the provider's native identifier — a
|
||||
name on OpenAI and Gemini, a voice id on ElevenLabs (path segment) and Cartesia. `{ id }` selects an OpenAI custom
|
||||
voice and is treated as the plain string on routes that do not distinguish custom from built-in. Deepgram's voice is
|
||||
the model id (`aura-2-thalia-en`), so `voice` is `unsupported` there. There is no cross-provider voice catalog or
|
||||
name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfig`) and per-voice settings
|
||||
(ElevenLabs `voice_settings`) go through `providerOptions`.
|
||||
|
||||
**Format and PCM.** `format` is container-level; provider sample rates and bitrates live under `providerOptions`
|
||||
(ElevenLabs `outputFormat`, Cartesia `sampleRate`/`bitRate`/`encoding`, Deepgram `encoding`/`container`/`sampleRate`/
|
||||
`bitRate`). Each protocol maps `format` to its native value (ElevenLabs `mp3_44100_128`/`pcm_24000`/`wav_24000`/
|
||||
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
|
||||
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
|
||||
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
|
||||
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini's asset follows the
|
||||
provider's declared type: WAV for Gemini 3.8 TTS `generate`, headerless PCM otherwise. The route never wraps PCM as WAV,
|
||||
so `pcm` is the only explicit `format` it accepts, and not on Gemini 3.8 `generate`. Every `format` value a route cannot
|
||||
produce (unknown to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but `pcm` on Gemini, `pcm` on
|
||||
Gemini 3.8 `generate`) fails the same way as an unsupported field: `UnsupportedOperation` with
|
||||
`operation: "media.format"`.
|
||||
|
||||
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
|
||||
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
|
||||
`generate` with timestamps collects the SSE stream). OpenAI, Gemini, and Deepgram reject it.
|
||||
|
||||
Common-field lowering per provider:
|
||||
|
||||
| Provider | `voice` | `speed` | `language` | `instructions` | `timestamps` | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | `voice` (name or `{ id }`) | `speed` | unsupported | `instructions` | unsupported | `tokens` from SSE `speech.audio.done` only |
|
||||
| Gemini | `prebuiltVoiceConfig.voiceName` | unsupported | `speechConfig.languageCode` | unsupported (direct in text) | unsupported | `tokens` from `usageMetadata` |
|
||||
| ElevenLabs | path voice id (required) | `voice_settings.speed` | `language_code` | unsupported | `with-timestamps` | `credits` from `character-cost` header |
|
||||
| Cartesia | `voice` (required) | `generation_config.speed` | `language` | unsupported | `add_timestamps` | none |
|
||||
| Deepgram | unsupported (voice is the model) | `speed` query | unsupported | unsupported | unsupported | `characters` from `dg-char-count` header |
|
||||
|
||||
Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incrementally over a WebSocket (ElevenLabs
|
||||
`stream-input`, Cartesia WebSocket contexts, Deepgram WebSocket speak) — is a separate scoped resource, not part of
|
||||
`generate`/`stream`, and ships with the realtime work in phase 5.
|
||||
|
||||
#### Transcription (STT)
|
||||
|
||||
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
|
||||
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `elevenlabs-transcription`,
|
||||
`assemblyai-transcription`; new `AssemblyAI` facade).
|
||||
|
||||
```ts
|
||||
const request = Transcription.request({
|
||||
model: openai.transcription("gpt-4o-transcribe-diarize"),
|
||||
audio: yield* Media.file("./call.wav"),
|
||||
language: "en", // provider-native passthrough
|
||||
timestamps: "segment", // none | segment | word
|
||||
diarize: true,
|
||||
speakers: 2, // speaker count (AssemblyAI exact, ElevenLabs maximum)
|
||||
providerOptions: { known_speaker_names: ["agent"] },
|
||||
})
|
||||
|
||||
const response = yield* Transcription.generate(request)
|
||||
response.text; response.segments; response.words; response.language; response.durationSeconds; response.usage
|
||||
yield* Transcription.stream(request) // Stream<TranscriptionEvent>: generation-queued | generation-progress | text-delta | segment | finish
|
||||
const generation = yield* Transcription.start(request) // queued routes only
|
||||
yield* Transcription.resume(model, token)
|
||||
```
|
||||
|
||||
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
|
||||
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
|
||||
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
|
||||
`<Options, Frame, State>`, `<Options, Token>`) and composes through the shared `composeRoute` (`src/media-model.ts`),
|
||||
which picks `MediaRoute.inline` / `stream` / `queued`; and `TranscriptionClient`, like every modality client, is
|
||||
`MediaClient.make` (`src/media-client.ts`), which dispatches on `route.kind`. `generate` on a queued route is `start`
|
||||
then `await`; `stream` on an inline route is the response as a single `finish`, and on a queued route it is the status
|
||||
observations followed by `finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
|
||||
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
|
||||
`collect` is just "take `finish`".
|
||||
|
||||
The route layer gained a `binary` body with array-valued `query` (Deepgram) and `Queued.start.prepare` (AssemblyAI's
|
||||
upload); `packages/ai/AGENTS.md` (Media Routes) describes both.
|
||||
|
||||
Settled rules:
|
||||
|
||||
- **Timestamps.** A granularity the selected route or model cannot produce fails as `UnsupportedOperation`
|
||||
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram, ElevenLabs, and AssemblyAI
|
||||
always return words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
|
||||
word offsets, so segment timestamps and diarization also request word offsets there.
|
||||
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
|
||||
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A`,
|
||||
ElevenLabs `speaker_0` — with no cross-provider speaker model. `speakers` is the number of speakers to label:
|
||||
AssemblyAI (`speakers_expected`) treats it as an exact constraint rather than a hint, and ElevenLabs
|
||||
(`num_speakers`) as the maximum. Both turn on diarization for it; the other routes reject it.
|
||||
- **Segments from words.** ElevenLabs returns only a token list (`word`, `spacing`, `audio_event`), so its segments
|
||||
are speaker turns: consecutive words and spacing with one `speaker_id`, text joined from the provider's own spacing
|
||||
tokens. `words` drops spacing and audio events. Segments therefore need diarization, which `timestamps: "segment"`
|
||||
turns on, as AssemblyAI's utterances need speaker labels.
|
||||
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
|
||||
AssemblyAI and ElevenLabs `language_code`). `response.language` is the provider's own value, lowercased but not
|
||||
normalized: an ISO code on most routes (AssemblyAI's detection returns `en`, ElevenLabs ISO 639-3 `eng`), `english`
|
||||
from whisper-1. Deepgram and AssemblyAI assume English unless asked to detect, so a missing `language` enables their
|
||||
detection.
|
||||
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
|
||||
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
|
||||
turns (one part per turn), which join with a space.
|
||||
- **Streaming inline providers** emit only `finish`; deltas are never faked.
|
||||
- **Units.** AssemblyAI milliseconds and Gemini protobuf durations (`"0.400s"`) are normalized to seconds at the
|
||||
protocol boundary.
|
||||
|
||||
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
|
||||
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
|
||||
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
|
||||
| ElevenLabs | inline | multipart `file`, or `source_url` | words always; `segment` → `diarize` (speaker turns) | `diarize` | `prompt`; `webhook`, per-channel `use_multi_channel` | `seconds` (`audio_duration_secs`) |
|
||||
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
|
||||
|
||||
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
|
||||
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
|
||||
with the realtime work in phase 5.
|
||||
|
||||
### `Generation` — shared async execution
|
||||
|
||||
```ts
|
||||
class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly route: GenerationRoute<Response> // token-free: { status, result, cancel?: Effect } closed over the decoded token
|
||||
readonly token: unknown // route-owned serializable JSON
|
||||
readonly status: "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
readonly progress?: number // 0..1, normalized
|
||||
readonly position?: number
|
||||
refresh(): Effect<Generation<Response>, AIError>
|
||||
result(): Effect<Response, AIError>
|
||||
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
|
||||
cancel(): Effect<void, AIError>
|
||||
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
}
|
||||
|
||||
GenerationAwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration }
|
||||
```
|
||||
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
|
||||
Status polls and result reads retry transient failures (rate limits, provider 5xx, and transport errors, classified by the same `isRetryable` the Session runner uses) inside `MediaRoute.queued`. Only the HTTP exchange retries, never the decoded document: a terminal `failed` generation also surfaces as `ProviderInternal` and must not be re-read. Gaps grow exponentially from 1s with jitter, up to 30s each, honoring a provider `retry-after` up to that cap, for at most 8 retries. `await`, `events`, and `Video.stream` cut retries off at `poll.timeout` and fail with `Timeout`, so retries never extend the caller's deadline; a direct `result()` or `resume` read is bounded by the retry cap alone. `start` and `cancel` never retry: a repeated submit can start and bill a second job. The policy is internal; there is no option for it.
|
||||
|
||||
Interrupting `await`, `events`, or `Video.stream` (or aborting the promise API's `signal`) stops waiting only. The provider job keeps running and billing; call `cancel()` explicitly to stop it.
|
||||
|
||||
### Usage
|
||||
|
||||
```ts
|
||||
Usage =
|
||||
| { type: "tokens"; input; output; total; details? }
|
||||
| { type: "seconds"; seconds }
|
||||
| { type: "characters"; characters }
|
||||
| { type: "credits"; credits }
|
||||
| { type: "compute"; seconds }
|
||||
```
|
||||
|
||||
Header-only usage (ElevenLabs `character-cost`, Deepgram `dg-char-count`) is lifted into `usage` by the route.
|
||||
|
||||
### Promise API — `@opencode/ai/promise`
|
||||
|
||||
Mirrors the `packages/plugin/src/effect` and `packages/plugin/src/promise` split that already exists in this repo. One mechanism for LLM and media.
|
||||
|
||||
```ts
|
||||
import { AI } from "@opencode/ai/promise"
|
||||
|
||||
const ai = AI.make() // ManagedRuntime over RequestExecutor.fetchLayer + all clients
|
||||
// AI.make({ layer }) to inject a custom executor / recorder / middleware
|
||||
|
||||
const image = await ai.image.generate({ model, prompt })
|
||||
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
|
||||
const reference = await ai.file("./ref.png")
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text, voice })) { … }
|
||||
|
||||
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
|
||||
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
|
||||
|
||||
const request = ai.llm.request({ model, prompt })
|
||||
const text = await ai.llm.generate(request)
|
||||
for await (const event of ai.llm.stream(request)) { … }
|
||||
|
||||
await ai.dispose()
|
||||
```
|
||||
|
||||
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. Aborting an `AbortSignal` interrupts the work and, like `fetch`, rejects the Promise or throws from the stream with `signal.reason` instead of ending the stream as if complete. Nothing in `src/*` except this entrypoint knows about promises.
|
||||
|
||||
### Providers
|
||||
|
||||
Existing facades gain per-modality selectors; the modality routes each facade provides (*italics* are not
|
||||
implemented):
|
||||
|
||||
| Facade | llm | image | video | speech | transcription | other |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
|
||||
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
|
||||
| `XAI` | ✓ | ✓ | ✓ | | | |
|
||||
| `ElevenLabs` | | | | ✓ | Scribe | *soundEffect, music (phase 5)* |
|
||||
| `Cartesia` | | | | ✓ | | |
|
||||
| `Deepgram` | | | | Aura | ✓ | |
|
||||
| `Fal` | | ✓ (queued) | ✓ | | | |
|
||||
| `AssemblyAI` | | | | | ✓ (queued) | |
|
||||
| `BlackForestLabs` | | ✓ (queued) | | | | |
|
||||
| `Replicate` | | ✓ (queued) | | | | |
|
||||
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
|
||||
| `Runway` | | | ✓ | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | *deferred* | *deferred* | | | |
|
||||
|
||||
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
|
||||
|
||||
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. As with `LanguageModel`, the route type does not carry `Options`, so `ImageModel<OpenAIImageOptions>` is an `ImageModel` and client methods take plain `ImageRequestFor`. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
|
||||
|
||||
### Routes and protocols
|
||||
|
||||
Media does not fit the LLM four-axis route (SSE frames → event state machine) except for streaming TTS/STT. Reuse `Endpoint`, `Auth`, `Framing`, `RequestExecutor`, and add media protocol kinds:
|
||||
|
||||
- `MediaProtocol.inline` — `body.from(request)` (JSON, multipart, or query), `response.decode(response)` (JSON, or binary body → `Media.Asset`).
|
||||
- `MediaProtocol.queued` — `start` (body + decode to `{ token, snapshot }`), `status`, `result`, optional `cancel`, and a `token` codec. `result` is always a separate GET (against the status document for Veo/xAI/Runway, fal's `response_url` otherwise) so `await` after `start` and after `resume` share one path. `PollContext.auth` hands the auth headers the route sent to the protocol for output URLs that need them (Veo downloads); they become transient `Media.Asset.headers`, never part of `source`. There is no separate `download` step: `Media.Asset.bytes()` downloads through the executor with those headers. `MediaRoute.inline(...)` / `MediaRoute.queued(...)` compose each kind with endpoint and auth; the queued route decodes the token once and hands `Generation` a token-free `{ status, result, cancel? }`.
|
||||
- `MediaProtocol.stream` — `body.from(request)` over the request plus its `mode`, `frames` (a function that picks the framing for the call: `Framing.sse`, `lines`, `document`, or the raw bytes), fresh per-response `initial()` state, `step` emitting modality events, and `finish(state, context)` — with the observed response for header-only usage — emitting exactly one terminal event or failing as an incomplete stream. The route fills `reason.http` on stream errors. `MediaRoute.stream(...)` exposes `stream` and `generate` (the same stream folded by the modality's `collect`).
|
||||
|
||||
`MediaRoute.inline` / `MediaRoute.queued` / `MediaRoute.stream` compose one protocol kind with endpoint/auth and tag the route with its `kind`; `ImageModel`/`VideoModel`/`SpeechModel`/`TranscriptionModel` share the `MediaModel` base (`src/media-model.ts`).
|
||||
|
||||
### LLM integration
|
||||
|
||||
- `MediaPart` becomes `{ type: "media"; media: Media.Asset; … }` so protocols branch on `kind` and can pass `url`/`ref` sources through natively (OpenAI `image_url`, Gemini `fileData`).
|
||||
- New `LLMEvent`s: `media { media: Media.Asset }` so Gemini inline image output is first-class instead of dropped. OpenAI Responses `image_generation_call` keeps its single carrier — the provider-executed `tool-result` with `file` content — because Core consumes hosted tool-result content today and has no `media` event handling yet; it switches to the `media` carrier when Core adopts the event, so the image is never emitted twice.
|
||||
- `Message.assistant([...])` accepts media parts; Gemini multi-turn image editing replays them.
|
||||
- `Tool.FileContent` aligns with `Media.Source`.
|
||||
|
||||
## Decisions
|
||||
|
||||
All settled:
|
||||
|
||||
1. **Per-modality selectors** (`openai.image(id)`, `.video`, `.speech`, `.transcription`) name media models, mirroring `openai.responses(id)`. The one-word overlap with the request namespace is accepted over a callable-facade `ModelRef` as a second construction path.
|
||||
2. **`providerOptions` everywhere** (rename current `Image.options`) for consistency with LLM.
|
||||
3. **No hidden `n` fan-out.** `n` lowers natively; routes that cannot do `n > 1` fail typed. Callers use `Effect.all` / `Promise.all` explicitly.
|
||||
4. **Errors over warnings** for unsupported common fields; `notices` for provider-side partial results only.
|
||||
5. **`Media.Asset` is a class** (lazy bytes, cached) with `Media.Source` as the serializable Schema for wire/persistence. `Asset.from(source)` / `asset.source` round-trip losslessly. Same pattern as `LanguageModel` today.
|
||||
6. **Promise entrypoint**: `@opencode/ai/promise` exporting `AI.make(options?: { layer? })` plus a module-level default `ai` for scripts, covering LLM too.
|
||||
7. **Modality set for v1**: `Image`, `Video`, `Speech`, `Transcription`. `Music`/`SoundEffect` and `session` (bidirectional WS, realtime) are designed-for but deferred.
|
||||
8. **Sora is skipped** (API shuts down 2026-09-24). Video launches with Veo, xAI, fal, Runway.
|
||||
|
||||
## Build order
|
||||
|
||||
Foundation + Image ship together as the reference implementation, serially. Video, Speech, and Transcription then proceed in parallel on separate branches. Image jobs and partial streaming come last, after Video has hardened `Generation`.
|
||||
|
||||
## Phasing
|
||||
|
||||
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
|
||||
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
|
||||
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, ElevenLabs Scribe, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
|
||||
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints.
|
||||
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
|
||||
|
||||
Core adoption (session attachments beyond png/jpeg/gif/webp/pdf, image-generation tool, TUI rendering) comes after phase 1 and is a Core concern.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.26",
|
||||
"version": "2.0.29",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -171,17 +171,7 @@ const countHints = (request: LLMRequest) =>
|
||||
)
|
||||
|
||||
export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
const route = request.model.route.id
|
||||
if (!RESPECTS_INLINE_HINTS.has(route)) return request
|
||||
const policy =
|
||||
(route === "openrouter" || route === "vercel-ai-gateway-messages") &&
|
||||
(request.cache === undefined || request.cache === "auto")
|
||||
? gatewayPolicy(request.model.id)
|
||||
: route === "alibaba-chat" && (request.cache === undefined || request.cache === "auto")
|
||||
? request.model.id.toLowerCase().startsWith("qwen")
|
||||
? QWEN
|
||||
: NONE
|
||||
: resolve(request.cache)
|
||||
const policy = selectPolicy(request)
|
||||
if (!policy.tools && !policy.system && !policy.messages) return request
|
||||
|
||||
const hint = makeHint(policy.ttlSeconds)
|
||||
@@ -193,3 +183,17 @@ export const applyCachePolicy = (request: LLMRequest): LLMRequest => {
|
||||
if (tools === request.tools && system === request.system && messages === request.messages) return request
|
||||
return LLMRequest.update(request, { tools, system, messages })
|
||||
}
|
||||
|
||||
// A declared cache format overrides the route allowlist and its model-ID heuristics.
|
||||
const selectPolicy = (request: LLMRequest): CachePolicyObject => {
|
||||
const format = request.model.compatibility?.cacheFormat
|
||||
const route = request.model.route.id
|
||||
const auto = request.cache === undefined || request.cache === "auto"
|
||||
if (format === "alibaba") return auto ? QWEN : { ...resolve(request.cache), tools: false }
|
||||
if (format === "anthropic") return resolve(request.cache)
|
||||
if (!RESPECTS_INLINE_HINTS.has(route)) return NONE
|
||||
if (!auto) return resolve(request.cache)
|
||||
if (route === "openrouter" || route === "vercel-ai-gateway-messages") return gatewayPolicy(request.model.id)
|
||||
if (route === "alibaba-chat") return request.model.id.toLowerCase().startsWith("qwen") ? QWEN : NONE
|
||||
return AUTO
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Effect, Option, Schema } from "effect"
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import {
|
||||
HttpTransport,
|
||||
type Transport,
|
||||
type TransportExecution,
|
||||
type TransportPrepareInput,
|
||||
} from "../route/transport/index.js"
|
||||
import {
|
||||
AIError,
|
||||
LLMEvent,
|
||||
@@ -25,6 +30,7 @@ import { GeminiGenerateContent } from "./utils/gemini-generate-content.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
|
||||
const ADAPTER = "gemini"
|
||||
const CONTINUATION = "CONTINUATION"
|
||||
// Google documents this sentinel for replaying Gemini 3 function calls after their original signature was lost.
|
||||
const SKIP_THOUGHT_SIGNATURE_VALIDATOR = "skip_thought_signature_validator"
|
||||
// Gemini 2.5 rejects a budget under the model's minimum: 512 on Flash-Lite, the highest, and 128 on Pro.
|
||||
@@ -212,6 +218,7 @@ const GeminiBodyFields = {
|
||||
tools: optionalArray(GeminiTool),
|
||||
toolConfig: Schema.optional(GeminiToolConfig),
|
||||
generationConfig: Schema.optional(GeminiGenerationConfig),
|
||||
continuationToken: Schema.optional(Schema.String),
|
||||
}
|
||||
const GeminiBody = Schema.Struct(GeminiBodyFields)
|
||||
export type GeminiBody = Schema.Schema.Type<typeof GeminiBody>
|
||||
@@ -254,7 +261,9 @@ interface ParserState {
|
||||
readonly finishReason?: string
|
||||
readonly hasToolCalls: boolean
|
||||
readonly promptFeedback?: GeminiPromptFeedback
|
||||
readonly usage?: Usage
|
||||
readonly usage?: GeminiUsage
|
||||
/** Usage of the earlier requests of a response Gemini continued across requests. */
|
||||
readonly continuedUsage?: ReadonlyArray<GeminiUsage>
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningSignature?: string
|
||||
readonly textSignature?: string
|
||||
@@ -497,7 +506,11 @@ const fromRequest = Effect.fn("Gemini.fromRequest")(function* (request: LLMReque
|
||||
// `cachedContentTokenCount` subset. `candidatesTokenCount` is *exclusive*
|
||||
// of `thoughtsTokenCount` — visible-only, not a total — so we sum the two
|
||||
// to produce the inclusive `outputTokens` the rest of the contract expects.
|
||||
const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
|
||||
const mapUsage = (
|
||||
usage: GeminiUsage | undefined,
|
||||
metadataKey: string,
|
||||
metadata: Record<string, unknown> = usage ?? {},
|
||||
) => {
|
||||
if (!usage) return undefined
|
||||
// Explicit provider nulls decode as `null`; normalize to `undefined` so the
|
||||
// token arithmetic below treats them like absent counts.
|
||||
@@ -518,10 +531,35 @@ const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
|
||||
cacheReadInputTokens: cached,
|
||||
reasoningTokens: thoughts,
|
||||
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
|
||||
providerMetadata: providerMetadata(metadataKey, usage),
|
||||
providerMetadata: providerMetadata(metadataKey, metadata),
|
||||
})
|
||||
}
|
||||
|
||||
// Each continued request re-bills the output so far as input. Reporting the first request's input with all output keeps
|
||||
// input plus output equal to the response's final context size, under-reporting billed input; the summed billed counts
|
||||
// remain in provider metadata.
|
||||
const mapContinuedUsage = (requests: ReadonlyArray<GeminiUsage>, metadataKey: string) => {
|
||||
const sum = (field: keyof GeminiUsage) =>
|
||||
ProviderShared.sumTokens(...requests.map((usage) => usage[field] ?? undefined))
|
||||
return mapUsage(
|
||||
{
|
||||
promptTokenCount: requests[0].promptTokenCount,
|
||||
cachedContentTokenCount: requests[0].cachedContentTokenCount,
|
||||
candidatesTokenCount: sum("candidatesTokenCount"),
|
||||
thoughtsTokenCount: sum("thoughtsTokenCount"),
|
||||
},
|
||||
metadataKey,
|
||||
{
|
||||
promptTokenCount: sum("promptTokenCount"),
|
||||
cachedContentTokenCount: sum("cachedContentTokenCount"),
|
||||
candidatesTokenCount: sum("candidatesTokenCount"),
|
||||
thoughtsTokenCount: sum("thoughtsTokenCount"),
|
||||
totalTokenCount: sum("totalTokenCount"),
|
||||
requests: requests.length,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const mapFinishReason = (finishReason: string | undefined, hasToolCalls: boolean): FinishReason => {
|
||||
if (finishReason === undefined) return hasToolCalls ? "tool-calls" : "unknown"
|
||||
if (finishReason === "STOP") return hasToolCalls ? "tool-calls" : "stop"
|
||||
@@ -545,7 +583,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
const promptBlockReason =
|
||||
state.finishReason === undefined ? (state.promptFeedback?.blockReason ?? undefined) : undefined
|
||||
const finishReason = state.finishReason ?? promptBlockReason
|
||||
if (finishReason === undefined && state.usage === undefined) return []
|
||||
if (finishReason === undefined && state.usage === undefined && state.continuedUsage === undefined) return []
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
let lifecycle = state.lifecycle
|
||||
@@ -573,7 +611,10 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
promptBlockReason === undefined ? mapFinishReason(finishReason, state.hasToolCalls) : "content-filter",
|
||||
raw: finishReason,
|
||||
},
|
||||
usage: state.usage,
|
||||
usage:
|
||||
state.continuedUsage === undefined
|
||||
? mapUsage(state.usage, state.providerMetadataKey)
|
||||
: mapContinuedUsage([...state.continuedUsage, state.usage ?? {}], state.providerMetadataKey),
|
||||
providerMetadata:
|
||||
state.promptFeedback === undefined
|
||||
? undefined
|
||||
@@ -582,7 +623,19 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
|
||||
return events
|
||||
}
|
||||
|
||||
const step = (state: ParserState, event: GeminiEvent) => {
|
||||
const step = (state: ParserState, event: GeminiEvent | NextRequest) => {
|
||||
// Usage is cumulative within one request, so the next request of the same response counts from zero.
|
||||
if (event instanceof NextRequest)
|
||||
return Effect.succeed([
|
||||
{
|
||||
...state,
|
||||
finishReason: undefined,
|
||||
promptFeedback: undefined,
|
||||
usage: undefined,
|
||||
continuedUsage: [...(state.continuedUsage ?? []), state.usage ?? {}],
|
||||
},
|
||||
[],
|
||||
] as const)
|
||||
if (ProviderShared.isRecord(event.error)) {
|
||||
const body = ProviderShared.encodeJson(event)
|
||||
return Effect.fail(
|
||||
@@ -604,14 +657,13 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(state.route, `Invalid ${state.route} stream event`, ProviderShared.encodeJson(event)),
|
||||
)
|
||||
const candidate = event.candidates?.[0]
|
||||
const nextState = {
|
||||
...state,
|
||||
promptFeedback: event.promptFeedback ?? state.promptFeedback,
|
||||
usage: event.usageMetadata
|
||||
? (mapUsage(event.usageMetadata, state.providerMetadataKey) ?? state.usage)
|
||||
: state.usage,
|
||||
usage: event.usageMetadata ?? state.usage,
|
||||
finishReason: candidate?.finishReason ?? state.finishReason,
|
||||
}
|
||||
const candidate = event.candidates?.[0]
|
||||
if (candidate?.finishReason && mapFinishReason(candidate.finishReason, state.hasToolCalls) === "error")
|
||||
return Effect.fail(
|
||||
ProviderShared.eventError(
|
||||
@@ -620,11 +672,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
ProviderShared.encodeJson(event),
|
||||
),
|
||||
)
|
||||
if (!candidate?.content)
|
||||
return Effect.succeed([
|
||||
{ ...nextState, finishReason: candidate?.finishReason ?? nextState.finishReason },
|
||||
[],
|
||||
] as const)
|
||||
if (!candidate?.content) return Effect.succeed([nextState, []] as const)
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
let hasToolCalls = nextState.hasToolCalls
|
||||
@@ -784,12 +832,104 @@ const step = (state: ParserState, event: GeminiEvent) => {
|
||||
nextReasoningId,
|
||||
nextTextId,
|
||||
seenCallIds,
|
||||
finishReason: candidate.finishReason ?? nextState.finishReason,
|
||||
},
|
||||
events,
|
||||
] as const)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Transport
|
||||
// =============================================================================
|
||||
const GeminiCheckpoint = Schema.Struct({
|
||||
candidates: optionalNull(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
finishReason: optionalNull(Schema.String),
|
||||
continuationToken: optionalNull(Schema.String),
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
const decodeCheckpoint = Schema.decodeUnknownOption(GeminiCheckpoint)
|
||||
/** Marks where the transport starts another request for the same response; provider JSON can never match it. */
|
||||
class NextRequest {}
|
||||
|
||||
export interface Prepared {
|
||||
readonly http: HttpTransport.HttpPrepared<string>
|
||||
readonly input: TransportPrepareInput<GeminiBody>
|
||||
}
|
||||
|
||||
const sse = HttpTransport.sseJson.with<GeminiBody>()
|
||||
|
||||
/**
|
||||
* SSE transport that continues responses Gemini splits across requests. Chunks carry `continuationToken` checkpoints,
|
||||
* and a request that reaches its output cap finishes with `CONTINUATION`. The transport resends the original body with
|
||||
* the latest token, which also resumes a request that ended or dropped before finishing.
|
||||
*/
|
||||
export const transport: Transport<GeminiBody, Prepared, unknown> = {
|
||||
id: sse.id,
|
||||
prepare: (input) => sse.prepare(input).pipe(Effect.map((http) => ({ http, input }))),
|
||||
execute: (prepared, request, runtime) =>
|
||||
Effect.gen(function* () {
|
||||
const route = request.model.route.id
|
||||
const label = `${request.model.provider}/${route}`
|
||||
// Frames are parsed once here, so errors recover each frame's raw text for their body.
|
||||
const raw = new WeakMap<object, string>()
|
||||
const respond = (execution: TransportExecution<string>): Stream.Stream<unknown, AIError> =>
|
||||
Stream.suspend(() => {
|
||||
let token: string | undefined
|
||||
let finishReason: string | undefined
|
||||
let dropped: AIError | undefined
|
||||
return execution.frames.pipe(
|
||||
Stream.mapEffect((frame) =>
|
||||
ProviderShared.parseJson(route, frame, `Invalid ${label} stream event`).pipe(
|
||||
Effect.map((event) => {
|
||||
if (typeof event === "object" && event !== null) raw.set(event, frame)
|
||||
const candidate = Option.getOrUndefined(decodeCheckpoint(event))?.candidates?.[0]
|
||||
token = candidate?.continuationToken ?? (candidate?.finishReason ? undefined : token)
|
||||
finishReason = candidate?.finishReason ?? finishReason
|
||||
return event
|
||||
}),
|
||||
),
|
||||
),
|
||||
Stream.catchIf(
|
||||
// A connection failure may resume; a timeout keeps its meaning and ends the response.
|
||||
(error) => error.reason._tag === "Transport" && error.reason.code !== "Timeout",
|
||||
(error) => {
|
||||
dropped = error
|
||||
return Stream.empty
|
||||
},
|
||||
),
|
||||
Stream.concat(
|
||||
Stream.suspend(() => {
|
||||
if (token !== undefined && (finishReason === undefined || finishReason === CONTINUATION))
|
||||
return Stream.concat(Stream.succeed(new NextRequest()), next(token))
|
||||
if (dropped) return Stream.fail(dropped)
|
||||
if (finishReason === CONTINUATION)
|
||||
return Stream.fail(ProviderShared.eventError(label, "Gemini continued the response without a token"))
|
||||
return Stream.empty
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
const next = (continuationToken: string) =>
|
||||
Stream.unwrap(
|
||||
sse
|
||||
.prepare({ ...prepared.input, body: { ...prepared.input.body, continuationToken } })
|
||||
.pipe(
|
||||
Effect.flatMap((continued) => sse.execute(continued, request, runtime)),
|
||||
Effect.map(respond),
|
||||
),
|
||||
)
|
||||
const first = yield* sse.execute(prepared.http, request, runtime)
|
||||
return {
|
||||
frames: respond(first),
|
||||
http: first.http,
|
||||
body: (frame) => (typeof frame === "object" && frame !== null ? raw.get(frame) : undefined),
|
||||
}
|
||||
}),
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Protocol And Gemini Route
|
||||
// =============================================================================
|
||||
@@ -806,8 +946,8 @@ export const protocol = Protocol.make({
|
||||
// Gemini's schema rules are this API's default, including for tuned endpoints whose IDs do not name Gemini.
|
||||
sanitizer: "gemini",
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(GeminiEvent),
|
||||
initial: (request) => ({
|
||||
event: Schema.Union([Schema.instanceOf(NextRequest), GeminiEvent]),
|
||||
initial: (request): ParserState => ({
|
||||
route: `${request.model.provider}/${request.model.route.id}`,
|
||||
providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),
|
||||
hasToolCalls: false,
|
||||
@@ -830,7 +970,7 @@ export const route = Route.make({
|
||||
baseURL: DEFAULT_BASE_URL,
|
||||
}),
|
||||
auth: Auth.none,
|
||||
framing: Framing.sse,
|
||||
transport,
|
||||
})
|
||||
|
||||
export * as Gemini from "./gemini.js"
|
||||
@@ -417,7 +417,9 @@ export const fromRequest = Effect.fn("MistralChat.fromRequest")(function* (reque
|
||||
frequency_penalty: request.generation?.frequencyPenalty,
|
||||
presence_penalty: request.generation?.presencePenalty,
|
||||
stop: request.generation?.stop,
|
||||
prompt_cache_key: request.cache === "none" ? undefined : (options.promptCacheKey ?? request.promptCacheKey),
|
||||
prompt_cache_key: ProviderShared.sendsPromptCacheKey(request)
|
||||
? (options.promptCacheKey ?? request.promptCacheKey)
|
||||
: undefined,
|
||||
safe_prompt: options.safePrompt,
|
||||
document_image_limit: options.documentImageLimit,
|
||||
document_page_limit: options.documentPageLimit,
|
||||
|
||||
@@ -169,7 +169,12 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
|
||||
})
|
||||
return {
|
||||
http: {
|
||||
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
request: ProviderShared.jsonPost({
|
||||
url: parts.url,
|
||||
body: parts.bodyText,
|
||||
headers: parts.headers,
|
||||
encoding: input.request.http?.encoding,
|
||||
}),
|
||||
framing: Framing.sse,
|
||||
middleware: input.middleware,
|
||||
},
|
||||
|
||||
@@ -334,14 +334,14 @@ export const StreamItem = Schema.StructWithRest(
|
||||
id: Schema.optional(Schema.String),
|
||||
call_id: Schema.optional(Schema.String),
|
||||
name: Schema.optional(Schema.String),
|
||||
namespace: Schema.optional(Schema.String),
|
||||
namespace: optionalNull(Schema.String),
|
||||
arguments: Schema.optional(Schema.String),
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
||||
export type OutputItem = StreamItem & { readonly id: string }
|
||||
export type OutputItem = StreamItem & { readonly id: string; readonly namespace?: string }
|
||||
|
||||
// Responses-compatible providers put error details at the top level, under `error`, or under
|
||||
// `response.error`, and gateways reshape them freely: strings, numeric codes, extra fields. Those
|
||||
@@ -946,6 +946,8 @@ const resolveItem = (
|
||||
index: number | undefined,
|
||||
): OutputItem => ({
|
||||
...item,
|
||||
// Some gateways serialize an absent namespace as null.
|
||||
namespace: item.namespace ?? undefined,
|
||||
id:
|
||||
item.id ??
|
||||
(index === undefined ? undefined : state.outputItems[index]) ??
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
} from "../schema/index.js"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { cacheControl } from "./utils/cache.js"
|
||||
import { OpenAIOptions } from "./utils/openai-options.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
@@ -827,15 +828,16 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
request.model.compatibility?.zaiToolStream ?? detectZaiToolStream(provider, baseURL, request.model.id)
|
||||
const hasHistory = hasToolHistory(request.messages)
|
||||
const hasActiveTools = flattened.tools.length > 0
|
||||
const lowering = { ...options, cacheControl: options.cacheControl ?? declaredCacheControl(request) }
|
||||
return {
|
||||
model: request.model.id,
|
||||
messages: yield* lowerMessages(flattened.request, options),
|
||||
messages: yield* lowerMessages(flattened.request, lowering),
|
||||
tools:
|
||||
flattened.tools.length === 0
|
||||
? hasHistory
|
||||
? []
|
||||
: undefined
|
||||
: flattened.tools.map((tool) => lowerTool(tool, options, supportsStrictMode)),
|
||||
: flattened.tools.map((tool) => lowerTool(tool, lowering, supportsStrictMode)),
|
||||
tool_choice: hasActiveTools && request.toolChoice ? yield* lowerToolChoice(request.toolChoice) : undefined,
|
||||
stream: true as const,
|
||||
...(supportsUsageInStreaming ? { stream_options: { include_usage: true } } : {}),
|
||||
@@ -853,6 +855,10 @@ export const fromRequest = Effect.fn("OpenAIChat.fromRequest")(function* (
|
||||
}
|
||||
})
|
||||
|
||||
// Provider wrappers that pass their own marker writer keep it; otherwise a declared cache format adds one.
|
||||
const declaredCacheControl = (request: LLMRequest) =>
|
||||
request.model.compatibility?.cacheFormat === undefined ? undefined : cacheControl()
|
||||
|
||||
// =============================================================================
|
||||
// Stream Parsing
|
||||
// =============================================================================
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type MediaPart,
|
||||
type OpenString,
|
||||
type ProviderID,
|
||||
type RequestEncoding,
|
||||
type TextPart,
|
||||
type ToolEntry,
|
||||
type ToolResultPart,
|
||||
@@ -39,10 +40,15 @@ export const knownString = <Known extends string>() =>
|
||||
|
||||
export const OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH = 64
|
||||
|
||||
// Body-level `prompt_cache_key` is off when caching is disabled or the model
|
||||
// explicitly rejects the field. Header-based affinity is unaffected.
|
||||
export const sendsPromptCacheKey = (request: LLMRequest) =>
|
||||
request.cache !== "none" && request.model.compatibility?.supportsPromptCacheKey !== false
|
||||
|
||||
// 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 promptCacheKey = (request: LLMRequest): string | undefined => {
|
||||
if (request.cache === "none" || request.promptCacheKey === undefined) return undefined
|
||||
if (!sendsPromptCacheKey(request) || 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("")
|
||||
@@ -373,11 +379,40 @@ export const validateWith =
|
||||
* send JSON with a stale content type. The body is passed pre-encoded so
|
||||
* routes can choose between
|
||||
* `Schema.encodeSync(payload)` and `ProviderShared.encodeJson(payload)`.
|
||||
* A `zstd` or `gzip` encoding compresses the body and sets `content-encoding`;
|
||||
* it falls back to plain JSON when the runtime has no zlib.
|
||||
*/
|
||||
export const jsonPost = (input: { readonly url: string; readonly body: string; readonly headers?: Headers.Input }) =>
|
||||
HttpClientRequest.post(input.url).pipe(
|
||||
HttpClientRequest.setHeaders(Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")),
|
||||
HttpClientRequest.bodyText(input.body, "application/json"),
|
||||
export const jsonPost = (input: {
|
||||
readonly url: string
|
||||
readonly body: string
|
||||
readonly headers?: Headers.Input
|
||||
readonly encoding?: RequestEncoding
|
||||
}) => {
|
||||
const headers = Headers.set(Headers.fromInput(input.headers), "content-type", "application/json")
|
||||
const compressed = compressBody(input.body, input.encoding)
|
||||
if (!compressed)
|
||||
return HttpClientRequest.post(input.url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(input.body, "application/json"),
|
||||
)
|
||||
return HttpClientRequest.post(input.url).pipe(
|
||||
HttpClientRequest.setHeaders(Headers.set(headers, "content-encoding", compressed.encoding)),
|
||||
HttpClientRequest.bodyUint8Array(compressed.body, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
function compressBody(text: string, encoding: RequestEncoding | undefined) {
|
||||
if (encoding === undefined || encoding === "none") return undefined
|
||||
// Looked up at call time instead of imported: a runtime without node:zlib or its zstd export
|
||||
// (browsers, older Node, some workerd setups) sends plain JSON rather than failing to load this module.
|
||||
const zlib = globalThis.process?.getBuiltinModule?.("node:zlib")
|
||||
try {
|
||||
const body = encoding === "zstd" ? zlib?.zstdCompressSync?.(text) : zlib?.gzipSync(text)
|
||||
return body && { encoding, body }
|
||||
} catch {
|
||||
// Compression is an optimization, so a failure sends plain JSON instead of failing the request.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export * as ProviderShared from "./shared.js"
|
||||
@@ -117,7 +117,12 @@ export const make = (
|
||||
encodeBody: Schema.encodeSync(Schema.fromJsonString(Body)),
|
||||
})
|
||||
const response = yield* executor.execute(
|
||||
ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
|
||||
ProviderShared.jsonPost({
|
||||
url: parts.url,
|
||||
body: parts.bodyText,
|
||||
headers: parts.headers,
|
||||
encoding: request.http?.encoding,
|
||||
}),
|
||||
options?.http,
|
||||
)
|
||||
const text = yield* RequestExecutor.responseStream(response).pipe(
|
||||
|
||||
@@ -5,7 +5,6 @@ import { ProviderShared } from "../protocols/shared.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { ProviderConfigurationError, ProviderID, type LLMRequest, type ModelID } from "../schema/index.js"
|
||||
import { GoogleVertexShared } from "./google-vertex-shared.js"
|
||||
|
||||
@@ -69,7 +68,7 @@ const route = Route.make({
|
||||
const serviceTier = request.providerOptions?.serviceTier
|
||||
return typeof serviceTier === "string" ? { "x-vertex-ai-llm-shared-request-type": serviceTier } : {}
|
||||
},
|
||||
framing: Framing.sse,
|
||||
transport: Gemini.transport,
|
||||
})
|
||||
|
||||
export const routes = [route]
|
||||
|
||||
@@ -34,7 +34,7 @@ export * as Moonshot from "./moonshot.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenCodeZen from "./opencode-zen.js"
|
||||
export * as OpenCode from "./opencode.js"
|
||||
export * as OpenRouter from "./openrouter.js"
|
||||
export * as Replicate from "./replicate.js"
|
||||
export * as Runway from "./runway.js"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
|
||||
export const id = ProviderID.make("opencode")
|
||||
const baseURL = "https://opencode.ai/zen/v1"
|
||||
const baseURL = "https://opencode.ai/inference/v1"
|
||||
|
||||
export type Options = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
@@ -28,4 +28,4 @@ export const configure = (input: Options = {}) => {
|
||||
export const provider = configure()
|
||||
export const experimental = provider.experimental
|
||||
|
||||
export * as OpenCodeZen from "./opencode-zen.js"
|
||||
export * as OpenCode from "./opencode.js"
|
||||
@@ -7,6 +7,7 @@ import type { HttpMiddleware, Transport, TransportPrepareInput } from "./index.j
|
||||
import * as ProviderShared from "../../protocols/shared.js"
|
||||
import {
|
||||
AIError,
|
||||
DEFAULT_CHUNK_TIMEOUT_MS,
|
||||
DEFAULT_HTTP_TIMEOUT_MS,
|
||||
mergeJsonRecords,
|
||||
TransportError,
|
||||
@@ -88,6 +89,7 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
url: parts.url,
|
||||
body: parts.bodyText,
|
||||
headers: parts.headers,
|
||||
encoding: prepareInput.request.http?.encoding,
|
||||
})
|
||||
return {
|
||||
request,
|
||||
@@ -123,7 +125,7 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
frames: prepared.framing.frame(
|
||||
RequestExecutor.responseStream(response).pipe(
|
||||
Stream.timeoutOrElse({
|
||||
duration: timeoutDuration(request.http?.chunkTimeout),
|
||||
duration: timeoutDuration(request.http?.chunkTimeout, DEFAULT_CHUNK_TIMEOUT_MS),
|
||||
orElse: () => Stream.fail(timeout("read", "Timed out waiting for response data", http)),
|
||||
}),
|
||||
Stream.interruptWhen(
|
||||
@@ -139,8 +141,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
}),
|
||||
})
|
||||
|
||||
const timeoutDuration = (value: HttpTimeout | undefined) =>
|
||||
value === false ? Duration.infinity : Duration.millis(value ?? DEFAULT_HTTP_TIMEOUT_MS)
|
||||
const timeoutDuration = (value: HttpTimeout | undefined, fallback = DEFAULT_HTTP_TIMEOUT_MS) =>
|
||||
value === false ? Duration.infinity : Duration.millis(value ?? fallback)
|
||||
|
||||
export const sseJson = {
|
||||
id: "http-json/sse",
|
||||
|
||||
@@ -52,9 +52,19 @@ export const mergeProviderOptions = (
|
||||
export const HttpTimeout = Schema.Union([Schema.Number.check(Schema.isGreaterThan(0)), Schema.Literal(false)])
|
||||
export type HttpTimeout = Schema.Schema.Type<typeof HttpTimeout>
|
||||
|
||||
/** Applied to `headerTimeout` and `chunkTimeout` when a request leaves them unset. */
|
||||
/** Applied to `headerTimeout` when a request leaves it unset. */
|
||||
export const DEFAULT_HTTP_TIMEOUT_MS = 300_000
|
||||
|
||||
/**
|
||||
* Applied to `chunkTimeout` when a request leaves it unset. Some providers, such as DeepSeek, have specifically asked
|
||||
* clients to allow at least 15 minutes, because their models can stream nothing for minutes while they reason.
|
||||
*/
|
||||
export const DEFAULT_CHUNK_TIMEOUT_MS = 900_000
|
||||
|
||||
/** `Content-Encoding` for JSON request bodies; `none` sends plain JSON. */
|
||||
export const RequestEncoding = Schema.Literals(["zstd", "gzip", "none"])
|
||||
export type RequestEncoding = Schema.Schema.Type<typeof RequestEncoding>
|
||||
|
||||
export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
|
||||
body: Schema.optional(JsonSchema),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
|
||||
@@ -65,6 +75,8 @@ export class HttpOptions extends Schema.Class<HttpOptions>("AI.HttpOptions")({
|
||||
headerTimeout: Schema.optional(HttpTimeout),
|
||||
/** Time allowed between streamed response chunks once headers have arrived. */
|
||||
chunkTimeout: Schema.optional(HttpTimeout),
|
||||
/** Compression for JSON request bodies. Only set it for endpoints known to decode it. */
|
||||
encoding: Schema.optional(RequestEncoding),
|
||||
}) {}
|
||||
|
||||
export namespace HttpOptions {
|
||||
@@ -86,9 +98,18 @@ export const mergeHttpOptions = (...items: ReadonlyArray<HttpOptions | undefined
|
||||
const timeout = items.findLast((item) => item?.timeout !== undefined)?.timeout
|
||||
const headerTimeout = items.findLast((item) => item?.headerTimeout !== undefined)?.headerTimeout
|
||||
const chunkTimeout = items.findLast((item) => item?.chunkTimeout !== undefined)?.chunkTimeout
|
||||
if (!body && !headers && !query && timeout === undefined && headerTimeout === undefined && chunkTimeout === undefined)
|
||||
const encoding = items.findLast((item) => item?.encoding !== undefined)?.encoding
|
||||
if (
|
||||
!body &&
|
||||
!headers &&
|
||||
!query &&
|
||||
timeout === undefined &&
|
||||
headerTimeout === undefined &&
|
||||
chunkTimeout === undefined &&
|
||||
encoding === undefined
|
||||
)
|
||||
return undefined
|
||||
return new HttpOptions({ body, headers, query, timeout, headerTimeout, chunkTimeout })
|
||||
return new HttpOptions({ body, headers, query, timeout, headerTimeout, chunkTimeout, encoding })
|
||||
}
|
||||
|
||||
export class GenerationOptions extends Schema.Class<GenerationOptions>("LLM.GenerationOptions")({
|
||||
@@ -186,6 +207,9 @@ export type LanguageModelMaxTokensFieldCompatibility = Schema.Schema.Type<
|
||||
typeof LanguageModelMaxTokensFieldCompatibility
|
||||
>
|
||||
|
||||
export const LanguageModelCacheFormatCompatibility = Schema.Literals(["anthropic", "alibaba"])
|
||||
export type LanguageModelCacheFormatCompatibility = Schema.Schema.Type<typeof LanguageModelCacheFormatCompatibility>
|
||||
|
||||
export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompatibility>(
|
||||
"LLM.LanguageModelCompatibility",
|
||||
)({
|
||||
@@ -199,9 +223,12 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
|
||||
supportsStore: Schema.optional(Schema.Boolean),
|
||||
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
|
||||
supportsStrictMode: Schema.optional(Schema.Boolean),
|
||||
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
|
||||
// key unless this is set; session-affinity headers still flow regardless.
|
||||
// Accepts `prompt_cache_key` in the request body. Chat omits the key unless
|
||||
// this is true; other protocols omit it only when this is false.
|
||||
// Session-affinity headers still flow regardless.
|
||||
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
|
||||
/** Prompt-cache marker syntax the endpoint accepts. Overrides the route default; both send `cache_control`, and `alibaba` skips tool definitions. */
|
||||
cacheFormat: Schema.optional(LanguageModelCacheFormatCompatibility),
|
||||
zaiToolStream: Schema.optional(Schema.Boolean),
|
||||
requireSignature: Schema.optional(Schema.Boolean),
|
||||
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/http"
|
||||
import { Evaluation, EvaluationClient } from "../src/experimental.js"
|
||||
import { OpenCodeZen, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
|
||||
import { OpenCode, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
|
||||
@@ -116,9 +116,9 @@ describe("experimental Evaluation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("configures the OpenCode Zen System One endpoint", () =>
|
||||
it.effect("configures the OpenCode System One endpoint", () =>
|
||||
Evaluation.run({
|
||||
model: OpenCodeZen.configure({ apiKey: "zen-key", baseURL: "https://zen.test/v1" }).experimental.evaluation(
|
||||
model: OpenCode.configure({ apiKey: "opencode-key", baseURL: "https://opencode.test/v1" }).experimental.evaluation(
|
||||
"jev-1.13",
|
||||
),
|
||||
state: "hello",
|
||||
@@ -136,8 +136,8 @@ describe("experimental Evaluation", () => {
|
||||
EvaluationClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(input.request.url).toBe("https://zen.test/v1/systemone")
|
||||
expect(input.request.headers.authorization).toBe("Bearer zen-key")
|
||||
expect(input.request.url).toBe("https://opencode.test/v1/systemone")
|
||||
expect(input.request.headers.authorization).toBe("Bearer opencode-key")
|
||||
return Effect.succeed(
|
||||
input.respond(
|
||||
JSON.stringify({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation, EvaluationClient, EvaluationModel, type EvaluationRoute } from "../src/experimental.js"
|
||||
import type { Service } from "../src/experimental/evaluation-client.js"
|
||||
import { OpenCodeZen, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
|
||||
import { OpenCode, OpenRouter, TypeSafeAI, VercelAIGateway } from "../src/providers.js"
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type Success<T> = T extends Effect.Effect<infer A, infer _E, infer _R> ? A : never
|
||||
@@ -34,7 +34,7 @@ void (true satisfies ClientRequirements)
|
||||
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Evaluation.run({
|
||||
model: OpenCodeZen.experimental.evaluation("jev-1.13"),
|
||||
model: OpenCode.experimental.evaluation("jev-1.13"),
|
||||
state: ["hello"],
|
||||
questions: { greeting: { type: "boolean", instructions: "Greeting?" } },
|
||||
})
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
Fireworks,
|
||||
Google,
|
||||
Meta,
|
||||
OpenCodeZen,
|
||||
OpenCode,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
@@ -149,7 +149,7 @@ describe("public exports", () => {
|
||||
expect(OpenRouter.model).toBeFunction()
|
||||
expect(OpenRouter.experimental.evaluation).toBeFunction()
|
||||
expect(TypeSafeAI.experimental.evaluation).toBeFunction()
|
||||
expect(OpenCodeZen.experimental.evaluation).toBeFunction()
|
||||
expect(OpenCode.experimental.evaluation).toBeFunction()
|
||||
expect(VercelAIGateway.experimental.evaluation).toBeFunction()
|
||||
expect(XAI.model).toBeFunction()
|
||||
expect(XAI.provider.responses).toBe(XAI.responses)
|
||||
|
||||
+3
-3
@@ -3,11 +3,11 @@
|
||||
"metadata": {
|
||||
"model": "jev-1.13-free",
|
||||
"tags": [
|
||||
"prefix:opencode-zen-evaluation",
|
||||
"prefix:opencode-evaluation",
|
||||
"provider:opencode",
|
||||
"protocol:system-one"
|
||||
],
|
||||
"name": "opencode-zen-evaluation/evaluates-choice-score-and-boolean-questions",
|
||||
"name": "opencode-evaluation/evaluates-choice-score-and-boolean-questions",
|
||||
"recordedAt": "2026-09-22T03:44:57.511Z"
|
||||
},
|
||||
"interactions": [
|
||||
@@ -15,7 +15,7 @@
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://opencode.ai/zen/v1/systemone",
|
||||
"url": "https://opencode.ai/inference/v1/systemone",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
@@ -69,7 +69,7 @@ describe("HTTP transport timeouts", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails when the response body stalls for five minutes", () =>
|
||||
it.effect("fails when the response body stalls for fifteen minutes", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = yield* stalledServer
|
||||
const fiber = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" })).pipe(
|
||||
@@ -79,7 +79,7 @@ describe("HTTP transport timeouts", () => {
|
||||
)
|
||||
yield* Deferred.await(server.stalled)
|
||||
yield* Effect.yieldNow
|
||||
yield* TestClock.adjust("5 minutes")
|
||||
yield* TestClock.adjust("15 minutes")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", transport: "http", operation: "read", code: "Timeout" })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation, EvaluationModel, type EvaluationOptions } from "../../src/experimental.js"
|
||||
import { OpenCodeZen, OpenRouter, TypeSafeAI } from "../../src/providers.js"
|
||||
import { OpenCode, OpenRouter, TypeSafeAI } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const questions = {
|
||||
@@ -32,8 +32,8 @@ const typesafe = recordedTests({
|
||||
metadata: { model: "jev-latest" },
|
||||
})
|
||||
|
||||
const zen = recordedTests({
|
||||
prefix: "opencode-zen-evaluation",
|
||||
const opencode = recordedTests({
|
||||
prefix: "opencode-evaluation",
|
||||
provider: "opencode",
|
||||
protocol: "system-one",
|
||||
requires: ["OPENCODE_API_KEY"],
|
||||
@@ -55,9 +55,9 @@ describe("experimental Evaluation recorded", () => {
|
||||
),
|
||||
)
|
||||
|
||||
zen.effect("evaluates choice score and boolean questions", () =>
|
||||
opencode.effect("evaluates choice score and boolean questions", () =>
|
||||
assertEvaluation(
|
||||
OpenCodeZen.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
|
||||
OpenCode.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
|
||||
"jev-1.13-free",
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, AIError, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage, Media } from "../../src/index.js"
|
||||
import { Deferred, Effect, Fiber } from "effect"
|
||||
import * as TestClock from "effect/testing/TestClock"
|
||||
import { LLM, AIError, LLMEvent, LLMRequest, Message, ToolCallPart, ToolDefinition, Usage, Media } from "../../src/index.js"
|
||||
import { Auth, LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as Gemini from "../../src/protocols/gemini.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
import { dynamicResponse, fixedResponse } from "../lib/http.js"
|
||||
import { sseEvents, sseRaw } from "../lib/sse.js"
|
||||
|
||||
const model = Gemini.route
|
||||
@@ -1832,3 +1833,316 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("Gemini response continuation", () => {
|
||||
const continuedRequest = LLM.request({
|
||||
model: Gemini.route
|
||||
.with({
|
||||
endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
|
||||
auth: Auth.header("x-goog-api-key", "test"),
|
||||
})
|
||||
.model({ id: "gemini-test" }),
|
||||
prompt: "Count.",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
|
||||
})
|
||||
|
||||
// Serves one scripted SSE body per request, or a body whose connection drops after it, and records every sent body.
|
||||
const scripted = (responses: ReadonlyArray<string | { readonly dropped: string } | ReadableStream<Uint8Array>>) => {
|
||||
const bodies: Array<Record<string, unknown>> = []
|
||||
const layer = dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
bodies.push(JSON.parse(input.text))
|
||||
const response = responses[bodies.length - 1]
|
||||
if (typeof response === "string" || response instanceof ReadableStream)
|
||||
return input.respond(response, { headers: { "content-type": "text/event-stream" } })
|
||||
let sent = false
|
||||
return input.respond(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sent) return controller.error(new Error("connection reset"))
|
||||
sent = true
|
||||
controller.enqueue(new TextEncoder().encode(response.dropped))
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
return { bodies, layer }
|
||||
}
|
||||
const chunks = (...values: ReadonlyArray<unknown>) =>
|
||||
values.map((value) => sseRaw(`data: ${JSON.stringify(value)}`)).join("")
|
||||
|
||||
const text = (value: string, extra: Record<string, unknown> = {}) => ({
|
||||
candidates: [{ content: { role: "model", parts: [{ text: value }] }, ...extra }],
|
||||
})
|
||||
const continuation = (token?: string) => ({
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "" }] }, finishReason: "CONTINUATION", continuationToken: token },
|
||||
],
|
||||
})
|
||||
|
||||
it.effect("stitches text split across three requests into one block", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([
|
||||
sseEvents(
|
||||
{
|
||||
candidates: [
|
||||
{ content: { role: "model", parts: [{ text: "plan", thought: true, thoughtSignature: "sig_1" }] } },
|
||||
],
|
||||
},
|
||||
text("THIRTEEN THOUSAND TWO"),
|
||||
continuation("token-1"),
|
||||
),
|
||||
sseEvents(
|
||||
text(" HUNDRED SEVEN"),
|
||||
// The finishing chunk's token supersedes an earlier checkpoint's.
|
||||
text("TY-TWO", { continuationToken: "checkpoint" }),
|
||||
continuation("token-2"),
|
||||
),
|
||||
sseEvents(text(" DONE", { finishReason: "STOP" })),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.text).toBe("THIRTEEN THOUSAND TWO HUNDRED SEVENTY-TWO DONE")
|
||||
expect(response.reasoning).toBe("plan")
|
||||
expect(response.events.filter((event) => event.type !== "text-delta").map((event) => event.type)).toEqual([
|
||||
"step-start",
|
||||
"reasoning-start",
|
||||
"reasoning-delta",
|
||||
"reasoning-end",
|
||||
"text-start",
|
||||
"text-end",
|
||||
"step-finish",
|
||||
"finish",
|
||||
])
|
||||
expect(response.events.find((event) => event.type === "reasoning-end")).toMatchObject({
|
||||
providerMetadata: { google: { thoughtSignature: "sig_1" } },
|
||||
})
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
expect(server.bodies).toHaveLength(3)
|
||||
expect(server.bodies[0]).not.toHaveProperty("continuationToken")
|
||||
expect(server.bodies[1]).toEqual({ ...server.bodies[0], continuationToken: "token-1" })
|
||||
expect(server.bodies[2]).toEqual({ ...server.bodies[0], continuationToken: "token-2" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("finishes with a signed tool call from a later request", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([
|
||||
sseEvents(text("Looking"), continuation("token-1")),
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: " it up." },
|
||||
{ functionCall: { id: "call_1", name: "lookup", args: {} }, thoughtSignature: "call_sig" },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
}),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.text).toBe("Looking it up.")
|
||||
expect(response.events.filter((event) => event.type === "text-start")).toHaveLength(1)
|
||||
expect(response.events.find((event) => event.type === "tool-call")).toMatchObject({
|
||||
id: "call_1",
|
||||
providerMetadata: { google: { thoughtSignature: "call_sig" } },
|
||||
})
|
||||
expect(response.finishReason).toEqual({ normalized: "tool-calls", raw: "STOP" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("forgets a block once the resumed request finishes", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([
|
||||
sseEvents(text("THIRTEEN", { continuationToken: "checkpoint-1" }), { promptFeedback: { blockReason: "OTHER" } }),
|
||||
sseEvents(text(" THOUSAND", { finishReason: "STOP" })),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.text).toBe("THIRTEEN THOUSAND")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
expect(response.events.filter(LLMEvent.is.finish).map((event) => event.providerMetadata)).toEqual([undefined])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("resumes a dropped stream from its latest checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([
|
||||
{ dropped: chunks(text("THIRTEEN"), text(" THOUSAND", { continuationToken: "checkpoint-1" })) },
|
||||
sseEvents(text(" TWO", { finishReason: "STOP" })),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.text).toBe("THIRTEEN THOUSAND TWO")
|
||||
expect(response.finishReason).toEqual({ normalized: "stop", raw: "STOP" })
|
||||
expect(server.bodies[1]).toEqual({ ...server.bodies[0], continuationToken: "checkpoint-1" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("counts the usage of a dropped request once resumed", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([
|
||||
{
|
||||
dropped: chunks(
|
||||
{ ...text("THIRTEEN"), usageMetadata: { promptTokenCount: 120, candidatesTokenCount: 5 } },
|
||||
{
|
||||
...text(" THOUSAND", { continuationToken: "checkpoint-1" }),
|
||||
usageMetadata: { promptTokenCount: 142, candidatesTokenCount: 32768, totalTokenCount: 32910 },
|
||||
},
|
||||
),
|
||||
},
|
||||
sseEvents({
|
||||
...text(" TWO", { finishReason: "STOP" }),
|
||||
usageMetadata: { promptTokenCount: 32910, candidatesTokenCount: 100, totalTokenCount: 33010 },
|
||||
}),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.usage).toEqual(
|
||||
new Usage({
|
||||
inputTokens: 142,
|
||||
outputTokens: 32868,
|
||||
nonCachedInputTokens: 142,
|
||||
totalTokens: 33010,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptTokenCount: 33052,
|
||||
candidatesTokenCount: 32868,
|
||||
totalTokenCount: 65920,
|
||||
requests: 2,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a stream that ends at a checkpoint without finishing", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([
|
||||
sseEvents(text("THIRTEEN", { continuationToken: "checkpoint-1" })),
|
||||
sseEvents(text(" THOUSAND", { finishReason: "STOP" })),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.text).toBe("THIRTEEN THOUSAND")
|
||||
expect(server.bodies[1]).toEqual({ ...server.bodies[0], continuationToken: "checkpoint-1" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a stream dropped before any checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([{ dropped: chunks(text("Partial")) }])
|
||||
const error = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer), Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("Transport")
|
||||
expect(server.bodies).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not resume a response cut off by its own timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const stalled = yield* Deferred.make<void>()
|
||||
let sent = false
|
||||
const server = scripted([
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
if (sent) return new Promise<void>(() => Deferred.doneUnsafe(stalled, Effect.void))
|
||||
sent = true
|
||||
controller.enqueue(new TextEncoder().encode(chunks(text("THIRTEEN", { continuationToken: "checkpoint-1" }))))
|
||||
},
|
||||
}),
|
||||
])
|
||||
const fiber = yield* LLMClient.generate(
|
||||
LLMRequest.update(continuedRequest, { http: { timeout: 1_000 } }),
|
||||
).pipe(Effect.provide(server.layer), Effect.flip, Effect.forkChild({ startImmediately: true }))
|
||||
yield* Deferred.await(stalled)
|
||||
yield* TestClock.adjust("1 second")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", code: "Timeout" })
|
||||
expect(server.bodies).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails a continuation without a token", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([sseEvents(text("Partial"), continuation())])
|
||||
const error = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer), Effect.flip)
|
||||
|
||||
expect(error).toMatchObject({
|
||||
reason: { _tag: "InvalidProviderOutput", http: { status: 200 } },
|
||||
message: "Gemini continued the response without a token",
|
||||
})
|
||||
expect(server.bodies).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("does not continue a length finish", () =>
|
||||
Effect.gen(function* () {
|
||||
const server = scripted([
|
||||
sseEvents(text("Partial", { finishReason: "MAX_TOKENS", continuationToken: "token-1" })),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.finishReason).toEqual({ normalized: "length", raw: "MAX_TOKENS" })
|
||||
expect(server.bodies).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports the first request's input with all output", () =>
|
||||
Effect.gen(function* () {
|
||||
const usage = [
|
||||
{ promptTokenCount: 142, candidatesTokenCount: 32768, totalTokenCount: 32910 },
|
||||
{ promptTokenCount: 32910, candidatesTokenCount: 32768, totalTokenCount: 65678 },
|
||||
{
|
||||
promptTokenCount: 65678,
|
||||
cachedContentTokenCount: 32911,
|
||||
candidatesTokenCount: 32768,
|
||||
totalTokenCount: 98446,
|
||||
},
|
||||
{
|
||||
promptTokenCount: 98446,
|
||||
cachedContentTokenCount: 65679,
|
||||
candidatesTokenCount: 7257,
|
||||
totalTokenCount: 105703,
|
||||
},
|
||||
]
|
||||
const server = scripted([
|
||||
sseEvents(
|
||||
{ ...text("a"), usageMetadata: { promptTokenCount: 120, candidatesTokenCount: 10, totalTokenCount: 130 } },
|
||||
{ ...continuation("token-1"), usageMetadata: usage[0] },
|
||||
),
|
||||
sseEvents({ ...continuation("token-2"), usageMetadata: usage[1] }),
|
||||
sseEvents({ ...continuation("token-3"), usageMetadata: usage[2] }),
|
||||
sseEvents({ ...text("b", { finishReason: "STOP" }), usageMetadata: usage[3] }),
|
||||
])
|
||||
const response = yield* LLMClient.generate(continuedRequest).pipe(Effect.provide(server.layer))
|
||||
|
||||
expect(response.usage).toEqual(
|
||||
new Usage({
|
||||
inputTokens: 142,
|
||||
outputTokens: 105561,
|
||||
nonCachedInputTokens: 142,
|
||||
totalTokens: 105703,
|
||||
providerMetadata: {
|
||||
google: {
|
||||
promptTokenCount: 197176,
|
||||
cachedContentTokenCount: 98590,
|
||||
candidatesTokenCount: 105561,
|
||||
totalTokenCount: 302737,
|
||||
requests: 4,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -539,6 +539,50 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues split Gemini responses with the Vertex request body", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent: Array<{ readonly authorization: string | null; readonly body: Record<string, unknown> }> = []
|
||||
const responses = [
|
||||
sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: { role: "model", parts: [{ text: "Hel" }] },
|
||||
finishReason: "CONTINUATION",
|
||||
continuationToken: "token-1",
|
||||
},
|
||||
],
|
||||
}),
|
||||
sseEvents({ candidates: [{ content: { role: "model", parts: [{ text: "lo." }] }, finishReason: "STOP" }] }),
|
||||
]
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: GoogleVertex.configure({
|
||||
accessToken: "vertex-token",
|
||||
project: "vertex-project",
|
||||
providerOptions: { labels: { component: "opencode" }, serviceTier: "flex" },
|
||||
}).model("gemini-test"),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
sent.push({ authorization: input.request.headers.authorization ?? null, body: JSON.parse(input.text) })
|
||||
return input.respond(responses[sent.length - 1], { headers: { "content-type": "text/event-stream" } })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.text).toBe("Hello.")
|
||||
expect(response.events.filter((event) => event.type === "text-start")).toHaveLength(1)
|
||||
expect(sent.map((request) => request.authorization)).toEqual(["Bearer vertex-token", "Bearer vertex-token"])
|
||||
expect(sent[0].body).toMatchObject({ labels: { component: "opencode" } })
|
||||
expect(sent[0].body).not.toHaveProperty("serviceTier")
|
||||
expect(sent[1].body).toEqual({ ...sent[0].body, continuationToken: "token-1" })
|
||||
}),
|
||||
)
|
||||
|
||||
test("rejects tuned Gemini models in express mode", () => {
|
||||
expect(() => GoogleVertex.configure({ apiKey: "fixture" }).model("endpoints/1234567890")).toThrow(
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/http"
|
||||
import { LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { LanguageModel, LLM, LLMEvent, Message, SystemPart, ToolDefinition, Media } from "../../src/index.js"
|
||||
import { Mistral } from "../../src/providers/index.js"
|
||||
import { MistralChat } from "../../src/protocols/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
@@ -188,6 +188,16 @@ describe("Mistral Chat", () => {
|
||||
)
|
||||
expect(uncached.body.prompt_cache_key).toBeUndefined()
|
||||
|
||||
const unsupported = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "common-key",
|
||||
providerOptions: { promptCacheKey: "native-key" },
|
||||
}),
|
||||
)
|
||||
expect(unsupported.body.prompt_cache_key).toBeUndefined()
|
||||
|
||||
const longKey = "cache-key-".repeat(10)
|
||||
const unbounded = yield* compileRequest(
|
||||
LLM.request({
|
||||
|
||||
@@ -99,6 +99,43 @@ describe("OpenAI-compatible Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("places cache breakpoints only when compatibility declares the format", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepare = (cacheFormat: "anthropic" | "alibaba" | undefined) =>
|
||||
compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAICompatibleChat.route
|
||||
.with({ provider: "custom", endpoint: { baseURL: "https://api.custom.test/v1" } })
|
||||
.model({ id: "claude-sonnet-5", compatibility: cacheFormat === undefined ? undefined : { cacheFormat } }),
|
||||
system: "You are concise.",
|
||||
prompt: "Say hello.",
|
||||
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: {} })],
|
||||
}),
|
||||
)
|
||||
|
||||
const declared = yield* prepare("anthropic")
|
||||
expect(declared.body).toMatchObject({
|
||||
messages: [
|
||||
{ role: "system", content: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Say hello.", cache_control: { type: "ephemeral" } }] },
|
||||
],
|
||||
tools: [{ function: { name: "lookup" }, cache_control: { type: "ephemeral" } }],
|
||||
})
|
||||
expect(JSON.stringify(declared.body)).not.toContain('"ttl"')
|
||||
|
||||
const alibaba = yield* prepare("alibaba")
|
||||
expect(alibaba.body).toMatchObject({
|
||||
messages: [
|
||||
{ role: "system", content: [{ type: "text", text: "You are concise.", cache_control: { type: "ephemeral" } }] },
|
||||
{ role: "user", content: [{ type: "text", text: "Say hello.", cache_control: { type: "ephemeral" } }] },
|
||||
],
|
||||
})
|
||||
expect(alibaba.body.tools?.[0]?.cache_control).toBeUndefined()
|
||||
|
||||
expect(JSON.stringify((yield* prepare(undefined)).body)).not.toContain("cache_control")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("configures the max tokens request field", () =>
|
||||
Effect.gen(function* () {
|
||||
const compatible = OpenAICompatibleChat.route
|
||||
|
||||
@@ -644,6 +644,27 @@ describe("Open Responses-compatible route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats a null function call namespace as absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const item = { type: "function_call", id: "fc_1", call_id: "call_1", name: "lookup", namespace: null }
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{ type: "response.output_item.added", output_index: 0, item: { ...item, arguments: "" } },
|
||||
{ type: "response.function_call_arguments.done", item_id: "fc_1", output_index: 0, arguments: "{}" },
|
||||
{ type: "response.output_item.done", output_index: 0, item: { ...item, arguments: "{}" } },
|
||||
{ type: "response.completed", response: { id: "resp_1", output: [{ ...item, arguments: "{}" }] } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.toolCalls).toEqual([expect.objectContaining({ id: "call_1", name: "lookup", input: {} })])
|
||||
expect(response.toolCalls[0]?.namespace).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects missing, null, and non-string event IDs even with a registered output index", () =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.forEach(
|
||||
|
||||
@@ -2117,6 +2117,24 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits the prompt cache key when compatibility disables it", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { supportsPromptCacheKey: false } }),
|
||||
prompt: "Hello",
|
||||
promptCacheKey: "request_cache",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
|
||||
expect(model.compatibility?.supportsPromptCacheKey).toBeUndefined()
|
||||
expect(
|
||||
(yield* compileRequest(LLM.request({ model, prompt: "Hello", promptCacheKey: "request_cache" }))).body,
|
||||
).toHaveProperty("prompt_cache_key", "request_cache")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("parses text and usage stream fixtures", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message } from "../../src/index.js"
|
||||
import { CacheHint, LanguageModel, LLM, Message } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as OpenRouter from "../../src/providers/openrouter.js"
|
||||
@@ -127,6 +127,26 @@ describe("OpenRouter", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("places breakpoints for any upstream when compatibility declares the cache format", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = OpenRouter.configure({ apiKey: "test-key" }).model("vendor/claude-proxy")
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: LanguageModel.update(model, { compatibility: { cacheFormat: "anthropic" } }),
|
||||
system: "Base agent",
|
||||
tools: [{ name: "lookup", description: "Lookup", inputSchema: { type: "object", properties: {} } }],
|
||||
prompt: "Hello",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.tools?.[0]?.cache_control).toEqual({ type: "ephemeral" })
|
||||
expect(prepared.body.messages).toMatchObject([
|
||||
{ role: "system", content: [{ text: "Base agent", cache_control: { type: "ephemeral" } }] },
|
||||
{ role: "user", content: [{ text: "Hello", cache_control: { type: "ephemeral" } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends no default breakpoints to upstreams that cache without them", () =>
|
||||
Effect.gen(function* () {
|
||||
const openrouter = OpenRouter.configure({ apiKey: "test-key" })
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { gunzipSync, zstdDecompressSync } from "node:zlib"
|
||||
import { Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/http"
|
||||
import { LLM, type RequestEncoding } from "../src/index.js"
|
||||
import { LLMClient } from "../src/route.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { deltaChunk, finishChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
|
||||
const model = configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).chat("gpt-4.1-mini")
|
||||
|
||||
const send = (prompt: string, encoding?: RequestEncoding) =>
|
||||
Effect.gen(function* () {
|
||||
const seen: { encoding: string | null; body: Uint8Array }[] = []
|
||||
const server = dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
seen.push({
|
||||
encoding: web.headers.get("content-encoding"),
|
||||
body: new Uint8Array(yield* Effect.promise(() => web.arrayBuffer())),
|
||||
})
|
||||
return input.respond(sseEvents(deltaChunk({ role: "assistant", content: "Hi" }), finishChunk("stop")), {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
yield* LLMClient.generate(LLM.request({ model, prompt, http: encoding ? { encoding } : undefined })).pipe(
|
||||
Effect.provide(server),
|
||||
)
|
||||
return seen[0]
|
||||
})
|
||||
|
||||
describe("HTTP request encoding", () => {
|
||||
it.effect("compresses the JSON body with the requested encoding", () =>
|
||||
Effect.gen(function* () {
|
||||
const zstd = yield* send("Hello", "zstd")
|
||||
expect(zstd.encoding).toBe("zstd")
|
||||
expect(JSON.parse(zstdDecompressSync(zstd.body).toString())).toMatchObject({ model: "gpt-4.1-mini" })
|
||||
|
||||
const gzip = yield* send("Hello", "gzip")
|
||||
expect(gzip.encoding).toBe("gzip")
|
||||
expect(JSON.parse(gunzipSync(gzip.body).toString())).toMatchObject({ model: "gpt-4.1-mini" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("sends plain JSON when unset or none", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const sent of [yield* send("Hello"), yield* send("Hello", "none")]) {
|
||||
expect(sent.encoding).toBeNull()
|
||||
expect(JSON.parse(new TextDecoder().decode(sent.body))).toMatchObject({ model: "gpt-4.1-mini" })
|
||||
}
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
import { expect, sourceURL, story } from "../../storybook/playwright/story"
|
||||
import type { ParentProps } from "solid-js"
|
||||
import type { Command } from "@opencode/gui-extensions/sdk"
|
||||
import type { Item } from "../src/runtime/extension/host"
|
||||
|
||||
const source = (path: string) => sourceURL(new URL(path, import.meta.url))
|
||||
|
||||
@@ -9,21 +12,49 @@ const modules = {
|
||||
language: source("../src/runtime/i18n/language.tsx"),
|
||||
browser: source("../../gui-extensions/src/browser/index.ts"),
|
||||
browserRenderer: source("../../gui-extensions/src/browser/renderer.tsx"),
|
||||
browserPanel: source("../../gui-extensions/src/browser/panel.tsx"),
|
||||
file: source("../../gui-extensions/src/file/index.ts"),
|
||||
fileRenderer: source("../../gui-extensions/src/file/renderer.tsx"),
|
||||
commands: source("../src/shell/commands/command.tsx"),
|
||||
sdk: source("../../gui-extensions/src/sdk/index.ts"),
|
||||
render: source("../src/runtime/extension/render.tsx"),
|
||||
settings: source("../src/settings/model.tsx"),
|
||||
platform: source("../src/runtime/platform/platform.tsx"),
|
||||
solid: source("./extension-host.fixture.tsx"),
|
||||
}
|
||||
|
||||
story.beforeEach(async ({ mount, page }) => {
|
||||
// Any story loads the app styles; the fixture mounts the real side region and extensions beside it.
|
||||
await mount("ui-line-comment--editor")
|
||||
await page.evaluate(async (modules) => {
|
||||
const [{ mountBrowserRegion }, host, panels, language, browser, file] = await Promise.all([
|
||||
const [
|
||||
{ mountBrowserRegion },
|
||||
host,
|
||||
panels,
|
||||
language,
|
||||
browser,
|
||||
file,
|
||||
commands,
|
||||
sdk,
|
||||
renderer,
|
||||
settings,
|
||||
platform,
|
||||
solid,
|
||||
] = await Promise.all([
|
||||
import(modules.fixture),
|
||||
import(modules.host),
|
||||
import(modules.panels),
|
||||
import(modules.language),
|
||||
import(modules.browser),
|
||||
import(modules.file),
|
||||
import(modules.commands),
|
||||
import(modules.sdk),
|
||||
import(modules.render),
|
||||
import(modules.settings),
|
||||
import(modules.platform),
|
||||
import(modules.solid),
|
||||
// Load the pane before mounting: Vite must not reload the page to optimize its dependencies mid-fixture.
|
||||
import(modules.browserPanel),
|
||||
])
|
||||
|
||||
mountBrowserRegion({
|
||||
@@ -35,10 +66,87 @@ story.beforeEach(async ({ mount, page }) => {
|
||||
{ ...browser.default, renderer: () => import(modules.browserRenderer) },
|
||||
{ ...file.default, renderer: () => import(modules.fileRenderer) },
|
||||
],
|
||||
Shell: (props: ParentProps) =>
|
||||
solid.createComponent(platform.PlatformProvider, {
|
||||
value: { platform: "web", openExternal() {}, restart: async () => undefined, notify: async () => undefined },
|
||||
get children() {
|
||||
return solid.createComponent(settings.SettingsProvider, {
|
||||
get children() {
|
||||
return solid.createComponent(commands.CommandProvider, {
|
||||
get children() {
|
||||
return props.children
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
Commands: (props: { close: () => void }) => {
|
||||
const command = commands.useCommand()
|
||||
const extensions = host.useExtensionHost()
|
||||
command.register("fixture-session", () => [
|
||||
{
|
||||
id: "tab.close",
|
||||
title: "Close session tab",
|
||||
keybind: "mod+w",
|
||||
onSelect: props.close,
|
||||
},
|
||||
])
|
||||
// Storybook aliases the app's command hook to a no-op. Bridge the real extension contributions
|
||||
// to the real dispatcher imported by source URL, so the shortcut runs rather than its alias.
|
||||
command.register("fixture-extensions", () =>
|
||||
extensions.items(sdk.Command).map((item: Item<Command>) => {
|
||||
const scope = item.value.scope
|
||||
|
||||
return {
|
||||
id: `${item.extension}.${item.value.id}`,
|
||||
title: item.value.title,
|
||||
keybind: item.value.bind,
|
||||
disabled: item.value.enabled === false,
|
||||
hidden: item.value.hidden,
|
||||
editable: item.value.editable,
|
||||
when: scope
|
||||
? (event: KeyboardEvent) => event.target instanceof Element && !!event.target.closest(scope)
|
||||
: undefined,
|
||||
onSelect: () => item.value.run(),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return null
|
||||
},
|
||||
Contribution: renderer.Contribution,
|
||||
})
|
||||
}, modules)
|
||||
})
|
||||
|
||||
story("closes a blank browser tab from its address field without closing the session", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const outside = root.getByRole("button", { name: "Beta", exact: true })
|
||||
await expect(root.getByText("Registrations: 1", { exact: true })).toBeVisible()
|
||||
await outside.click()
|
||||
await expect(root.getByText("Registrations: 2", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Prove the scope while browser.close is still registered.
|
||||
await outside.focus()
|
||||
await outside.press("ControlOrMeta+w")
|
||||
await expect(root.getByTestId("session-state")).toHaveText("closed")
|
||||
await expect(root.getByTestId("browser-closes")).toHaveText("[]")
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toBeVisible()
|
||||
await outside.click()
|
||||
await expect(root.getByTestId("session-state")).toHaveText("open")
|
||||
|
||||
const address = root.getByRole("combobox", { name: "Browser address", exact: true })
|
||||
await address.fill("example.com")
|
||||
await expect(address).toBeFocused()
|
||||
await address.press("ControlOrMeta+w")
|
||||
await expect(root.getByRole("tab", { name: "New tab", exact: true })).toHaveCount(0)
|
||||
await expect(root.getByTestId("browser-closes")).toHaveText('["tab_33333333-3333-3333-3333-333333333333"]')
|
||||
await expect(root.getByTestId("session-state")).toHaveText("open")
|
||||
})
|
||||
|
||||
story("keeps a restored browser tab selected and undrawn until the desktop's first inventory", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-region-fixture")
|
||||
const tabs = root.getByRole("tab")
|
||||
|
||||
@@ -1,19 +1,187 @@
|
||||
import { expect, story } from "../../storybook/playwright/story"
|
||||
|
||||
story("shows question navigation shortcuts", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const shortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘⏎" : "Ctrl+⏎"))
|
||||
const next = component.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
|
||||
story("resizes the dock to fit different answer counts", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--question-answer-counts")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const next = question.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
|
||||
await expect(next).toBeEnabled()
|
||||
|
||||
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
|
||||
const initial = await height()
|
||||
await next.click()
|
||||
await expect(component.getByRole("button", { name: "Submit", exact: true })).toContainText(shortcut)
|
||||
await expect.poll(height).toBeGreaterThan(initial)
|
||||
await expect(question.getByRole("checkbox", { name: "Diagnostics", exact: true })).toBeVisible()
|
||||
|
||||
const expanded = await height()
|
||||
const back = question.getByRole("button", { name: "Back", exact: true })
|
||||
await expect(back).toBeEnabled()
|
||||
await back.click()
|
||||
await expect.poll(height).toBeLessThan(expanded)
|
||||
await expect(question.getByRole("radio", { name: /Focused/ })).toBeVisible()
|
||||
})
|
||||
|
||||
story("selects single answers and toggles multiple answers with number keys", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const focused = question.getByRole("radio", { name: /Focused/ })
|
||||
const wide = question.getByRole("radio", { name: /Wide/ })
|
||||
const customRadio = question.getByRole("radio", { name: "Type your own answer…", exact: true })
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
const next = question.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(focused).toBeFocused()
|
||||
|
||||
await focused.press("2")
|
||||
await expect(wide).toBeChecked()
|
||||
await expect(focused).not.toBeChecked()
|
||||
await expect(wide).toBeFocused()
|
||||
await next.press("1")
|
||||
await expect(focused).toBeChecked()
|
||||
await expect(wide).not.toBeChecked()
|
||||
await focused.press("3")
|
||||
await expect(customRadio).toBeChecked()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(focused).not.toBeChecked()
|
||||
await expect(wide).not.toBeChecked()
|
||||
await input.press("Enter")
|
||||
await expect(customRadio).toBeFocused()
|
||||
await customRadio.press("2")
|
||||
await expect(wide).toBeChecked()
|
||||
await expect(customRadio).not.toBeChecked()
|
||||
|
||||
await next.click()
|
||||
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
|
||||
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
|
||||
const customCheckbox = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
|
||||
await expect(files).toBeFocused()
|
||||
await files.press("1")
|
||||
await expect(files).toBeChecked()
|
||||
await files.press("2")
|
||||
await expect(terminal).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await terminal.press("2")
|
||||
await expect(terminal).not.toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
|
||||
await terminal.press("4")
|
||||
await expect(customCheckbox).toBeChecked()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).not.toBeChecked()
|
||||
await input.press("Enter")
|
||||
await expect(customCheckbox).toBeFocused()
|
||||
await customCheckbox.press("4")
|
||||
await expect(customCheckbox).not.toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).not.toBeChecked()
|
||||
})
|
||||
|
||||
story("ignores modified, out-of-range, and repeated number shortcuts", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const focused = question.getByRole("radio", { name: /Focused/ })
|
||||
const wide = question.getByRole("radio", { name: /Wide/ })
|
||||
await expect(focused).toBeFocused()
|
||||
await focused.press("1")
|
||||
await focused.press("9")
|
||||
await focused.press("Alt+2")
|
||||
await expect(focused).toBeChecked()
|
||||
await expect(wide).not.toBeChecked()
|
||||
|
||||
await question.getByRole("button", { name: "Next", exact: true }).click()
|
||||
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
|
||||
await expect(question.getByRole("checkbox", { name: "Changed files", exact: true })).toBeFocused()
|
||||
await page.keyboard.down("2")
|
||||
await expect(terminal).toBeChecked()
|
||||
await page.keyboard.down("2")
|
||||
await expect(terminal).toBeChecked()
|
||||
await page.keyboard.up("2")
|
||||
})
|
||||
|
||||
story("types digits into the custom answer without selecting numbered options", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const focused = question.getByRole("radio", { name: /Focused/ })
|
||||
const custom = question.getByRole("radio", { name: "Type your own answer…", exact: true })
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
await expect(focused).toBeFocused()
|
||||
await focused.press("3")
|
||||
await expect(input).toBeFocused()
|
||||
await input.press("1")
|
||||
await input.press("2")
|
||||
await input.press("3")
|
||||
await expect(input).toHaveValue("123")
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(focused).not.toBeChecked()
|
||||
await expect(question.getByRole("radio", { name: /Wide/ })).not.toBeChecked()
|
||||
})
|
||||
|
||||
story("shows question navigation shortcuts in buttons and tooltips", async ({ mount, page }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const mac = await page.evaluate(() => /Mac|iPhone|iPad|iPod/.test(navigator.platform))
|
||||
const shortcut = mac ? "⌘⏎" : "Ctrl+⏎"
|
||||
const next = component.getByRole("button", { name: "Next", exact: true })
|
||||
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
|
||||
await expect(next.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
|
||||
await expect(next).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
|
||||
await next.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText("Next")
|
||||
await expect(page.getByRole("tooltip")).toContainText(shortcut)
|
||||
await next.press(mac ? "Meta+Enter" : "Control+Enter")
|
||||
const submit = component.getByRole("button", { name: "Submit", exact: true })
|
||||
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toBeVisible()
|
||||
await expect(submit.locator('[data-slot="question-submit-shortcut"]')).toHaveText(shortcut)
|
||||
await expect(submit).toHaveAttribute("aria-keyshortcuts", "Meta+Enter Control+Enter")
|
||||
await submit.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText("Submit")
|
||||
await expect(page.getByRole("tooltip")).toContainText(shortcut)
|
||||
const back = component.getByRole("button", { name: "Back", exact: true })
|
||||
const backShortcut = await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "⌘[" : "Alt+←"))
|
||||
await expect(back).toHaveText("Back")
|
||||
await expect(back).toHaveAttribute("aria-keyshortcuts", mac ? "Meta+[" : "Alt+ArrowLeft")
|
||||
await back.hover()
|
||||
await expect(page.getByRole("tooltip")).toContainText(backShortcut)
|
||||
await page.keyboard.press(
|
||||
await page.evaluate(() => (/Mac|iPhone|iPad|iPod/.test(navigator.platform) ? "Meta+[" : "Alt+ArrowLeft")),
|
||||
)
|
||||
await expect(page.getByRole("tooltip")).toContainText(mac ? "⌘[" : "Alt+←")
|
||||
await back.press(mac ? "Meta+[" : "Alt+ArrowLeft")
|
||||
await expect(next).toBeVisible()
|
||||
})
|
||||
|
||||
story("keeps the custom answer input and dock geometry stable while editing", async ({ mount }) => {
|
||||
const component = await mount("app-current-session-surface--question-request")
|
||||
const question = component.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const row = question.locator('[data-slot="question-option"][data-custom="true"]')
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
const toggle = question.getByRole("radio", { name: "Type your own answer…", exact: true })
|
||||
await expect(question.getByRole("radio", { name: /Focused/ })).toBeFocused()
|
||||
await expect(input).toBeEditable()
|
||||
await expect(input).toHaveValue("")
|
||||
const node = await input.evaluateHandle((element) => element)
|
||||
const height = () => question.evaluate((element) => element.getBoundingClientRect().height)
|
||||
const initial = await height()
|
||||
const draft = "A custom answer long enough to wrap if the editor becomes multiline instead of staying single-line"
|
||||
|
||||
const predefined = question.getByRole("radio", { name: /Focused/ })
|
||||
await predefined.click()
|
||||
await input.focus()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(predefined).toBeChecked()
|
||||
await expect(toggle).not.toBeChecked()
|
||||
await row.click({ position: { x: 4, y: 4 } })
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(input).toBeFocused()
|
||||
await expect(input).toHaveValue("")
|
||||
await expect(predefined).not.toBeChecked()
|
||||
await input.fill(draft)
|
||||
await expect(input).toHaveValue(draft)
|
||||
await expect(toggle).toBeChecked()
|
||||
await expect(predefined).not.toBeChecked()
|
||||
await expect.poll(height).toBe(initial)
|
||||
await input.press("Enter")
|
||||
await expect(toggle).toBeFocused()
|
||||
await expect(input).toHaveValue(draft)
|
||||
await row.click({ position: { x: 30, y: 20 } })
|
||||
await expect(input).toBeFocused()
|
||||
await expect(input).toHaveValue(draft)
|
||||
await expect
|
||||
.poll(() => input.evaluate((element, original) => element === original && original.isConnected, node))
|
||||
.toBe(true)
|
||||
await node.dispose()
|
||||
})
|
||||
@@ -85,13 +85,52 @@ test("appends search results without resetting the selected command", async ({ p
|
||||
await expect(dialog.getByRole("option", { name: "Copy Project ID", exact: true })).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("keeps the automatically selected file when session results arrive later", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
test("selects the first session when it arrives above automatically selected files", async ({ page }) => {
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({ json: { data: [{ path: "README.md", type: "file" }] } }),
|
||||
)
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
json: {
|
||||
data: Array.from({ length: 18 }, (_, index) => ({
|
||||
...paletteSession,
|
||||
id: index === 0 ? paletteSession.id : `ses_palette_readme_${index}`,
|
||||
location: { directory: paletteSession.directory },
|
||||
title: `README work ${index + 1}`,
|
||||
})),
|
||||
},
|
||||
})
|
||||
})
|
||||
await input.fill("README")
|
||||
await expect(dialog.getByRole("option", { name: "/ README.md", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
sessions.resolve()
|
||||
const first = dialog.getByRole("option", { name: /README work 1 command-palette/ })
|
||||
const second = dialog.getByRole("option", { name: /README work 2 command-palette/ })
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await expect(first).toBeInViewport()
|
||||
await input.press("ArrowDown")
|
||||
await expect(second).toHaveAttribute("aria-selected", "true")
|
||||
await expect(second).toBeInViewport()
|
||||
await input.press("ArrowUp")
|
||||
await expect(first).toHaveAttribute("aria-selected", "true")
|
||||
await input.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.getByRole("heading", { name: paletteSession.title, exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("keeps a keyboard-selected file when session results arrive later", async ({ page }) => {
|
||||
const warnings = captureConsoleWarnings(page)
|
||||
const { dialog, input } = await openCommandPalette(page)
|
||||
const sessions = Promise.withResolvers<void>()
|
||||
await page.route("**/api/fs/find?*", (route) =>
|
||||
route.fulfill({ json: { data: [{ path: "README.txt", type: "file" }, { path: "README.md", type: "file" }] } }),
|
||||
)
|
||||
await page.route("**/api/session?*", async (route) => {
|
||||
await sessions.promise
|
||||
await route.fulfill({
|
||||
@@ -102,6 +141,11 @@ test("keeps the automatically selected file when session results arrive later",
|
||||
})
|
||||
await input.fill("README")
|
||||
const file = dialog.getByRole("option", { name: "/ README.md", exact: true })
|
||||
await expect(dialog.getByRole("option", { name: "/ README.txt", exact: true })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
)
|
||||
await input.press("ArrowDown")
|
||||
await expect(file).toHaveAttribute("aria-selected", "true")
|
||||
sessions.resolve()
|
||||
await expect(dialog.getByRole("option", { name: /README work/ })).toBeVisible()
|
||||
|
||||
@@ -4,6 +4,89 @@ import { openDraft, openSession } from "../utils/workspace"
|
||||
|
||||
test.use({ permissions: ["clipboard-read", "clipboard-write"] })
|
||||
|
||||
test("a catalog refresh cannot replace an existing session's model", async ({ page }) => {
|
||||
const alpha = { id: "alpha", name: "Alpha", variants: { high: {} } }
|
||||
const beta = { id: "beta", name: "Beta" }
|
||||
const catalog = { current: provider(alpha, beta) }
|
||||
const switches: unknown[] = []
|
||||
const prompts: unknown[] = []
|
||||
const sessionID = "ses_catalog_selection"
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST" && new URL(request.url()).pathname === `/api/session/${sessionID}/model`)
|
||||
switches.push(request.postDataJSON())
|
||||
})
|
||||
|
||||
const view = await openSession(page, {
|
||||
name: "CatalogSelection",
|
||||
sessions: [{ id: sessionID, agent: "build", model: { id: "alpha", providerID: "opencode", variant: "high" } }],
|
||||
provider: () => catalog.current,
|
||||
onPrompt: (input) => void prompts.push(input.body),
|
||||
seed: { storage: { "opencode.global.dat:model": { recent: [{ providerID: "opencode", modelID: "beta" }] } } },
|
||||
})
|
||||
|
||||
const control = page.locator('[data-action="composer-model"]')
|
||||
await expect(control).toHaveText("Alpha")
|
||||
await view.editor.fill("Keep my selected model")
|
||||
|
||||
catalog.current = provider(beta)
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_catalog_missing",
|
||||
created: T0 + 1,
|
||||
type: "model.updated",
|
||||
location: { directory: view.directory },
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
await expect(control).toHaveText("Select model")
|
||||
await view.editor.press("Enter")
|
||||
await expect(page.getByText("Select an agent and model", { exact: true })).toBeVisible()
|
||||
await expect(view.editor).toHaveText("Keep my selected model")
|
||||
expect(switches).toEqual([])
|
||||
expect(prompts).toEqual([])
|
||||
|
||||
catalog.current = provider(alpha, beta)
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_catalog_restored",
|
||||
created: T0 + 2,
|
||||
type: "model.updated",
|
||||
location: { directory: view.directory },
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
await expect(control).toHaveText("Alpha")
|
||||
await expect(page.getByRole("button", { name: "Choose model variant", exact: true })).toHaveText("high")
|
||||
await view.editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(1)
|
||||
expect(switches).toEqual([{ model: { id: "alpha", providerID: "opencode", variant: "high" } }])
|
||||
|
||||
catalog.current = provider(beta)
|
||||
await view.push([
|
||||
{
|
||||
id: "evt_catalog_removed",
|
||||
created: T0 + 3,
|
||||
type: "model.updated",
|
||||
location: { directory: view.directory },
|
||||
data: {},
|
||||
},
|
||||
])
|
||||
await expect(control).toHaveText("Select model")
|
||||
await control.click()
|
||||
await page
|
||||
.getByRole("dialog", { name: "Select model" })
|
||||
.getByRole("button", { name: "Beta Free", exact: true })
|
||||
.click()
|
||||
await expect(control).toHaveText("Beta")
|
||||
await view.editor.fill("Use the replacement I chose")
|
||||
await view.editor.press("Enter")
|
||||
await expect.poll(() => prompts.length).toBe(2)
|
||||
expect(switches).toEqual([
|
||||
{ model: { id: "alpha", providerID: "opencode", variant: "high" } },
|
||||
{ model: { id: "beta", providerID: "opencode" } },
|
||||
])
|
||||
})
|
||||
|
||||
async function draft(page: Page) {
|
||||
const { editor } = await openDraft(page, { name: "ComposerDraft", provider: NO_PROVIDER })
|
||||
await editor.click()
|
||||
@@ -227,6 +310,35 @@ test("keeps a narrow session composer contained when invoking a built-in", async
|
||||
await expect(editor).toBeEmpty()
|
||||
})
|
||||
|
||||
test("keeps focus in a dialog that replaces the model dialog and returns it to the composer caret", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { editor } = await openSession(page, { name: "ComposerDialogFocus", provider: NO_PROVIDER })
|
||||
await editor.fill("keep me")
|
||||
await editor.press("End")
|
||||
await editor.press("ArrowLeft")
|
||||
await editor.press("ArrowLeft")
|
||||
await editor.press("ArrowLeft")
|
||||
await editor.press("ControlOrMeta+'")
|
||||
|
||||
const models = page.getByRole("dialog", { name: "Select model", exact: true })
|
||||
await expect(models.getByRole("searchbox", { name: "Search models", exact: true })).toBeFocused()
|
||||
await models.getByRole("button", { name: "Manage models", exact: true }).click()
|
||||
await expect(models).toBeHidden()
|
||||
|
||||
const manage = page.getByRole("dialog", { name: "Manage models", exact: true })
|
||||
const search = manage.getByRole("searchbox", { name: "Search models", exact: true })
|
||||
await expect(search).toBeFocused()
|
||||
// Code behind the dialog that focuses the composer must not pull focus out of the open dialog.
|
||||
await editor.evaluate((element) => element.focus())
|
||||
await expect(search).toBeFocused()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(manage).toBeHidden()
|
||||
await expect(editor).toBeFocused()
|
||||
await page.keyboard.type(",")
|
||||
await expect(editor).toHaveText("keep, me")
|
||||
})
|
||||
|
||||
test("lists slash commands in their built-in order", async ({ page }) => {
|
||||
const { editor } = await openSession(page, {
|
||||
name: "ComposerSlash",
|
||||
@@ -292,6 +404,57 @@ for (const row of [
|
||||
})
|
||||
}
|
||||
|
||||
test("marks a Console provider with its workspace only when it is also connected directly", async ({ page }) => {
|
||||
const model = (id: string, name: string) => ({ [id]: { id, name, limit: { context: 200_000 } } })
|
||||
|
||||
const managed = (id: string, canonical: string, name: string, models: ReturnType<typeof model>) => ({
|
||||
id,
|
||||
canonical,
|
||||
integrationID: "opencode",
|
||||
name: `Anomaly / ${name}`,
|
||||
models,
|
||||
})
|
||||
|
||||
await openSession(page, {
|
||||
name: "ComposerConsoleProviders",
|
||||
provider: {
|
||||
all: [
|
||||
managed("opencode", "opencode", "OpenCode", model("console-model", "Console Model")),
|
||||
managed("console-google", "google", "Google", model("managed-gemini", "Managed Gemini")),
|
||||
managed("console-openai", "openai", "OpenAI", model("managed-gpt", "Managed GPT")),
|
||||
{ id: "google", canonical: "google", name: "Google", models: model("direct-gemini", "Direct Gemini") },
|
||||
],
|
||||
connected: ["opencode", "console-google", "console-openai", "google"],
|
||||
default: { providerID: "opencode", modelID: "console-model" },
|
||||
},
|
||||
seed: {
|
||||
storage: {
|
||||
"opencode.global.dat:model": {
|
||||
user: [
|
||||
["opencode", "console-model"],
|
||||
["console-google", "managed-gemini"],
|
||||
["console-openai", "managed-gpt"],
|
||||
["google", "direct-gemini"],
|
||||
].map(([providerID, modelID]) => ({ providerID, modelID, visibility: "show" })),
|
||||
recent: [],
|
||||
variant: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await page.locator('[data-component="composer"] [data-action="composer-model"]').click()
|
||||
const menu = page.getByRole("menu")
|
||||
await expect(menu.getByRole("menuitemradio", { name: "Direct Gemini", exact: true })).toBeVisible()
|
||||
const headings = menu.locator('[data-slot="menu-v2-group-label"]')
|
||||
await expect(headings).toHaveCount(4)
|
||||
await expect(headings.filter({ hasText: /^GoogleAnomaly$/ })).toHaveCount(1)
|
||||
await expect(headings.filter({ hasText: /^Google$/ })).toHaveCount(1)
|
||||
await expect(headings.filter({ hasText: /^OpenAI$/ })).toHaveCount(1)
|
||||
await expect(headings.filter({ hasText: /^OpenCode$/ })).toHaveCount(1)
|
||||
await expect(menu.getByText(/Anomaly \//)).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("shows thinking on hover or a non-default selection while preserving keyboard access", async ({ page }) => {
|
||||
const { editor: input } = await openSession(page, {
|
||||
name: "ComposerThinking",
|
||||
|
||||
@@ -74,14 +74,32 @@ async function setup(page: Page, input: { tabs: TabSeed[]; a?: Partial<MockServe
|
||||
)
|
||||
.toBe(true)
|
||||
|
||||
const enableAutoAccept = async () => {
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
return {
|
||||
replies,
|
||||
sessionGets,
|
||||
transport: servers[SERVER]!.transport,
|
||||
listed,
|
||||
enableAutoAccept: () => enableAutoAccept(page),
|
||||
}
|
||||
}
|
||||
|
||||
return { replies, sessionGets, transport: servers[SERVER]!.transport, listed, enableAutoAccept }
|
||||
async function enableAutoAccept(page: Page) {
|
||||
await page.keyboard.press("Control+,")
|
||||
const autoAccept = page.getByTestId("settings-screen").locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
}
|
||||
|
||||
async function chooseProjectAutoAccept(page: Page, project: string, current: string, choice: "On" | "Off") {
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: project, exact: true }).click()
|
||||
const autoAccept = settings.locator('[data-action="settings-project-auto-accept-permissions"]')
|
||||
await expect(autoAccept).toHaveText(current)
|
||||
await autoAccept.click()
|
||||
await page.getByRole("option", { name: choice, exact: true }).click()
|
||||
await expect(autoAccept).toHaveText(choice)
|
||||
await settings.getByRole("button", { name: "Back to projects", exact: true }).click()
|
||||
}
|
||||
|
||||
const reply = (sessionID: string, permissionID: string) => ({
|
||||
@@ -201,3 +219,60 @@ test("auto-accept approves a request discovered by opening a session", async ({
|
||||
await view.enableAutoAccept()
|
||||
await expect.poll(() => view.replies).toEqual([reply(sessionA.id, "permission-synced-a")])
|
||||
})
|
||||
|
||||
test("a project's auto-accept choice overrides the default in both directions", async ({ page }) => {
|
||||
const api = session({ id: "ses_project_api", directory: "/work/api", title: "API session", projectID: "proj_api" })
|
||||
const web = session({ id: "ses_project_web", directory: "/work/web", title: "Web session", projectID: "proj_web" })
|
||||
const requests = { [api.id]: [pending("permission-api", api.id)], [web.id]: [pending("permission-web", web.id)] }
|
||||
const replies: string[] = []
|
||||
await mockServers(page, {
|
||||
[SERVER]: {
|
||||
directory: "/work/api",
|
||||
project: project({ id: "proj_api", directory: "/work/api" }),
|
||||
projects: [
|
||||
project({ id: "proj_api", directory: "/work/api" }),
|
||||
project({ id: "proj_web", directory: "/work/web" }),
|
||||
],
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [api, web],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
sessionStatus: { [api.id]: { type: "running" }, [web.id]: { type: "running" } },
|
||||
permissions: () => Object.values(requests).flat(),
|
||||
sessionPermissions: requests,
|
||||
onPermissionReply: (reply) => replies.push(reply.permissionID),
|
||||
},
|
||||
})
|
||||
await seed(page, {
|
||||
projects: { local: [{ worktree: "/work/api" }, { worktree: "/work/web" }] },
|
||||
tabs: [api.id, web.id],
|
||||
})
|
||||
const dock = page.locator('[data-component="dock-prompt"][data-kind="permission"]')
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
|
||||
await page.goto(sessionHref(web.id))
|
||||
await expectSessionTitle(page, web.title)
|
||||
await expect(dock.getByText("git status")).toBeVisible()
|
||||
|
||||
// On for api while the default is off; off for web before the default turns on.
|
||||
await page.keyboard.press("Control+,")
|
||||
await chooseProjectAutoAccept(page, "api", "Use default (Off)", "On")
|
||||
await expect.poll(() => replies).toEqual(["permission-api"])
|
||||
await chooseProjectAutoAccept(page, "web", "Use default (Off)", "Off")
|
||||
await settings.getByRole("tab", { name: "Preferences", exact: true }).click()
|
||||
const autoAccept = settings.locator('[data-action="settings-auto-accept-permissions"]')
|
||||
await autoAccept.locator('[data-slot="switch-control"]').click()
|
||||
await expect(autoAccept.getByRole("switch")).toBeChecked()
|
||||
await settings.getByRole("button", { name: "Back to app" }).click()
|
||||
|
||||
await expectSessionTitle(page, web.title)
|
||||
await expect(dock.getByText("git status")).toBeVisible()
|
||||
expect(replies).toEqual(["permission-api"])
|
||||
|
||||
// Back on the default, web follows it.
|
||||
await page.keyboard.press("Control+,")
|
||||
await settings.getByRole("tab", { name: "Projects", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "web", exact: true }).click()
|
||||
await settings.locator('[data-action="settings-project-auto-accept-permissions"]').click()
|
||||
await page.getByRole("option", { name: "Use default (On)" }).click()
|
||||
await expect.poll(() => replies).toEqual(["permission-api", "permission-web"])
|
||||
})
|
||||
@@ -86,7 +86,9 @@ test("Git initialization belongs to its session and refreshes after a session sw
|
||||
await expect(pending).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("a rejected Git init releases its session's button without showing an error in another session", async ({ page }) => {
|
||||
test("a rejected Git init releases its session's button without showing an error in another session", async ({
|
||||
page,
|
||||
}) => {
|
||||
await openSession(page, {
|
||||
name: "ReviewInitFailure",
|
||||
project: { vcs: undefined },
|
||||
@@ -143,7 +145,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
const panel = page.locator("#review-panel")
|
||||
const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]')
|
||||
const sidebarToggle = panel.getByRole("button", { name: "Toggle file tree" })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
const contextButton = page.getByRole("button", { name: "Toggle session context" })
|
||||
const openFile = panel.getByRole("button", { name: "Open file" })
|
||||
const tab = (name: string) => panel.getByRole("tab", { name, exact: true })
|
||||
await contextButton.click()
|
||||
@@ -181,7 +183,7 @@ test("open file tab browses, searches, and tracks missing files", async ({ page
|
||||
const missingResponse = page.waitForResponse((response) => response.url().includes("/api/fs/read/README.md"))
|
||||
await workspace.push([filesystemEvent(directory, "README.md", "unlink")])
|
||||
await missingResponse
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0)
|
||||
await expect(tab("File not found: README.md")).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("File not found: README.md", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -309,7 +311,7 @@ test("context closes the side region only when its button opened it", async ({ p
|
||||
})
|
||||
const panel = page.locator("#review-panel")
|
||||
const toggle = page.getByRole("button", { name: "Toggle review", exact: true })
|
||||
const contextButton = page.getByRole("button", { name: "View context usage" })
|
||||
const contextButton = page.getByRole("button", { name: "Toggle session context" })
|
||||
const context = panel.getByRole("tab", { name: "Context", exact: true })
|
||||
|
||||
await contextButton.click()
|
||||
@@ -333,6 +335,83 @@ test("context closes the side region only when its button opened it", async ({ p
|
||||
await expect(panel.locator("#session-side-panel-review-tab")).toHaveAttribute("aria-selected", "true")
|
||||
})
|
||||
|
||||
test("context hides free-model cost but keeps paid-model zero cost", async ({ page }) => {
|
||||
const cases = [
|
||||
{ id: "ses_context_free", title: "Free model", model: "free", cost: 0, usage: false, text: "0" },
|
||||
{ id: "ses_context_paid", title: "Paid model", model: "paid", cost: 0, usage: false, text: "0·$0.00" },
|
||||
{ id: "ses_context_used", title: "Paid usage", model: "paid", cost: 1.25, usage: true, text: "2k·$1.25" },
|
||||
{ id: "ses_context_free_used", title: "Free usage", model: "free", cost: 0, usage: true, text: "2k" },
|
||||
{ id: "ses_context_mixed", title: "Previously paid", model: "free", cost: 1.25, usage: true, text: "2k·$1.25" },
|
||||
{ id: "ses_context_unknown", title: "Unknown pricing", model: "missing", cost: 0, usage: false, text: "0·$0.00" },
|
||||
{ id: "ses_context_output", title: "Output pricing", model: "output", cost: 0, usage: false, text: "0·$0.00" },
|
||||
// The root's model is free, but its subagent spent on a paid model: the family total shows.
|
||||
{ id: "ses_context_family", title: "Paid subagent", model: "free", cost: 0, usage: true, text: "2k·$1.25" },
|
||||
// A completed compaction resets the context until the next reply reports usage, as in the TUI.
|
||||
{ id: "ses_context_compacted", title: "Compacted", model: "paid", cost: 1.25, usage: true, text: "0·$1.25" },
|
||||
]
|
||||
|
||||
await openSession(page, {
|
||||
name: "ContextPricing",
|
||||
provider: provider(
|
||||
{ id: "free", name: "Free", cost: { input: 0, output: 0 } },
|
||||
{ id: "paid", name: "Paid", cost: { input: 1, output: 3 } },
|
||||
{ id: "output", name: "Output", cost: { input: 0, output: 1 } },
|
||||
),
|
||||
sessions: [
|
||||
...cases.map((item) => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
model: { id: item.model, providerID: "opencode" },
|
||||
cost: item.cost,
|
||||
})),
|
||||
{
|
||||
id: "ses_context_family_child",
|
||||
parentID: "ses_context_family",
|
||||
title: "Subagent run",
|
||||
model: { id: "paid", providerID: "opencode" },
|
||||
cost: 1.25,
|
||||
},
|
||||
],
|
||||
pageMessages: (id) => ({
|
||||
items: cases.flatMap((item): SessionMessageInfo[] =>
|
||||
item.id === id && item.usage
|
||||
? [
|
||||
{
|
||||
id: `msg_${item.id}`,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: item.model, providerID: "opencode" },
|
||||
tokens: { input: 1500, output: 500, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
content: [{ type: "text", text: "Done." }],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
...(item.id === "ses_context_compacted"
|
||||
? [
|
||||
{
|
||||
id: `msg_${item.id}_compaction`,
|
||||
type: "compaction" as const,
|
||||
status: "completed" as const,
|
||||
reason: "manual" as const,
|
||||
summary: "Current state",
|
||||
recent: "",
|
||||
time: { created: 3 },
|
||||
},
|
||||
]
|
||||
: []),
|
||||
]
|
||||
: [],
|
||||
),
|
||||
}),
|
||||
})
|
||||
const button = page.getByRole("button", { name: "Toggle session context", exact: true })
|
||||
|
||||
for (const item of cases) {
|
||||
await page.locator("[data-titlebar-tab-slot]", { hasText: item.title }).click()
|
||||
await expectSessionTitle(page, item.title)
|
||||
await expect(button).toHaveText(item.text)
|
||||
}
|
||||
})
|
||||
|
||||
test("file tree expands Windows paths and scrolls long names in both directions", async ({ page }) => {
|
||||
const directory = "C:/OpenCode/OpenFileExpand"
|
||||
const longFilename = "a-very-long-file-name-that-must-overflow-the-file-sidebar-instead-of-being-truncated.ts"
|
||||
@@ -493,12 +572,156 @@ test("rereads a file each time an artifact link opens it", async ({ page }) => {
|
||||
await expect(tab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("second draft", { exact: true })).toBeVisible()
|
||||
await expect(panel.getByText("first draft", { exact: true })).toHaveCount(0)
|
||||
|
||||
// A restored file tab whose file was deleted shows its inline not-found state without a toast; clicking a broken link
|
||||
// when no tab is open for it shows the toast.
|
||||
await page.route("**/api/fs/read/shopping.txt*", (route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
// SAFETY: the server's wire body for a missing file, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "FileNotFoundError", path: "shopping.txt", message: "File not found: shopping.txt" },
|
||||
}),
|
||||
)
|
||||
await page.reload()
|
||||
const missingTab = panel.getByRole("tab", { name: "File not found: shopping.txt", exact: true })
|
||||
await expect(missingTab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("File not found: shopping.txt", { exact: true })).toBeVisible()
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toHaveCount(0)
|
||||
|
||||
await missingTab.locator("..").getByRole("button", { name: "Close tab" }).click()
|
||||
await expect(missingTab).toHaveCount(0)
|
||||
await link.click()
|
||||
await expect(page.getByText("Failed to load file", { exact: true })).toBeVisible()
|
||||
await expect(missingTab).toHaveCount(0)
|
||||
})
|
||||
|
||||
/** What the image case's frame probe records on the window. */
|
||||
type ImageProbe = { problems: string[]; frames: number; running: boolean }
|
||||
|
||||
test("image files keep the review panel painted while loading", async ({ page }) => {
|
||||
test("image gestures anchor zoom, pan, and reset independently of the pane", async ({ page }) => {
|
||||
// Some browsers expose no natural dimensions for a viewBox-only SVG.
|
||||
await page.addInitScript(() => {
|
||||
for (const name of ["naturalWidth", "naturalHeight"]) {
|
||||
const original = Object.getOwnPropertyDescriptor(HTMLImageElement.prototype, name)!
|
||||
Object.defineProperty(HTMLImageElement.prototype, name, {
|
||||
...original,
|
||||
get() {
|
||||
return this.alt === "second.svg" ? 0 : original.get?.call(this)
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
await openSession(page, {
|
||||
name: "ImageGestures",
|
||||
fileList: () => [],
|
||||
fileContent: (path) => ({
|
||||
content:
|
||||
path === "second.svg"
|
||||
? '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1600"><rect width="1600" height="1600" fill="#6699aa"/></svg>'
|
||||
: '<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="1600"><rect width="1600" height="1600" fill="#6699aa"/></svg>',
|
||||
}),
|
||||
pageMessages: () => ({
|
||||
items: [
|
||||
{
|
||||
id: "msg_images",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "test", providerID: "opencode" },
|
||||
content: [{ type: "text", text: "[First](first.svg) [Second](second.svg)" }],
|
||||
time: { created: 1, completed: 2 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
await page.getByRole("link", { name: "First", exact: true }).click()
|
||||
const panel = page.locator("#review-panel")
|
||||
const image = panel.getByRole("img", { name: "first.svg", exact: true })
|
||||
const stage = panel.locator('[data-slot="artifact-stage"]')
|
||||
const fit = panel.getByRole("button", { name: "Fit", exact: true })
|
||||
await expect(image).toBeVisible()
|
||||
await expect(fit).toBeDisabled()
|
||||
// Wait for the side pane's opening motion before choosing a gesture anchor.
|
||||
await image.hover()
|
||||
const initial = (await image.boundingBox())!
|
||||
const pane = (await panel.boundingBox())!
|
||||
const anchor = { x: Math.round(initial.x + initial.width * 0.55), y: Math.round(initial.y + initial.height * 0.55) }
|
||||
await page.mouse.move(anchor.x, anchor.y)
|
||||
// A continuous pinch catches anchor drift while the image still fits one axis.
|
||||
const cdp = await page.context().newCDPSession(page)
|
||||
await cdp.send("Input.synthesizePinchGesture", {
|
||||
...anchor,
|
||||
scaleFactor: 2,
|
||||
relativeSpeed: 500,
|
||||
gestureSourceType: "mouse",
|
||||
})
|
||||
await cdp.detach()
|
||||
await expect(fit).toBeEnabled()
|
||||
const zoomed = (await image.boundingBox())!
|
||||
expect(zoomed.width).toBeCloseTo(initial.width * 2, 1)
|
||||
expect((anchor.x - zoomed.x) / zoomed.width).toBeCloseTo((anchor.x - initial.x) / initial.width, 4)
|
||||
expect((anchor.y - zoomed.y) / zoomed.height).toBeCloseTo((anchor.y - initial.y) / initial.height, 4)
|
||||
expect(await panel.boundingBox()).toEqual(pane)
|
||||
expect(await page.evaluate(() => window.visualViewport?.scale)).toBe(1)
|
||||
await page.mouse.wheel(40, 60)
|
||||
await expect.poll(async () => (await image.boundingBox())!.x).toBeCloseTo(zoomed.x - 40, 1)
|
||||
expect((await image.boundingBox())!.y).toBeCloseTo(zoomed.y - 60, 1)
|
||||
const scrolled = (await image.boundingBox())!
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(anchor.x + 60, anchor.y + 40, { steps: 5 })
|
||||
await page.mouse.up()
|
||||
await expect(fit).toBeEnabled()
|
||||
expect((await image.boundingBox())!.x).toBeCloseTo(scrolled.x + 60, 1)
|
||||
expect((await image.boundingBox())!.y).toBeCloseTo(scrolled.y + 40, 1)
|
||||
// Touch uses the same captured-pointer path and must not generate a click-to-fit.
|
||||
const touch = await page.context().newCDPSession(page)
|
||||
await touch.send("Input.dispatchTouchEvent", { type: "touchStart", touchPoints: [{ x: anchor.x, y: anchor.y }] })
|
||||
await touch.send("Input.dispatchTouchEvent", {
|
||||
type: "touchMove",
|
||||
touchPoints: [{ x: anchor.x - 30, y: anchor.y - 20 }],
|
||||
})
|
||||
await touch.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] })
|
||||
await touch.detach()
|
||||
await expect(fit).toBeEnabled()
|
||||
expect((await image.boundingBox())!.x).toBeCloseTo(scrolled.x + 30, 1)
|
||||
expect((await image.boundingBox())!.y).toBeCloseTo(scrolled.y + 20, 1)
|
||||
// Keyboard-accessible fit restores the exact original geometry.
|
||||
await fit.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(fit).toBeDisabled()
|
||||
expect(await image.boundingBox()).toEqual(initial)
|
||||
expect(
|
||||
await stage.evaluate((element) =>
|
||||
element.dispatchEvent(new WheelEvent("wheel", { deltaY: 40, bubbles: true, cancelable: true })),
|
||||
),
|
||||
).toBe(true)
|
||||
await image.click()
|
||||
await expect(fit).toBeEnabled()
|
||||
await page.getByRole("link", { name: "Second", exact: true }).click()
|
||||
await expect(panel.getByRole("img", { name: "second.svg", exact: true })).toBeVisible()
|
||||
await expect(fit).toBeDisabled()
|
||||
await panel.getByRole("img", { name: "second.svg", exact: true }).hover()
|
||||
const second = (await panel.getByRole("img", { name: "second.svg", exact: true }).boundingBox())!
|
||||
expect(second.width).toBeGreaterThan(0)
|
||||
expect(second.height).toBeGreaterThan(0)
|
||||
await page.keyboard.down("Control")
|
||||
await page.mouse.wheel(0, -50)
|
||||
await expect(fit).toBeEnabled()
|
||||
await page.keyboard.up("Control")
|
||||
await fit.click()
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const frame = (await stage.boundingBox())!
|
||||
const media = (await panel.getByRole("img", { name: "second.svg", exact: true }).boundingBox())!
|
||||
|
||||
return media.width <= frame.width - 47 && media.height <= frame.height - 47
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test("image files load into the review panel and keep it painted while loading", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 960, height: 900 })
|
||||
const directory = "C:/OpenCode/ReviewImage"
|
||||
const image = "assets/preview.png"
|
||||
@@ -512,7 +735,13 @@ test("image files keep the review panel painted while loading", async ({ page })
|
||||
read.resolve()
|
||||
await release.promise
|
||||
|
||||
return { type: "binary", content: "iVBORw0KGgo=", encoding: "base64", mimeType: "image/png" }
|
||||
// A 1x1 PNG.
|
||||
return {
|
||||
type: "binary",
|
||||
content: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
|
||||
encoding: "base64",
|
||||
mimeType: "image/png",
|
||||
}
|
||||
},
|
||||
fileList: (path) => {
|
||||
if (!path) return [fileNode(directory, "assets", "directory"), fileNode(directory, "src", "directory")]
|
||||
@@ -568,6 +797,10 @@ test("image files keep the review panel painted while loading", async ({ page })
|
||||
release.resolve()
|
||||
await response
|
||||
await expect(page.locator('[data-slot="session-review-v2-file-name"]')).toHaveText("preview.png")
|
||||
await expect(page.locator("#review-panel").getByRole("img", { name: image, exact: true })).toHaveJSProperty(
|
||||
"naturalWidth",
|
||||
1,
|
||||
)
|
||||
const loaded = await frames()
|
||||
await expect.poll(frames).toBeGreaterThan(loaded)
|
||||
expect(
|
||||
@@ -581,6 +814,84 @@ test("image files keep the review panel painted while loading", async ({ page })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test("video files over 25 MB play in the viewer, expand into the theatre overlay, and collapse on Escape without remounting", async ({
|
||||
page,
|
||||
}) => {
|
||||
const directory = "C:/OpenCode/ReviewVideo"
|
||||
const file = "arcade-update-draft.mp4"
|
||||
|
||||
const head = Buffer.from(
|
||||
"AAAAHGZ0eXBpc29tAAACAGlzb21pc28ybXA0MQAAAAhmcmVlAAAAMm1kYXSCSYNCAAPwAjYAOCQcGEIAADBgAABYS///mpmB/////7FMC////+OkgAAAAAL+bW9vdgAAAGxtdmhkAAAAAAAAAAAAAAAAAAAD6AAAA+gAAQAAAQAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAil0cmFrAAAAXHRraGQAAAADAAAAAAAAAAAAAAABAAAAAAAAA+gAAAAAAAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAABAAAAAAEAAAAAkAAAAAAAkZWR0cwAAABxlbHN0AAAAAAAAAAEAAAPoAAAAAAABAAAAAAGhbWRpYQAAACBtZGhkAAAAAAAAAAAAAAAAAABAAAAAQABVxAAAAAAALWhkbHIAAAAAAAAAAHZpZGUAAAAAAAAAAAAAAABWaWRlb0hhbmRsZXIAAAABTG1pbmYAAAAUdm1oZAAAAAEAAAAAAAAAAAAAACRkaW5mAAAAHGRyZWYAAAAAAAAAAQAAAAx1cmwgAAAAAQAAAQxzdGJsAAAAqHN0c2QAAAAAAAAAAQAAAJh2cDA5AAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAAAEAAJABIAAAASAAAAAAAAAABF0xhdmM2My4xLjEwMiBsaWJ2cHgtdnA5AAAAAAAAAAAAGP//AAAAFHZwY0MBAAAAAAqCAgICAAAAAAAKZmllbAEAAAAAEHBhc3AAAAABAAAAAQAAABRidHJ0AAAAAAAAJxAAAAAAAAAAGHN0dHMAAAAAAAAAAQAAAAEAAEAAAAAAHHN0c2MAAAAAAAAAAQAAAAEAAAABAAAAAQAAABRzdHN6AAAAAAAAACoAAAABAAAAFHN0Y28AAAAAAAAAAQAAACwAAABhdWR0YQAAAFltZXRhAAAAAAAAACFoZGxyAAAAAAAAAABtZGlyYXBwbAAAAAAAAAAAAAAAACxpbHN0AAAAJKl0b28AAAAcZGF0YQAAAAEAAAAATGF2ZjYzLjEuMTAy",
|
||||
"base64",
|
||||
)
|
||||
|
||||
const mp4 = Buffer.alloc(38_400_000)
|
||||
|
||||
head.copy(mp4)
|
||||
mp4.writeUInt32BE(mp4.length - head.length, head.length)
|
||||
mp4.write("free", head.length + 4, "ascii")
|
||||
|
||||
await openSession(page, {
|
||||
name: "ReviewVideo",
|
||||
fileList: (path) => (path ? [] : [fileNode(directory, file)]),
|
||||
seed: {
|
||||
panes: { ses_reviewvideo: { review: true } },
|
||||
},
|
||||
})
|
||||
await page.route(`**/api/fs/read/${file}*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
headers: { "access-control-allow-origin": "*", "content-type": "application/octet-stream" },
|
||||
body: mp4,
|
||||
}),
|
||||
)
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await panel.getByRole("button", { name: file }).click()
|
||||
await expect(panel.getByRole("tab", { name: file, exact: true })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
const toolbar = panel.locator('[data-slot="artifact-toolbar"]')
|
||||
const stage = panel.locator('[data-slot="artifact-stage"]')
|
||||
const video = stage.locator('video[data-slot="artifact-media"]')
|
||||
const fullscreen = stage.getByRole("button", { name: "Enter full screen", exact: true })
|
||||
const exitFullscreen = stage.getByRole("button", { name: "Exit full screen", exact: true })
|
||||
|
||||
await expect(video).toBeVisible()
|
||||
await expect(toolbar).toContainText("64 × 36")
|
||||
await expect(toolbar).toContainText("0:01")
|
||||
await expect(toolbar).toContainText("38.4 MB")
|
||||
await expect(panel.getByText(/Binary file/)).toHaveCount(0)
|
||||
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
|
||||
const inlineBounds = (await stage.boundingBox())!
|
||||
expect(inlineBounds.width).toBeLessThan(1000)
|
||||
|
||||
await video.evaluate((element) => {
|
||||
element.dataset.e2eProbe = "retained"
|
||||
})
|
||||
|
||||
await fullscreen.click()
|
||||
await expect(exitFullscreen).toHaveAttribute("aria-pressed", "true")
|
||||
await expect(stage).toHaveAttribute("data-expanded", "")
|
||||
expect(await stage.evaluate((element) => element.matches(":popover-open"))).toBe(true)
|
||||
expect(await page.evaluate(() => document.fullscreenElement)).toBeNull()
|
||||
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
|
||||
await expect.poll(() => stage.boundingBox()).toEqual({ x: 0, y: 0, width: 1440, height: 900 })
|
||||
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
|
||||
await expect(stage).not.toHaveAttribute("data-expanded")
|
||||
expect(await stage.evaluate((element) => element.matches(":popover-open"))).toBe(false)
|
||||
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
|
||||
await expect.poll(() => stage.boundingBox()).toEqual(inlineBounds)
|
||||
|
||||
await video.dblclick()
|
||||
await expect(exitFullscreen).toHaveAttribute("aria-pressed", "true")
|
||||
await page.mouse.click(8, 8)
|
||||
await expect(fullscreen).toHaveAttribute("aria-pressed", "false")
|
||||
expect(await video.evaluate((element) => element.dataset.e2eProbe)).toBe("retained")
|
||||
})
|
||||
|
||||
test("restores review state and the side-panel tab per session", async ({ page }) => {
|
||||
const sessions = [
|
||||
{ id: "ses_review_state_a", title: "Alpha review state" },
|
||||
@@ -630,7 +941,7 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
await page.getByRole("option", { name: "Branch changes" }).click()
|
||||
await page.getByRole("button", { name: "beta.ts" }).click()
|
||||
await selectedFile("beta.ts")
|
||||
await page.getByRole("button", { name: "View context usage" }).click()
|
||||
await page.getByRole("button", { name: "Toggle session context" }).click()
|
||||
await selectedTab("Context")
|
||||
|
||||
await switchSession("Gamma review state")
|
||||
@@ -664,9 +975,22 @@ test("restores review state and the side-panel tab per session", async ({ page }
|
||||
await selectedFile("gamma.ts")
|
||||
await review.press("End")
|
||||
await selectedTab("README.md")
|
||||
|
||||
// A hidden side-panel tab does not claim Close Tab while the side region is collapsed.
|
||||
await toggle.click()
|
||||
await expect(panel).toBeHidden()
|
||||
await page.keyboard.press("Control+w")
|
||||
await expect(page.locator("[data-titlebar-tab-slot]", { hasText: "Alpha review state" })).toHaveCount(0)
|
||||
await expectSessionTitle(page, "Beta review state")
|
||||
await page.keyboard.press("Control+Shift+T")
|
||||
await expectSessionTitle(page, "Alpha review state")
|
||||
await toggle.click()
|
||||
await selectedTab("README.md")
|
||||
|
||||
await page.keyboard.press("Control+w")
|
||||
await expect(panel.getByRole("tab", { name: "README.md", exact: true })).toHaveCount(0)
|
||||
await expect(review).toHaveAttribute("aria-selected", "true")
|
||||
await expectSessionTitle(page, "Alpha review state")
|
||||
})
|
||||
|
||||
test("shows and restores last turn changes from the session diff", async ({ page }) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Schema } from "effect"
|
||||
import { provider } from "../utils/app"
|
||||
import { holdRoute, provider } from "../utils/app"
|
||||
import type { MockServerConfig } from "../utils/mock-server"
|
||||
import { openSession } from "../utils/workspace"
|
||||
|
||||
@@ -160,6 +160,7 @@ async function openQueue(
|
||||
mock: ReturnType<typeof createQueueMock>,
|
||||
followUpBehavior?: "queue" | "steer",
|
||||
revert?: string,
|
||||
status: "idle" | "running" = "running",
|
||||
) {
|
||||
const model = { id: "queue-model", name: "Queue Model" }
|
||||
|
||||
@@ -175,7 +176,7 @@ async function openQueue(
|
||||
],
|
||||
provider: provider(model),
|
||||
pageMessages: () => ({ items: mock.messages }),
|
||||
sessionStatus: () => ({ [sessionID]: { type: "running" } }),
|
||||
sessionStatus: () => ({ [sessionID]: { type: status } }),
|
||||
inbox: () => [...mock.rows.map((row) => ({ ...row, payload: { ...row.payload } })), ...mock.compactions],
|
||||
onPrompt: mock.onPrompt,
|
||||
onCompact: mock.onCompact,
|
||||
@@ -223,6 +224,73 @@ test("follow-up preference controls Enter while Mod+Enter uses the alternate del
|
||||
await expect(view.input).toHaveText("")
|
||||
})
|
||||
|
||||
test("a steer shows as pending in the timeline as the composer clears, before its selection commits", async ({
|
||||
page,
|
||||
}) => {
|
||||
// The running execution has delivered input, so a new steer waits behind its work.
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[{ id: "msg_queue_delivered", type: "user", text: "First prompt", time: { created: 1 } }],
|
||||
)
|
||||
|
||||
const view = await openQueue(page, mock, "steer")
|
||||
const commit = await holdRoute(page, (url) => url.pathname === `/api/session/${sessionID}/model`, { method: "POST" })
|
||||
|
||||
const prompt = page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
|
||||
hasText: "steer while the model commits",
|
||||
})
|
||||
|
||||
await expect(userRow(page, "msg_queue_delivered")).toContainText("First prompt")
|
||||
await view.input.fill("steer while the model commits")
|
||||
await view.input.press("Enter")
|
||||
await commit.arrived
|
||||
await expect(view.input).toHaveText("")
|
||||
await expect(prompt).toBeVisible()
|
||||
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
|
||||
// It turns pending at once rather than fading from the delivered color it may have rendered with first.
|
||||
await expect(prompt.locator('[data-slot="user-message-text"]')).toHaveCSS("transition-duration", "0s")
|
||||
expect(mock.prompts).toEqual([])
|
||||
|
||||
commit.release()
|
||||
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["steer while the model commits"])
|
||||
await expect(prompt).toHaveCount(1)
|
||||
await expect(prompt.locator('[data-component="user-message"]')).toHaveAttribute("data-pending", "true")
|
||||
})
|
||||
|
||||
test("a steer into an idle session leaves the finished turn finished until delivery", async ({ page }) => {
|
||||
const mock = createQueueMock(
|
||||
[],
|
||||
[
|
||||
{ id: "msg_queue_finished_user", type: "user", text: "Inspect the queue.", time: { created: 1700000000000 } },
|
||||
{
|
||||
id: "msg_queue_finished_assistant",
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "queue-model", providerID: "opencode" },
|
||||
content: [{ type: "text", text: "The queue is fine." }],
|
||||
finish: "stop",
|
||||
time: { created: 1700000000001, completed: 1700000000002 },
|
||||
},
|
||||
{ id: "msg_queue_finished_idle", type: "idle", outcome: "succeeded", time: { created: 1700000000003 } },
|
||||
],
|
||||
)
|
||||
|
||||
const view = await openQueue(page, mock, "steer", undefined, "idle")
|
||||
const finished = page.getByRole("button", { name: "Copy response" })
|
||||
await expect(finished).toHaveCount(1)
|
||||
|
||||
await view.input.fill("start the next turn")
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.prompts.map((item) => item.text)).toEqual(["start the next turn"])
|
||||
await expect(
|
||||
page.locator('[data-timeline-virtual-content] [data-timeline-row="UserMessage"]', {
|
||||
hasText: "start the next turn",
|
||||
}),
|
||||
).toBeVisible()
|
||||
// The admitted steer waits for delivery; the finished turn above it keeps its copy action and duration.
|
||||
await expect(finished).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("dragging reorders queued prompts", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
|
||||
mock.rows[0].payload.files = [
|
||||
@@ -971,10 +1039,23 @@ for (const delivery of ["steer", "queue"] as const) {
|
||||
})
|
||||
.toBe(true)
|
||||
|
||||
// Working ends the work the model has seen, so the steer it has not seen yet waits below it.
|
||||
const working = transcript.locator('[data-component="session-working"]')
|
||||
await expect(tools.or(working).or(pending)).toHaveText([
|
||||
/^Used\s*2\s*Read, Grep$/,
|
||||
/Working/,
|
||||
/U2: Also check the retry path\./,
|
||||
])
|
||||
|
||||
mock.rows.splice(0, 1)
|
||||
mock.emit("session.inbox.delivered", { sessionID, inboxID })
|
||||
await expect(thinking).toHaveCount(0)
|
||||
await expect(pending).toHaveCount(1)
|
||||
await expect(tools.or(pending).or(working)).toHaveText([
|
||||
/^Used\s*2\s*Read, Grep$/,
|
||||
/U2: Also check the retry path\./,
|
||||
/Working/,
|
||||
])
|
||||
|
||||
if (colors) {
|
||||
await expect(bubble).toHaveAttribute("data-delivery-marker", "pending")
|
||||
|
||||
@@ -83,6 +83,93 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
expect((await reply).postDataJSON()).toEqual({ answer: { q0: "minimal" } })
|
||||
})
|
||||
|
||||
test("replies with predefined choices and the edited custom multiselect answer", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
forms: [
|
||||
{
|
||||
id: "frm_question_multiselect",
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question" },
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
type: "multiselect",
|
||||
title: "Include",
|
||||
description: "Which supporting surfaces should remain visible?",
|
||||
options: [
|
||||
{ value: "files", label: "Changed files" },
|
||||
{ value: "terminal", label: "Terminal" },
|
||||
],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(sessionID))
|
||||
await expectSessionTitle(page, title)
|
||||
const question = page.locator('[data-component="dock-prompt"][data-kind="question"]')
|
||||
const files = question.getByRole("checkbox", { name: "Changed files", exact: true })
|
||||
const terminal = question.getByRole("checkbox", { name: "Terminal", exact: true })
|
||||
const custom = question.getByRole("checkbox", { name: "Type your own answer…", exact: true })
|
||||
const input = question.getByRole("textbox", { name: "Type your own answer…", exact: true })
|
||||
const cancellations: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_multiselect`
|
||||
)
|
||||
cancellations.push(request.url())
|
||||
})
|
||||
|
||||
await expect(files).not.toBeChecked()
|
||||
await files.click()
|
||||
await input.fill("Original custom answer")
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await terminal.click()
|
||||
await expect(terminal).toBeChecked()
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
|
||||
await custom.click()
|
||||
await expect(custom).not.toBeChecked()
|
||||
await expect(input).toHaveValue("Original custom answer")
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
await input.focus()
|
||||
await expect(custom).not.toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
await custom.click()
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(input).toHaveValue("Original custom answer")
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
|
||||
await input.fill("Updated custom answer")
|
||||
await input.press("Escape")
|
||||
await expect(input).not.toBeFocused()
|
||||
await expect(question.getByRole("button", { name: "Submit", exact: true })).toBeEnabled()
|
||||
await expect(input).toHaveValue("Updated custom answer")
|
||||
await expect(custom).toBeChecked()
|
||||
await expect(files).toBeChecked()
|
||||
await expect(terminal).toBeChecked()
|
||||
expect(cancellations).toEqual([])
|
||||
|
||||
const reply = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_multiselect/reply`,
|
||||
)
|
||||
|
||||
await question.getByRole("button", { name: "Submit", exact: true }).press("Control+Enter")
|
||||
expect((await reply).postDataJSON()).toEqual({ answer: { q0: ["files", "terminal", "Updated custom answer"] } })
|
||||
expect(cancellations).toEqual([])
|
||||
})
|
||||
|
||||
test("shows a pending permission dock", async ({ page }) => {
|
||||
const replies: { sessionID: string; permissionID: string; body: unknown }[] = []
|
||||
await mockServer(page, {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { ConfigEntry, OpenCodeEvent, WorktreeDirectory } from "@opencode/client/promise"
|
||||
import { NO_PROVIDER, REMOTE_SERVER, SERVER, holdRoute, project, session } from "../utils/app"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { mockMissingLocation, mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { mockRemoteServer, mockWorkspace, openSettings, type WorkspaceInput } from "../utils/workspace"
|
||||
|
||||
const directory = "C:/Projects/settings-demo"
|
||||
@@ -248,6 +248,41 @@ test("project settings open as a nested view that keeps its route", async ({ pag
|
||||
await expect(settings.getByRole("heading", { name: "Projects", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
// The desktop app reopens its last settings URL on launch, so a crash on this page would repeat on every restart.
|
||||
test("a project whose folder is gone opens its settings with a notice", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Settings demo",
|
||||
directory,
|
||||
provider: NO_PROVIDER,
|
||||
sessions: [],
|
||||
seed: { tabs: [] },
|
||||
})
|
||||
await mockMissingLocation(page, directory)
|
||||
|
||||
const plugins = page.waitForResponse((response) => {
|
||||
const url = new URL(response.url())
|
||||
|
||||
return url.pathname === "/api/plugin" && url.searchParams.get("location[directory]") === directory
|
||||
})
|
||||
|
||||
await page.goto(
|
||||
`/settings?server=${encodeURIComponent(SERVER)}&project=${encodeURIComponent(directory)}&tab=extensions`,
|
||||
)
|
||||
expect((await plugins).status()).toBe(404)
|
||||
const settings = page.getByTestId("settings-screen")
|
||||
const notice = settings.getByRole("status").filter({ hasText: "Project folder not found" })
|
||||
await expect(notice).toContainText(directory)
|
||||
await settings.getByRole("tab", { name: "Plugins", exact: true }).click()
|
||||
|
||||
await expect(
|
||||
settings.getByRole("tabpanel", { name: "Plugins", exact: true }).getByText("No plugins yet", { exact: true }),
|
||||
).toBeVisible()
|
||||
|
||||
await settings.getByRole("tab", { name: "General", exact: true }).click()
|
||||
await expect(settings.getByRole("textbox", { name: "Project name", exact: true })).toBeVisible()
|
||||
await expect(notice).toContainText(directory)
|
||||
})
|
||||
|
||||
for (const field of [
|
||||
{
|
||||
name: "color",
|
||||
@@ -750,6 +785,464 @@ for (const row of [
|
||||
})
|
||||
}
|
||||
|
||||
const consoleIntegrations = [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [
|
||||
{ id: "device", type: "oauth", label: "OpenCode Console account" },
|
||||
{ type: "key", label: "API key (service account)" },
|
||||
],
|
||||
connections: [],
|
||||
},
|
||||
{ id: "opencode-go", name: "OpenCode Go", methods: [{ type: "key" }], connections: [] },
|
||||
]
|
||||
|
||||
for (const row of [
|
||||
{ provider: "OpenCode Console", connect: "Connect OpenCode Console", signIn: "Connecting to OpenCode Console" },
|
||||
{ provider: "OpenCode Go", connect: "Connect OpenCode Go", signIn: "Connecting to OpenCode Go" },
|
||||
]) {
|
||||
test(`providers: ${row.provider} signs in from the Console intro`, async ({ page, context }) => {
|
||||
const url = "https://auth.example.test/device?user_code=ABCD-EFGH"
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
const { settings } = await open(page, { integrations: consoleIntegrations, onIntegrationOAuth: () => ({ url }) })
|
||||
|
||||
// Registered after the mock server so it sees the sign-in request first.
|
||||
const start = await holdRoute(page, (url) => url.pathname === "/api/integration/opencode/connect/oauth", {
|
||||
method: "POST",
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await settings
|
||||
.locator(".settings-provider-row", { hasText: row.provider })
|
||||
.getByRole("button", { name: "Connect", exact: true })
|
||||
.click()
|
||||
const intro = page.getByRole("dialog", { name: row.connect, exact: true })
|
||||
const container = page.locator('[data-slot="dialog-container"]')
|
||||
await expect(
|
||||
intro.getByText("Sign in with your OpenCode Console account to use the available models."),
|
||||
).toBeVisible()
|
||||
await expect(intro.getByRole("button", { name: "Use API key", exact: true })).toBeVisible()
|
||||
expect((await container.boundingBox())?.height).toBe(512)
|
||||
|
||||
const popup = page.waitForEvent("popup")
|
||||
await intro.getByRole("button", { name: "Continue in the browser", exact: true }).click()
|
||||
await start.arrived
|
||||
await expect(intro.getByRole("button", { name: "Opening browser…", exact: true })).toBeDisabled()
|
||||
expect((await container.boundingBox())?.height).toBe(512)
|
||||
start.release()
|
||||
await expect(await popup).toHaveURL(url)
|
||||
|
||||
const signIn = page.getByRole("dialog", { name: row.signIn, exact: true })
|
||||
await expect(signIn.getByRole("group", { name: "Device code: ABCD-EFGH", exact: true })).toBeVisible()
|
||||
await expect(signIn.getByRole("button", { name: "Copy sign-in link", exact: true })).toBeVisible()
|
||||
await expect(signIn.getByRole("button", { name: "Use API key", exact: true })).toHaveCount(0)
|
||||
await expect.poll(async () => (await container.boundingBox())?.height).toBeLessThan(512)
|
||||
})
|
||||
}
|
||||
|
||||
test("providers: the connect dialog lists connected providers and manages their accounts", async ({ page }) => {
|
||||
const accounts = [
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "key" },
|
||||
]
|
||||
|
||||
const state = { active: "cred_work" }
|
||||
const activated: string[] = []
|
||||
|
||||
const { settings } = await open(page, {
|
||||
integrations: () => [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
|
||||
},
|
||||
{ id: "anthropic", name: "Anthropic", methods: [{ type: "key", label: "API key" }], connections: [] },
|
||||
],
|
||||
onCredentialActivate: (credentialID) => {
|
||||
activated.push(credentialID)
|
||||
state.active = credentialID
|
||||
},
|
||||
onCredentialUpdate: (credentialID, body) => {
|
||||
const account = accounts.find((item) => item.id === credentialID)
|
||||
|
||||
if (account && body.label) account.label = body.label
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Show more providers", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
const openai = dialog.locator('[data-provider-id="openai"]')
|
||||
const count = openai.locator('[data-component="tag"]')
|
||||
await expect(count).toHaveText("2")
|
||||
await expect(count).toHaveAttribute("aria-label", "2 accounts")
|
||||
await expect(dialog.locator('[data-provider-id="anthropic"]').locator('[data-component="tag"]')).toHaveCount(0)
|
||||
await openai.click()
|
||||
|
||||
await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible()
|
||||
const list = dialog.getByRole("radiogroup", { name: "Connected accounts", exact: true })
|
||||
await expect(list.getByRole("radio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
// Rows keep their order when the active account changes.
|
||||
await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"])
|
||||
await list.getByRole("radio", { name: "Personal", exact: true }).click()
|
||||
await expect(list.getByRole("radio", { name: "Personal", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
await expect(list.getByRole("radio")).toHaveText(["Personal", "Work"])
|
||||
expect(activated).toEqual(["cred_personal"])
|
||||
|
||||
await list.getByRole("button", { name: "Personal options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const name = list.getByRole("textbox", { name: "Account name", exact: true })
|
||||
await expect(name).toBeFocused()
|
||||
await expect(name).toHaveValue("Personal")
|
||||
await name.fill("Home")
|
||||
await name.press("Enter")
|
||||
await expect(list.getByRole("radio")).toHaveText(["Home", "Work"])
|
||||
await expect(list.getByRole("radio", { name: "Home", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
|
||||
await list.getByRole("button", { name: "Home options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
await name.fill("Ignored")
|
||||
await name.press("Escape")
|
||||
await expect(list.getByRole("radio")).toHaveText(["Home", "Work"])
|
||||
await expect(page.getByRole("dialog", { name: "OpenAI", exact: true })).toBeVisible()
|
||||
|
||||
await list.getByRole("button", { name: "Work options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
|
||||
await expect(dialog.getByText("Remove Work?", { exact: true })).toBeVisible()
|
||||
|
||||
const removed = page.waitForRequest(
|
||||
(request) => request.method() === "DELETE" && new URL(request.url()).pathname === "/api/credential/cred_work",
|
||||
)
|
||||
|
||||
await dialog.getByRole("button", { name: "Remove", exact: true }).click()
|
||||
await removed
|
||||
|
||||
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
|
||||
await expect(dialog.getByPlaceholder("API key")).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
await expect(list).toBeVisible()
|
||||
})
|
||||
|
||||
test("providers: adding a Go account uses a Go key, and a lone sign-in starts once", async ({ page, context }) => {
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
const attempts: string[] = []
|
||||
|
||||
const { settings } = await open(page, {
|
||||
integrations: [
|
||||
...consoleIntegrations.filter((integration) => integration.id !== "opencode-go"),
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
methods: [{ type: "key" }],
|
||||
connections: [{ type: "credential", id: "cred_go", label: "Go key", method: "key" }],
|
||||
},
|
||||
{
|
||||
id: "github-copilot",
|
||||
name: "GitHub Copilot",
|
||||
methods: [{ id: "device", type: "oauth", label: "GitHub" }],
|
||||
connections: [{ type: "credential", id: "cred_gh", label: "Octocat", method: "oauth" }],
|
||||
},
|
||||
],
|
||||
onIntegrationOAuth: (input) => {
|
||||
attempts.push(input.integrationID)
|
||||
|
||||
return { url: "https://auth.example.test/device?user_code=GH12-3456" }
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await settings.getByRole("button", { name: "Show more providers", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.locator('[data-provider-id="opencode-go"]').click()
|
||||
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
|
||||
await expect(dialog.getByPlaceholder("API key")).toBeVisible()
|
||||
expect(attempts).toEqual([])
|
||||
|
||||
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
await expect(dialog.getByRole("radio", { name: "Go key", exact: true })).toBeVisible()
|
||||
await dialog.getByRole("button", { name: "Navigate back", exact: true }).click()
|
||||
await dialog.locator('[data-provider-id="github-copilot"]').click()
|
||||
const popup = page.waitForEvent("popup")
|
||||
await dialog.getByRole("button", { name: "Add another account", exact: true }).click()
|
||||
await popup
|
||||
await expect.poll(() => attempts).toEqual(["github-copilot"])
|
||||
expect(attempts).toEqual(["github-copilot"])
|
||||
})
|
||||
|
||||
test("providers: the account options rename and remove the active account", async ({ page }) => {
|
||||
const account = { type: "credential", id: "cred_work", label: "Work", method: "key" }
|
||||
const other = { type: "credential", id: "cred_personal", label: "Personal", method: "key" }
|
||||
const removed: string[] = []
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: { all: [{ id: "openai", name: "OpenAI", models: {} }], connected: ["openai"], default: {} },
|
||||
integrations: () => [
|
||||
{
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
connections: [account, other].filter((item) => !removed.includes(item.id)),
|
||||
},
|
||||
],
|
||||
onCredentialUpdate: (credentialID, body) => {
|
||||
const target = [account, other].find((item) => item.id === credentialID)
|
||||
|
||||
if (target && body.label) target.label = body.label
|
||||
},
|
||||
})
|
||||
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname
|
||||
|
||||
if (request.method() === "DELETE" && path.startsWith("/api/credential/")) removed.push(path.split("/").at(-1)!)
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const trigger = settings.getByRole("button", { name: "Manage OpenAI accounts", exact: true })
|
||||
await expect(trigger).toHaveText("Work")
|
||||
|
||||
await trigger.click()
|
||||
await expect(page.getByRole("menuitemradio", { name: "Work", exact: true })).toHaveAttribute("aria-checked", "true")
|
||||
await expect(page.getByRole("menuitem", { name: "Add account", exact: true })).toBeVisible()
|
||||
await page.keyboard.press("Escape")
|
||||
|
||||
const options = settings.getByRole("button", { name: "Work options", exact: true })
|
||||
await expect(options).toHaveCSS("height", "28px")
|
||||
await options.click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
const name = settings.getByRole("textbox", { name: "Account name", exact: true })
|
||||
await expect(name).toBeFocused()
|
||||
await expect(name).toHaveValue("Work")
|
||||
await name.fill("Team")
|
||||
await name.press("Enter")
|
||||
await expect(trigger).toHaveText("Team")
|
||||
expect(other.label).toBe("Personal")
|
||||
|
||||
await settings.getByRole("button", { name: "Team options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
await name.fill("Ignored")
|
||||
await name.press("Escape")
|
||||
await expect(trigger).toHaveText("Team")
|
||||
|
||||
await settings.getByRole("button", { name: "Team options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Rename", exact: true }).click()
|
||||
await name.fill("Shared")
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
await expect(trigger).toHaveText("Shared")
|
||||
|
||||
await settings.getByRole("button", { name: "Shared options", exact: true }).click()
|
||||
await page.getByRole("menuitem", { name: "Remove", exact: true }).click()
|
||||
await expect.poll(() => removed).toEqual(["cred_work"])
|
||||
})
|
||||
|
||||
test("providers: switching to a Zen API key does not wait for a Console workspace", async ({ page }) => {
|
||||
const accounts = [
|
||||
{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" },
|
||||
{ type: "credential", id: "cred_zen", label: "Zen key", method: "key" },
|
||||
]
|
||||
|
||||
const state = { active: "cred_console" }
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: () =>
|
||||
state.active === "cred_console"
|
||||
? {
|
||||
all: [
|
||||
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} },
|
||||
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} },
|
||||
],
|
||||
connected: ["opencode", "console-google"],
|
||||
default: {},
|
||||
}
|
||||
: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Zen",
|
||||
models: { paid: { id: "paid", name: "Paid", cost: { input: 3 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: {},
|
||||
},
|
||||
integrations: () => [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
|
||||
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
|
||||
},
|
||||
],
|
||||
onCredentialActivate: (credentialID) => {
|
||||
state.active = credentialID
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const trigger = settings.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true })
|
||||
await expect(trigger).toHaveText("Anomaly")
|
||||
await trigger.click()
|
||||
|
||||
const catalogReads: string[] = []
|
||||
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "GET" && new URL(request.url()).pathname === "/api/provider")
|
||||
catalogReads.push(request.url())
|
||||
})
|
||||
await page.getByRole("menuitemradio", { name: "Zen key", exact: true }).click()
|
||||
await expect(page.getByText("OpenCode Console account switched")).toBeVisible()
|
||||
// One refresh after the switch; a Console workspace poll would keep reading the catalog.
|
||||
expect(catalogReads).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("providers: connecting OpenCode Go keeps the Console row in place", async ({ page, context }) => {
|
||||
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
|
||||
|
||||
// After the sign-in the server reloads the workspace, briefly serving only the free catalog.
|
||||
const state = { authorized: false, pendingReads: 0 }
|
||||
|
||||
const workspace = {
|
||||
all: [
|
||||
{ id: "opencode", integrationID: "opencode", name: "Anomaly / OpenCode", models: {} },
|
||||
{ id: "console-google", integrationID: "opencode", name: "Anomaly / Google", models: {} },
|
||||
],
|
||||
connected: ["opencode", "console-google"],
|
||||
default: {},
|
||||
}
|
||||
|
||||
const catalog = () => {
|
||||
if (state.pendingReads > 0) {
|
||||
state.pendingReads--
|
||||
|
||||
return { all: [{ id: "opencode", name: "OpenCode", models: {} }], connected: ["opencode"], default: {} }
|
||||
}
|
||||
|
||||
if (!state.authorized) return workspace
|
||||
|
||||
return {
|
||||
all: [...workspace.all, { id: "opencode-go", integrationID: "opencode-go", name: "OpenCode Go", models: {} }],
|
||||
connected: [...workspace.connected, "opencode-go"],
|
||||
default: {},
|
||||
}
|
||||
}
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: catalog,
|
||||
integrations: () => [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
|
||||
connections: [{ type: "credential", id: "cred_console", label: "Anomaly", method: "oauth" }],
|
||||
},
|
||||
{
|
||||
id: "opencode-go",
|
||||
name: "OpenCode Go",
|
||||
methods: [{ type: "key" }],
|
||||
connections: state.authorized ? [{ type: "credential", id: "cred_go", label: "Anomaly", method: "oauth" }] : [],
|
||||
},
|
||||
],
|
||||
onIntegrationOAuth: () => ({ url: "https://auth.example.test/device?user_code=GOGO-1234" }),
|
||||
onIntegrationOAuthStatus: () => {
|
||||
if (!state.authorized) {
|
||||
state.authorized = true
|
||||
state.pendingReads = 3
|
||||
}
|
||||
|
||||
return "complete"
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const connected = settings.locator('[data-component="connected-providers-section"]')
|
||||
await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible()
|
||||
await connected.evaluate((element) => {
|
||||
new MutationObserver(() => {
|
||||
if (!element.textContent?.includes("OpenCode Console")) element.dataset.flashed = ""
|
||||
}).observe(element, { childList: true, subtree: true, characterData: true })
|
||||
})
|
||||
|
||||
await settings
|
||||
.locator(".settings-provider-row", { hasText: "OpenCode Go" })
|
||||
.getByRole("button", { name: "Connect", exact: true })
|
||||
.click()
|
||||
const popup = page.waitForEvent("popup")
|
||||
await page.getByRole("button", { name: "Continue in the browser", exact: true }).click()
|
||||
await popup
|
||||
await expect(page.getByText("OpenCode Go connected", { exact: true })).toBeVisible()
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0)
|
||||
|
||||
await expect(connected.getByText("OpenCode Go", { exact: true })).toBeVisible()
|
||||
await expect(connected.getByText("OpenCode Console", { exact: true })).toBeVisible()
|
||||
await expect(connected).not.toHaveAttribute("data-flashed")
|
||||
})
|
||||
|
||||
test("providers: switching Console accounts keeps the list until the new workspace loads", async ({ page }) => {
|
||||
const accounts = [
|
||||
{ type: "credential", id: "cred_clara", label: "Clara", method: "oauth" },
|
||||
{ type: "credential", id: "cred_anomaly", label: "Anomaly", method: "oauth" },
|
||||
]
|
||||
|
||||
// The server loads the new workspace's providers a few catalog reads after the switch.
|
||||
const state = { active: "cred_clara", pendingReads: 0 }
|
||||
const workspace = () => accounts.find((account) => account.id === state.active)!.label
|
||||
|
||||
const catalog = () => {
|
||||
if (state.pendingReads > 0) {
|
||||
state.pendingReads--
|
||||
|
||||
return {
|
||||
all: [{ id: "opencode", name: "OpenCode", models: { free: { id: "free", name: "Free", cost: { input: 0 } } } }],
|
||||
connected: ["opencode"],
|
||||
default: {},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
all: [
|
||||
{ id: "opencode", integrationID: "opencode", name: `${workspace()} / OpenCode`, models: {} },
|
||||
{ id: "console-google", integrationID: "opencode", name: `${workspace()} / Google`, models: {} },
|
||||
],
|
||||
connected: ["opencode", "console-google"],
|
||||
default: {},
|
||||
}
|
||||
}
|
||||
|
||||
const { settings } = await open(page, {
|
||||
provider: catalog,
|
||||
integrations: () => [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode Console",
|
||||
methods: [{ id: "device", type: "oauth", label: "OpenCode Console account" }],
|
||||
connections: accounts.toSorted((a, b) => Number(b.id === state.active) - Number(a.id === state.active)),
|
||||
},
|
||||
],
|
||||
onCredentialActivate: (credentialID) => {
|
||||
state.active = credentialID
|
||||
state.pendingReads = 2
|
||||
},
|
||||
})
|
||||
|
||||
await settings.getByRole("tab", { name: "Providers", exact: true }).click()
|
||||
const connected = settings.locator('[data-component="connected-providers-section"]')
|
||||
const trigger = connected.getByRole("button", { name: "Manage OpenCode Console accounts", exact: true })
|
||||
await expect(trigger).toHaveText("Clara")
|
||||
await connected.evaluate((element) => {
|
||||
new MutationObserver(() => {
|
||||
if (element.textContent?.includes("No connected providers")) element.dataset.flashed = ""
|
||||
}).observe(element, { childList: true, subtree: true, characterData: true })
|
||||
})
|
||||
|
||||
await trigger.click()
|
||||
await page.getByRole("menuitemradio", { name: "Anomaly", exact: true }).click()
|
||||
await expect(page.getByText("OpenCode Console account switched")).toBeVisible()
|
||||
await expect(trigger).toHaveText("Anomaly")
|
||||
await expect(connected).not.toHaveAttribute("data-flashed")
|
||||
})
|
||||
|
||||
test("the add server dialog keeps focus above fullscreen settings", async ({ page }) => {
|
||||
await mockRemoteServer(page, { directory: "/remote/settings-demo" })
|
||||
const { settings } = await open(page)
|
||||
|
||||
@@ -56,6 +56,71 @@ test("tab strip keeps draft tabs as wide as session tabs and navigates on mouse
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
})
|
||||
|
||||
test("inactive session attention dots share the close control without marking the avatar or active tab", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b],
|
||||
sessionPermissions: {
|
||||
[b.id]: [
|
||||
{ id: "permission-tab-b", sessionID: b.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
await page.goto(sessionHref(a.id))
|
||||
const tabA = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
const dot = tabB.locator('[data-slot="tab-unread-dot"]')
|
||||
const close = tabB.getByRole("button", { name: "Close tab", exact: true })
|
||||
await expect(tabA.locator('[data-slot="tab-title"]')).toHaveText(a.title)
|
||||
await expect(tabB.locator('[data-slot="tab-title"]')).toHaveText(b.title)
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(dot).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
|
||||
await expect(close).toHaveCSS("opacity", "0")
|
||||
await expect(tabB.locator('[data-slot="project-avatar-unread-dot"]')).toHaveCount(0)
|
||||
await expect
|
||||
.poll(() =>
|
||||
dot.evaluate((element) => {
|
||||
const dot = element.getBoundingClientRect()
|
||||
const slot = element.parentElement!.getBoundingClientRect()
|
||||
|
||||
return (
|
||||
Math.abs(dot.x + dot.width / 2 - slot.x - slot.width / 2) +
|
||||
Math.abs(dot.y + dot.height / 2 - slot.y - slot.height / 2)
|
||||
)
|
||||
}),
|
||||
)
|
||||
.toBeLessThan(1)
|
||||
|
||||
await tabB.hover()
|
||||
await expect(dot).toBeHidden()
|
||||
await expect(close).toHaveCSS("opacity", "1")
|
||||
await page.mouse.move(0, 0)
|
||||
await tabB.locator("[data-titlebar-tab-link]").focus()
|
||||
await page.keyboard.press("Tab")
|
||||
await expect(close).toBeFocused()
|
||||
await expect(dot).toBeHidden()
|
||||
await expect(close).toHaveCSS("opacity", "1")
|
||||
await close.blur()
|
||||
await expect(dot).toBeVisible()
|
||||
|
||||
await tabB.locator("[data-titlebar-tab-link]").click()
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(tabB).toHaveAttribute("data-active", "true")
|
||||
await expect(dot).toHaveCount(0)
|
||||
await tabA.locator("[data-titlebar-tab-link]").click()
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(dot).toBeVisible()
|
||||
await tabB.hover()
|
||||
await close.click()
|
||||
await expect(tabB).toHaveCount(0)
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
})
|
||||
|
||||
test("a tab does not reopen its title editor while a rename is saving", async ({ page }) => {
|
||||
await mockWorkspace(page, { name: "Tabs", sessions: [a, b] })
|
||||
await page.goto(sessionHref(a.id))
|
||||
@@ -97,44 +162,139 @@ test("keyboard navigation follows the visible tab order and skips unresolved tab
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
test("option arrows move to the previous or next tab, and with shift to the next unread one", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
sessionPermissions: {
|
||||
[c.id]: [
|
||||
{ id: "permission-tab-c", sessionID: c.id, action: "shell", resources: ["git status"], metadata: {}, save: [] },
|
||||
],
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.mouse.move(0, 0)
|
||||
await expect(
|
||||
page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(c.id)}"]) [data-slot="tab-unread-dot"]`),
|
||||
).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
|
||||
await page.keyboard.press("Alt+ArrowUp")
|
||||
await expectPath(page, sessionHref(a.id))
|
||||
|
||||
await page.keyboard.press("Shift+Alt+ArrowDown")
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
})
|
||||
|
||||
for (const row of [
|
||||
{ tabLayout: "horizontal", width: 360 },
|
||||
{ tabLayout: "vertical", width: 390 },
|
||||
{ tabLayout: "horizontal", width: 360, hasTouch: false, direction: "ltr" },
|
||||
{ tabLayout: "vertical", width: 390, hasTouch: true, direction: "rtl" },
|
||||
]) {
|
||||
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: row.width, height: 720 })
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
|
||||
test.describe(`mobile drawer (${row.tabLayout})`, () => {
|
||||
test.use({ hasTouch: row.hasTouch })
|
||||
test(`mobile drawer exposes close controls and navigates between tabs (${row.tabLayout})`, async ({ page }) => {
|
||||
await page.setViewportSize({ width: row.width, height: 720 })
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
sessionPermissions: {
|
||||
[b.id]: [
|
||||
{
|
||||
id: "permission-mobile-b",
|
||||
sessionID: b.id,
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
seed: { settings: { appearance: { tabLayout: row.tabLayout } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.locator("html").evaluate((element, direction) => element.setAttribute("dir", direction), row.direction)
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
|
||||
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
await expect(tabA).toHaveAttribute("data-active", "true")
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-unread-dot"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("width", "20px")
|
||||
await expect(tabB.locator('[data-slot="tab-unread-slot"]')).toHaveCSS("height", "20px")
|
||||
await expect
|
||||
.poll(() =>
|
||||
tabB.evaluate((element) => {
|
||||
const dot = element.querySelector('[data-slot="tab-unread-slot"]')!.getBoundingClientRect()
|
||||
const close = element.querySelector('[data-slot="tab-close"]')!.getBoundingClientRect()
|
||||
|
||||
return dot.right <= close.left || dot.left >= close.right
|
||||
}),
|
||||
)
|
||||
.toBe(true)
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeHidden()
|
||||
await expect(tabB.getByRole("button", { name: "Close tab", exact: true })).toHaveCSS("opacity", "1")
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
|
||||
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
|
||||
|
||||
if (row.tabLayout !== "vertical") return
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
await page.getByRole("button", { name: "Tabs", exact: true }).click()
|
||||
|
||||
const drawer = page.locator('[data-slot="mobile-tabs-drawer"]')
|
||||
const tabA = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(a.id)}"])`)
|
||||
const tabB = drawer.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
await expect(tabA).toHaveAttribute("data-active", "true")
|
||||
await expect(tabA.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeVisible()
|
||||
await expect(page.locator('[data-slot="vertical-tabs-sidebar"]')).toHaveCount(0)
|
||||
|
||||
await tabB.locator(`a[href="${sessionHref(b.id)}"]`).click()
|
||||
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(page.getByRole("dialog", { name: "Tabs", exact: true })).toBeHidden()
|
||||
|
||||
if (row.tabLayout !== "vertical") return
|
||||
await page.setViewportSize({ width: 1280, height: 720 })
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
.locator(`[data-titlebar-tab-link][href="${sessionHref(b.id)}"]`),
|
||||
).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"]')).toHaveCount(0)
|
||||
})
|
||||
}
|
||||
|
||||
test("icon-only inactive tabs retain their avatar and attention badge instead of showing a close overlay", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "CompactTabs",
|
||||
sessions: [
|
||||
a,
|
||||
b,
|
||||
...Array.from({ length: 30 }, (_, index) => ({ id: `ses_compact_${index}`, title: `Compact ${index}` })),
|
||||
],
|
||||
sessionPermissions: {
|
||||
[b.id]: [
|
||||
{
|
||||
id: "permission-compact-b",
|
||||
sessionID: b.id,
|
||||
action: "shell",
|
||||
resources: ["git status"],
|
||||
metadata: {},
|
||||
save: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
const tabB = page.locator(`[data-titlebar-tab-slot]:has(a[href="${sessionHref(b.id)}"])`)
|
||||
await expect(tabB.locator('[data-slot="tab-title"]')).toBeHidden()
|
||||
await expect(tabB.locator('[data-component="project-avatar-v2"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toBeVisible()
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("width", "6px")
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCSS("height", "6px")
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await tabB.hover()
|
||||
await expect(tabB.locator('[data-slot="tab-close"]')).toBeHidden()
|
||||
await tabB.locator("[data-titlebar-tab-link]").click()
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
await expect(tabB.locator('[data-slot="tab-avatar-unread-dot"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 480 })
|
||||
const directory = "C:/OpenCode/Tabs"
|
||||
@@ -202,6 +362,141 @@ test("vertical tabs resize, scroll, show shortcut hints, and navigate", async ({
|
||||
await expectPath(page, sessionHref(b.id))
|
||||
})
|
||||
|
||||
test("vertical tab gaps hover and select the nearest row", async ({ page }) => {
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c],
|
||||
seed: { settings: { appearance: { tabLayout: "vertical" } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const rows = sidebar.locator("[data-titlebar-tab]")
|
||||
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
|
||||
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title])
|
||||
const upper = await tabB.boundingBox()
|
||||
const lower = await tabC.boundingBox()
|
||||
|
||||
if (!upper || !lower) throw new Error("vertical tabs have no bounding boxes")
|
||||
expect(lower.y - upper.y - upper.height).toBe(4)
|
||||
|
||||
const middle = upper.y + upper.height + 2
|
||||
|
||||
// The two halves belong to their nearest row; an exact tie belongs to the next row.
|
||||
for (const [offset, id] of [
|
||||
[-1, b.id],
|
||||
[0, c.id],
|
||||
] as const) {
|
||||
await page.mouse.move(upper.x + upper.width / 2, middle + offset)
|
||||
await expect(sidebar.locator("[data-titlebar-tab]:hover [data-titlebar-tab-link]")).toHaveAttribute(
|
||||
"href",
|
||||
sessionHref(id),
|
||||
)
|
||||
}
|
||||
|
||||
await page.mouse.click(upper.x + upper.width / 2, middle + 1)
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
|
||||
await page.mouse.move(upper.x + upper.width / 2, middle - 1)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(upper.x + upper.width / 2, lower.y + lower.height - 2, { steps: 8 })
|
||||
await page.mouse.up()
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title, b.title])
|
||||
|
||||
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, c.title])
|
||||
await expectPath(page, sessionHref(c.id))
|
||||
|
||||
const list = await sidebar.locator("[data-titlebar-tab-list]").boundingBox()
|
||||
|
||||
if (!list) throw new Error("vertical tab list has no bounding box")
|
||||
|
||||
for (const [x, y] of [
|
||||
[list.x + list.width / 2, list.y - 1],
|
||||
[list.x + list.width / 2, list.y + list.height + 1],
|
||||
[list.x - 1, list.y + list.height / 2],
|
||||
[list.x + list.width + 1, list.y + list.height / 2],
|
||||
]) {
|
||||
await page.mouse.move(x, y)
|
||||
await expect(sidebar.locator("[data-titlebar-tab]:hover")).toHaveCount(0)
|
||||
}
|
||||
})
|
||||
|
||||
test("tab preview opens cold, switches immediately between tabs, and closes outside the strip", async ({ page }) => {
|
||||
const d = { id: "ses_tab_d", title: "Library" }
|
||||
await mockWorkspace(page, {
|
||||
name: "Tabs",
|
||||
sessions: [a, b, c, d],
|
||||
seed: { settings: { appearance: { tabLayout: "vertical" } } },
|
||||
})
|
||||
await page.goto(sessionHref(a.id))
|
||||
const sidebar = page.locator('[data-slot="vertical-tabs-sidebar"]')
|
||||
const rows = sidebar.locator("[data-titlebar-tab]")
|
||||
const tabB = rows.filter({ has: page.locator(`a[href="${sessionHref(b.id)}"]`) })
|
||||
const tabC = rows.filter({ has: page.locator(`a[href="${sessionHref(c.id)}"]`) })
|
||||
const preview = page.locator('[data-component="session-tab-popover"]')
|
||||
await expect(rows.locator("[data-titlebar-tab-title]")).toHaveText([a.title, b.title, c.title, d.title])
|
||||
await page.clock.install()
|
||||
await page.clock.pauseAt(new Date())
|
||||
await tabB.hover()
|
||||
await page.clock.runFor(700)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await page.clock.runFor(50)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await page.clock.runFor(10_000)
|
||||
await expect(preview).toContainText(b.title)
|
||||
|
||||
const box = await tabB.boundingBox()
|
||||
|
||||
if (!box) throw new Error("vertical tab has no bounding box")
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 1)
|
||||
await page.clock.runFor(1000)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await tabB.getByRole("button", { name: "Close tab", exact: true }).hover()
|
||||
await page.clock.runFor(1000)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await tabC.hover({ position: { x: box.width - 1, y: box.height / 2 } })
|
||||
await page.clock.runFor(20)
|
||||
await expect(preview).toHaveCount(1)
|
||||
await expect(preview).toContainText(c.title)
|
||||
|
||||
await expect(preview).toHaveCSS("pointer-events", "none")
|
||||
await rows.filter({ has: page.locator(`a[href="${sessionHref(d.id)}"]`) }).hover()
|
||||
await page.clock.runFor(20)
|
||||
await expect(preview).toHaveCount(1)
|
||||
await expect(preview).toContainText(d.title)
|
||||
const card = await preview.boundingBox()
|
||||
|
||||
if (!card) throw new Error("tab preview has no bounding box")
|
||||
// The card is informational: entering its painted area leaves the tab strip.
|
||||
await page.mouse.move(card.x + card.width / 2, card.y + card.height / 2)
|
||||
await page.clock.runFor(20)
|
||||
await expect(preview).toHaveCount(0)
|
||||
|
||||
await tabB.hover()
|
||||
await page.clock.runFor(800)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height + 20, { steps: 8 })
|
||||
await page.clock.runFor(1000)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await page.mouse.up()
|
||||
|
||||
// Removing an open tab must not leave the shared preview state permanently warm.
|
||||
await tabB.hover()
|
||||
await page.clock.runFor(800)
|
||||
await expect(preview).toContainText(b.title)
|
||||
await tabB.getByRole("button", { name: "Close tab", exact: true }).click()
|
||||
await page.mouse.move(900, 500)
|
||||
await page.clock.runFor(600)
|
||||
await tabC.hover()
|
||||
await page.clock.runFor(700)
|
||||
await expect(preview).toHaveCount(0)
|
||||
await page.clock.runFor(50)
|
||||
await expect(preview).toContainText(c.title)
|
||||
})
|
||||
|
||||
test("closing the active server's last tab opens the remaining server tab", async ({ page }) => {
|
||||
const sessionA = session({ id: "ses_server_a", directory: "C:/server-a", title: "Server A session" })
|
||||
const sessionB = session({ id: "ses_server_b", directory: "/home/server-b", title: "Server B session" })
|
||||
@@ -533,6 +828,70 @@ test("each session tab shows its own file tab after a switch to a session in ano
|
||||
}
|
||||
})
|
||||
|
||||
test("tab shortcuts cycle side-panel tabs when the side panel is focused with multiple tabs", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
const directory = "C:/OpenCode/SidePanelTabs"
|
||||
const alpha = { id: "ses_side_tabs_alpha", title: "Side tabs alpha" }
|
||||
const beta = { id: "ses_side_tabs_beta", title: "Side tabs beta" }
|
||||
await openSession(page, {
|
||||
name: "SidePanelTabs",
|
||||
sessions: [alpha, beta],
|
||||
fileList: (path) => (path ? [] : ["README.md"].map((file) => fileNode(directory, file))),
|
||||
fileContent: (path) => ({ type: "text", content: `contents:${path}` }),
|
||||
seed: { panes: { [alpha.id]: { review: true }, [beta.id]: { review: true } } },
|
||||
})
|
||||
|
||||
const panel = page.locator("#review-panel")
|
||||
const reviewTab = panel.getByRole("tab", { name: "Review", exact: true })
|
||||
const fileTab = panel.getByRole("tab", { name: "README.md", exact: true })
|
||||
const composer = page.getByRole("textbox", { name: "Prompt", exact: true })
|
||||
|
||||
await panel.getByRole("button", { name: "Open file" }).click()
|
||||
await panel.getByRole("button", { name: "README.md", exact: true }).click()
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible()
|
||||
|
||||
// Focus outside the side panel still cycles session tabs even when multiple side-panel tabs are open.
|
||||
await composer.click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expectPath(page, sessionHref(beta.id))
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
// Focus inside the side panel cycles the drawn side-panel tabs and keeps focus inside the side panel.
|
||||
await panel.getByText("contents:README.md", { exact: true }).click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("Control+Shift+Tab")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowRight")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
await page.keyboard.press("ControlOrMeta+Alt+ArrowLeft")
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await expectPath(page, sessionHref(alpha.id))
|
||||
|
||||
// Once only one side-panel tab remains, the shortcut returns to cycling session tabs.
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expect(fileTab).toHaveAttribute("aria-selected", "true")
|
||||
await page.keyboard.press("ControlOrMeta+w")
|
||||
await expect(fileTab).toHaveCount(0)
|
||||
await expect(reviewTab).toHaveAttribute("aria-selected", "true")
|
||||
await reviewTab.click()
|
||||
await page.keyboard.press("Control+Tab")
|
||||
await expectPath(page, sessionHref(beta.id))
|
||||
})
|
||||
|
||||
// Windows has no native menu bar: the titlebar menu owns Paste, which only the desktop edit action can perform.
|
||||
test("the Windows titlebar menu pastes through the desktop edit action", async ({ page }) => {
|
||||
await mockWorkspace(page, { name: "WindowsMenu", sessions: [] })
|
||||
|
||||
@@ -151,6 +151,23 @@ test("focuses a terminal created from the new-terminal button", async ({ page })
|
||||
await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
test("focuses the terminal opened from the command palette", async ({ page }) => {
|
||||
const { editor } = await openSession(page, workspace)
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await editor.click()
|
||||
await page.keyboard.press("ControlOrMeta+Shift+P")
|
||||
const palette = page.getByRole("dialog")
|
||||
const search = palette.getByRole("textbox")
|
||||
await expect(search).toBeFocused()
|
||||
await search.fill("Toggle terminal")
|
||||
await expect(palette.getByRole("option", { name: /^Toggle terminal/ })).toHaveAttribute("aria-selected", "true")
|
||||
|
||||
await search.press("Enter")
|
||||
await expect(palette).toHaveCount(0)
|
||||
await expect(terminal.locator("textarea")).toHaveCount(1)
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
})
|
||||
|
||||
test("closing the last restored terminal does not create a hidden replacement", async ({ page }) => {
|
||||
const cached = { id: "pty_cached", title: "Terminal 1" }
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { timelinePresets } from "@opencode/session-ui/timeline/detail"
|
||||
import { createTwoFilesPatch } from "diff"
|
||||
import {
|
||||
assistantMessage,
|
||||
historyMessages,
|
||||
partUpdated,
|
||||
renderedPartID,
|
||||
sessionID,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
toolPart,
|
||||
userMessage,
|
||||
userText,
|
||||
validateTimelineMessages,
|
||||
waitForVisualSettle,
|
||||
} from "../utils/timeline"
|
||||
import {
|
||||
@@ -459,12 +461,22 @@ for (const outline of [
|
||||
for (const failed of [false, true]) {
|
||||
test(`keeps a submitted prompt in place until Working shows${failed ? " after an error" : ""}`, async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
seedHistory: true,
|
||||
messages: [
|
||||
userMessage(),
|
||||
...(failed
|
||||
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
|
||||
: []),
|
||||
sessionMessages: [
|
||||
...validateTimelineMessages([
|
||||
...historyMessages(18),
|
||||
userMessage(),
|
||||
...(failed
|
||||
? [{ ...assistantMessage([]), error: { type: "provider.error", message: "Previous request failed" } }]
|
||||
: []),
|
||||
]),
|
||||
// The previous execution ended at an idle marker, so the submitted prompt starts a turn instead of
|
||||
// waiting as a steer behind that one.
|
||||
{
|
||||
id: "msg_1002_timeline_idle",
|
||||
type: "idle",
|
||||
outcome: failed ? "failed" : "succeeded",
|
||||
time: { created: 1700000003000 },
|
||||
},
|
||||
],
|
||||
})
|
||||
const working = page.locator('[data-component="session-working"]')
|
||||
@@ -494,7 +506,7 @@ for (const failed of [false, true]) {
|
||||
|
||||
// Records the prompt row in every frame from submission until Working has shown.
|
||||
const observation = await page.evaluateHandle((text) => {
|
||||
const frames: { prompt?: number; working: boolean }[] = []
|
||||
const frames: { prompt: number | undefined; working: boolean }[] = []
|
||||
let frame = 0
|
||||
|
||||
const sample = () => {
|
||||
@@ -503,7 +515,7 @@ for (const failed of [false, true]) {
|
||||
)
|
||||
|
||||
frames.push({
|
||||
...(prompt ? { prompt: prompt.getBoundingClientRect().y } : {}),
|
||||
prompt: prompt?.getBoundingClientRect().y,
|
||||
working: !!document.querySelector('[data-component="session-working"]'),
|
||||
})
|
||||
frame = requestAnimationFrame(sample)
|
||||
|
||||
@@ -125,6 +125,19 @@ const Group = HttpApiGroup.make("mock")
|
||||
error: MockNotFound.pipe(HttpApiSchema.status(404)),
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("credentialActivate", "/api/credential/:credentialID/activate", {
|
||||
params: { credentialID: Schema.String },
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.patch("credentialUpdate", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Schema.String },
|
||||
payload: JsonPayload,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("credentialRemove", "/api/credential/:credentialID", {
|
||||
params: { credentialID: Schema.String },
|
||||
|
||||
@@ -44,7 +44,11 @@ export type MockFileContent =
|
||||
export interface MockServerConfig {
|
||||
server?: string
|
||||
provider: Resolvable<MockProviderCatalog>
|
||||
integrations?: unknown[]
|
||||
integrations?: Resolvable<unknown[]>
|
||||
// Runs on POST /api/credential/:id/activate; the fixture swaps its integrations and catalog to the new account.
|
||||
onCredentialActivate?: (credentialID: string) => void
|
||||
// Runs on PATCH /api/credential/:id with the request body; the fixture applies the new label.
|
||||
onCredentialUpdate?: (credentialID: string, body: { label?: string }) => void
|
||||
onConnectKey?: (input: { integrationID: string; body: unknown }) => void
|
||||
// Terminal shells the settings offer (`/api/config/shell`).
|
||||
shells?: unknown[]
|
||||
@@ -103,6 +107,8 @@ export interface MockServerConfig {
|
||||
// Starts an OAuth attempt (POST .../connect/oauth) and returns its authorization URL; the attempt then stays pending.
|
||||
// Without it, OAuth connects answer 501 MockUnsupported.
|
||||
onIntegrationOAuth?: (input: { integrationID: string; directory: string; body: unknown }) => { url: string }
|
||||
// Answers status polls for an OAuth attempt; without it every attempt stays pending.
|
||||
onIntegrationOAuthStatus?: (attemptID: string) => "pending" | "complete"
|
||||
plugins?: Resolvable<unknown[]>
|
||||
skills?: Resolvable<unknown[]>
|
||||
// Replaces the `/api/worktree` inventory, which defaults to the directory plus project sandboxes.
|
||||
@@ -470,6 +476,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return { server, pty: transport.pty, push }
|
||||
}
|
||||
|
||||
// Answers every Location-scoped request for `directory` as the server does once that folder is deleted.
|
||||
export function mockMissingLocation(page: Page, directory: string) {
|
||||
return page.route(
|
||||
(url) => url.pathname.startsWith("/api/") && url.searchParams.get("location[directory]") === directory,
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
status: 404,
|
||||
// SAFETY: the server's wire body for a missing Location, which the client decodes into its own error.
|
||||
// oxlint-disable-next-line anti-slop-effect/no-manual-tagged-construction -- see SAFETY above
|
||||
json: { _tag: "LocationNotFoundError", location: { directory }, message: `Location not found: ${directory}` },
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// Mocks several servers on one page. Each origin gets its own handler and its own SSE transport for events.
|
||||
export async function mockServers(page: Page, servers: Record<string, Omit<MockServerConfig, "server" | "events">>) {
|
||||
return Object.fromEntries(
|
||||
@@ -758,9 +779,15 @@ function mockHandlers(
|
||||
Effect.gen(function* () {
|
||||
const path = decodeURIComponent(new URL(ctx.request.url, "http://localhost").pathname.slice(13))
|
||||
const value = yield* Effect.promise(() => Promise.resolve(config.fileContent?.(path)))
|
||||
const content = Predicate.isString(value) ? value : (value?.content ?? "")
|
||||
|
||||
return HttpServerResponse.uint8Array(new TextEncoder().encode(content))
|
||||
if (Predicate.isString(value)) return HttpServerResponse.uint8Array(new TextEncoder().encode(value))
|
||||
|
||||
// Like the server, stream base64 content as the bytes it encodes.
|
||||
return HttpServerResponse.uint8Array(
|
||||
value?.encoding === "base64"
|
||||
? Buffer.from(value.content, "base64")
|
||||
: new TextEncoder().encode(value?.content ?? ""),
|
||||
)
|
||||
}),
|
||||
)
|
||||
// Raw, so a hook's answer can replace the status and body.
|
||||
@@ -843,19 +870,20 @@ function mockHandlers(
|
||||
model: () => Effect.succeed({ location: location(config), data: currentModels(resolve(config.provider)) }),
|
||||
modelDefault: () =>
|
||||
Effect.succeed({ location: location(config), data: currentDefaultModel(resolve(config.provider)) }),
|
||||
integrationList: () => Effect.succeed({ location: location(config), data: config.integrations ?? [] }),
|
||||
integrationList: () =>
|
||||
Effect.sync(() => ({ location: location(config), data: resolve(config.integrations ?? []) })),
|
||||
integrationGet: (ctx) =>
|
||||
Effect.succeed({
|
||||
Effect.sync(() => ({
|
||||
location: location(config),
|
||||
data: config.integrations
|
||||
?.filter(Predicate.isObject)
|
||||
data: resolve(config.integrations ?? [])
|
||||
.filter(Predicate.isObject)
|
||||
.find((integration) => integration.id === ctx.params.integrationID) ?? {
|
||||
id: ctx.params.integrationID,
|
||||
name: ctx.params.integrationID,
|
||||
methods: [{ type: "key", label: "API key" }],
|
||||
connections: [],
|
||||
},
|
||||
}),
|
||||
})),
|
||||
integrationConnect: (ctx) =>
|
||||
Effect.sync(() => config.onConnectKey?.({ integrationID: ctx.params.integrationID, body: ctx.payload })).pipe(
|
||||
Effect.andThen(noContent),
|
||||
@@ -892,9 +920,18 @@ function mockHandlers(
|
||||
|
||||
return Effect.succeed({
|
||||
location: location(config, requestDirectory(config, ctx.request)),
|
||||
data: { status: "pending", time: { created, expires: created + 600_000 } },
|
||||
data: {
|
||||
status: config.onIntegrationOAuthStatus?.(ctx.params.attemptID) ?? "pending",
|
||||
time: { created, expires: created + 600_000 },
|
||||
},
|
||||
})
|
||||
}),
|
||||
credentialActivate: (ctx) =>
|
||||
Effect.sync(() => config.onCredentialActivate?.(ctx.params.credentialID)).pipe(Effect.andThen(noContent)),
|
||||
credentialUpdate: (ctx) =>
|
||||
Effect.sync(() => config.onCredentialUpdate?.(ctx.params.credentialID, ctx.payload)).pipe(
|
||||
Effect.andThen(noContent),
|
||||
),
|
||||
credentialRemove: () => noContent,
|
||||
command: (ctx) =>
|
||||
Effect.sync(() => ({
|
||||
@@ -1398,6 +1435,8 @@ function currentProviders(catalog: MockProviderCatalog) {
|
||||
.map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
canonical: provider.canonical,
|
||||
integrationID: provider.integrationID,
|
||||
package: provider.id,
|
||||
activation: connected.has(provider.id) ? "enabled" : "auto",
|
||||
}))
|
||||
@@ -1471,7 +1510,7 @@ export function currentSession(session: MockSession, fallbackDirectory?: string)
|
||||
parentID: session.parentID,
|
||||
projectID: session.projectID ?? "project",
|
||||
agent: session.agent ?? "build",
|
||||
model: session.model ?? { id: "mock-model", providerID: "mock-provider" },
|
||||
model: session.model,
|
||||
cost: session.cost ?? 0,
|
||||
tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
...decodeSessionOutcome(session),
|
||||
@@ -1529,7 +1568,14 @@ const decodePromptPayload = Schema.decodeUnknownOption(
|
||||
}),
|
||||
)
|
||||
|
||||
const decodeProvider = Schema.decodeUnknownOption(Schema.Struct({ id: Schema.String, name: Schema.String }))
|
||||
const decodeProvider = Schema.decodeUnknownOption(
|
||||
Schema.Struct({
|
||||
id: Schema.String,
|
||||
name: Schema.String,
|
||||
canonical: lenient(Schema.String),
|
||||
integrationID: lenient(Schema.String),
|
||||
}),
|
||||
)
|
||||
|
||||
const decodeModelProvider = Schema.decodeUnknownOption(
|
||||
Schema.Struct({ id: Schema.String, models: Schema.Record(Schema.String, Schema.Unknown) }),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.26",
|
||||
"version": "2.0.29",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
@@ -19,9 +19,9 @@
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit && bun run test:browser",
|
||||
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
|
||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||
"test:unit": "bun test --conditions=solid --only-failures --isolate --preload ./happydom.ts ./src",
|
||||
"test:browser": "bun test --conditions=browser --isolate --preload ./happydom.ts ./test-browser",
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --isolate --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:built": "PLAYWRIGHT_BUILD=1 playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { ServerSDK } from "@/runtime/server/client"
|
||||
import type { SessionMessageHandoff } from "@/session/handoff"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
import type { createComposerSubmission } from "./submission-state"
|
||||
|
||||
@@ -51,8 +52,8 @@ export type ComposerQueue = {
|
||||
export type ComposerSession = {
|
||||
id: string
|
||||
directory: string
|
||||
handoff?: {
|
||||
set: (message: SessionMessageUser) => void
|
||||
handoff: {
|
||||
set: (handoff: SessionMessageHandoff) => void
|
||||
clear: (messageID: string) => void
|
||||
}
|
||||
api: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { DialogSelectModel } from "@/providers/models/select-dialog"
|
||||
import { useLocal, type ModelSelection } from "@/providers/models/selection"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { getCursorPosition, setCursorPosition } from "./editor/dom"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createSessionOwnership } from "@/session/session-ownership"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
@@ -35,28 +34,8 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
|
||||
}
|
||||
|
||||
const chooseModel = () => {
|
||||
const owner = sessionOwnership.capture()
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
|
||||
const selection = window.getSelection()
|
||||
|
||||
const cursor =
|
||||
editor && selection?.rangeCount && editor.contains(selection.anchorNode) ? getCursorPosition(editor) : null
|
||||
|
||||
const restoreComposer = () => {
|
||||
// Kobalte restores focus during its teardown effect; defer past it so the
|
||||
// composer keeps focus and the caret returns to where the user left it.
|
||||
requestAnimationFrame(() => {
|
||||
const editor = document.querySelector<HTMLElement>('[data-component="composer-editor"]')
|
||||
|
||||
if (!editor) return
|
||||
editor.focus()
|
||||
|
||||
if (cursor !== null) setCursorPosition(editor, cursor)
|
||||
})
|
||||
}
|
||||
|
||||
owner.run(() => {
|
||||
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
|
||||
sessionOwnership.capture().run(() => {
|
||||
void dialog.show(() => <DialogSelectModel model={model} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,6 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot } from "solid-js"
|
||||
import type { LineComment } from "./comments"
|
||||
|
||||
let createCommentSessionForTest: typeof import("./comments").createCommentSessionForTest
|
||||
|
||||
beforeAll(async () => {
|
||||
mock.module("@solidjs/router", () => ({
|
||||
useNavigate: () => () => undefined,
|
||||
useParams: () => ({}),
|
||||
useLocation: () => ({}),
|
||||
useSearchParams: () => [{}, () => undefined],
|
||||
}))
|
||||
const mod = await import("./comments")
|
||||
createCommentSessionForTest = mod.createCommentSessionForTest
|
||||
})
|
||||
import { createCommentSessionForTest, type LineComment } from "./comments"
|
||||
|
||||
function line(file: string, id: string, time: number): LineComment {
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { ModelSelection } from "@/providers/models/selection"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import type { SessionMessageHandoff } from "@/session/handoff"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ActiveComposerAdapter, ComposerControls, ComposerSession, NewSessionComposerAdapter } from "./adapter"
|
||||
import type {
|
||||
ActiveComposerAdapter,
|
||||
ComposerControls,
|
||||
ComposerDelivery,
|
||||
ComposerSession,
|
||||
NewSessionComposerAdapter,
|
||||
} from "./adapter"
|
||||
import { createMemoryComposerState, type Prompt } from "./state"
|
||||
import { createComposerSubmit } from "./submit"
|
||||
import type { ComposerStateTarget } from "./submission-state"
|
||||
@@ -75,12 +81,14 @@ function submitInput(
|
||||
commands: () => readonly { name: string }[] | undefined = () => [],
|
||||
history: string[] = [],
|
||||
clientCommand?: (text: string) => (() => void | Promise<void>) | undefined,
|
||||
delivery: ComposerDelivery = "steer",
|
||||
) {
|
||||
return createComposerSubmit({
|
||||
adapter,
|
||||
mode: () => mode,
|
||||
commands,
|
||||
clientCommand,
|
||||
delivery: () => delivery,
|
||||
editor: () => undefined,
|
||||
queueScroll() {},
|
||||
addToHistory: (prompt) =>
|
||||
@@ -110,7 +118,7 @@ function session(input: {
|
||||
return {
|
||||
id: "session-1",
|
||||
directory: "C:/repo",
|
||||
handoff: input.handoff,
|
||||
handoff: input.handoff ?? { set() {}, clear() {} },
|
||||
current: input.current ?? (() => undefined),
|
||||
admitted: input.admitted ?? (() => false),
|
||||
api: {
|
||||
@@ -140,8 +148,11 @@ function session(input: {
|
||||
location: { command: { list: () => [] } },
|
||||
session: {
|
||||
setStatus: (_sessionID, status) => input.statuses?.push(status),
|
||||
// Like the data layer: admit locally, prepare, then send.
|
||||
prompt: async (value) => {
|
||||
input.calls.push("prompt")
|
||||
input.calls.push("admit")
|
||||
await value.prepare?.()
|
||||
input.calls.push("send")
|
||||
await input.prompt?.(value)
|
||||
|
||||
// The admitted inbox item, as the server returns it.
|
||||
@@ -192,16 +203,21 @@ describe("Composer submission", () => {
|
||||
})
|
||||
|
||||
test.each([
|
||||
{ current: { agent: "plan", model: { id: "old", providerID: "old" } }, calls: ["switch-agent", "switch-model"] },
|
||||
// The prompt admits before the selection commits, so a steer shows as pending while it does.
|
||||
{
|
||||
current: { agent: "plan", model: { id: "old", providerID: "old" } },
|
||||
calls: ["admit", "switch-agent", "switch-model", "send"],
|
||||
},
|
||||
// The model still commits: cached session state may lag behind an earlier switch.
|
||||
{
|
||||
current: { agent: "build", model: { providerID: "provider-1", id: "model-1", variant: "balanced" } },
|
||||
calls: ["switch-model"],
|
||||
calls: ["admit", "switch-model", "send"],
|
||||
},
|
||||
// A prompt commits a staged revert, which would delete the model switch made after its boundary.
|
||||
// A prompt commits a staged revert, which would delete the model switch made after its boundary. The revert
|
||||
// settles before admission, whose row it would also delete.
|
||||
{
|
||||
current: { agent: "plan", model: { id: "old", providerID: "old" }, revert: { messageID: "msg_reverted" } },
|
||||
calls: ["switch-agent", "revert-commit", "switch-model"],
|
||||
calls: ["switch-agent", "revert-commit", "switch-model", "admit", "send"],
|
||||
},
|
||||
])("applies the selection before sending one captured value: $calls", async (row) => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
@@ -213,7 +229,7 @@ describe("Composer submission", () => {
|
||||
await submitInput(active(state, target)).submit(new Event("submit"))
|
||||
const request = await admitted.promise
|
||||
|
||||
expect(calls).toEqual([...row.calls, "prompt"])
|
||||
expect(calls).toEqual([...row.calls])
|
||||
expect(request.delivery).toBe("steer")
|
||||
expect(request.text).toBe("ship it")
|
||||
expect(request.id).toMatch(/^msg_/)
|
||||
@@ -229,6 +245,42 @@ describe("Composer submission", () => {
|
||||
expect(state.context.items()).toEqual([])
|
||||
})
|
||||
|
||||
test.each(["steer", "queue"] as const)(
|
||||
"shows a %s in the session as the composer clears, before its selection commits",
|
||||
async (delivery) => {
|
||||
const state = createMemoryComposerState({ prompt: "ship it" }).capture()
|
||||
const committed = Promise.withResolvers<void>()
|
||||
const handoffs: SessionMessageHandoff[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
statuses,
|
||||
handoff: { set: (handoff) => handoffs.push(handoff), clear() {} },
|
||||
switchModel: async () => committed.promise,
|
||||
})
|
||||
|
||||
await submitInput(active(state, target), undefined, "normal", undefined, undefined, undefined, delivery).submit(
|
||||
new Event("submit"),
|
||||
)
|
||||
|
||||
expect(state.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
expect(statuses).toEqual(["running"])
|
||||
// A queued prompt shows in the queue once admitted, not in the transcript.
|
||||
expect(handoffs).toMatchObject(
|
||||
delivery === "steer"
|
||||
? [
|
||||
{
|
||||
message: { type: "user", text: "ship it" },
|
||||
selection: { agent: "build", model: { id: "model-1", providerID: "provider-1", variant: "balanced" } },
|
||||
},
|
||||
]
|
||||
: [],
|
||||
)
|
||||
committed.resolve()
|
||||
},
|
||||
)
|
||||
|
||||
test("applies the captured agent and model before a custom command without passing over its overrides", async () => {
|
||||
const state = createMemoryComposerState({ prompt: "/review changes" }).capture()
|
||||
const calls: string[] = []
|
||||
@@ -345,12 +397,13 @@ describe("Composer submission", () => {
|
||||
state.set(prompt)
|
||||
state.context.add({ type: "file", path: "notes.md", description: "the failing version" })
|
||||
const attempts: string[] = []
|
||||
const calls: string[] = []
|
||||
const statuses: ("idle" | "running")[] = []
|
||||
const first = Promise.withResolvers<void>()
|
||||
const second = Promise.withResolvers<void>()
|
||||
|
||||
const target = session({
|
||||
calls: [],
|
||||
calls,
|
||||
statuses,
|
||||
prompt: async (value) => {
|
||||
attempts.push(value.id ?? "")
|
||||
@@ -374,6 +427,9 @@ describe("Composer submission", () => {
|
||||
|
||||
expect(attempts).toHaveLength(4)
|
||||
expect(new Set(attempts).size).toBe(1)
|
||||
// A retried admission reuses its submission's selection commit.
|
||||
const submitted = ["admit", "switch-agent", "switch-model", "send", "admit", "send"]
|
||||
expect(calls).toEqual([...submitted, ...submitted])
|
||||
expect(statuses).toEqual(["running", "idle", "running", "idle"])
|
||||
expect(state.current()).toEqual(prompt)
|
||||
expect(state.context.items()).toMatchObject([
|
||||
@@ -461,7 +517,7 @@ describe("Composer submission", () => {
|
||||
]
|
||||
|
||||
draft.set(prompt)
|
||||
const handedOff = Promise.withResolvers<SessionMessageUser>()
|
||||
const handedOff = Promise.withResolvers<SessionMessageHandoff>()
|
||||
const admitted = Promise.withResolvers<void>()
|
||||
const cleanup = Promise.withResolvers<void>()
|
||||
|
||||
@@ -481,7 +537,7 @@ describe("Composer submission", () => {
|
||||
await submitted
|
||||
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
|
||||
|
||||
expect(await handedOff.promise).toMatchObject({
|
||||
expect((await handedOff.promise).message).toMatchObject({
|
||||
type: "user",
|
||||
text: "",
|
||||
files: [
|
||||
|
||||
@@ -146,16 +146,25 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
|
||||
const restore = () => restoreSubmission(input, submission, value, comments)
|
||||
|
||||
if (value.mode === "normal" && !command) {
|
||||
session.handoff?.set(handoffMessage(value))
|
||||
// The timeline shows a steer, and any switches it makes, as the composer clears; a queued prompt shows in
|
||||
// the queue once admitted.
|
||||
if (value.delivery === "steer")
|
||||
session.handoff.set({
|
||||
message: handoffMessage(value),
|
||||
selection: {
|
||||
agent: value.selection.agent,
|
||||
model: {
|
||||
id: value.selection.model.modelID,
|
||||
providerID: value.selection.model.providerID,
|
||||
variant: value.selection.variant,
|
||||
},
|
||||
},
|
||||
})
|
||||
const optimisticBusy = !input.adapter.working()
|
||||
|
||||
if (optimisticBusy && input.adapter.kind === "new-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
|
||||
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit, () => {
|
||||
if (optimisticBusy && input.adapter.kind === "active-session")
|
||||
session.data.session.setStatus(session.id, "running")
|
||||
}).then(
|
||||
const sending = sendPrompt(session, value, input.adapter.controls().model.selection.trackSessionCommit).then(
|
||||
() => ({ ok: true as const }),
|
||||
(error) => ({ ok: false as const, error }),
|
||||
)
|
||||
@@ -485,7 +494,6 @@ async function sendPrompt(
|
||||
session: ComposerSession,
|
||||
value: ComposerSubmission,
|
||||
track: ModelSelection["trackSessionCommit"] | undefined,
|
||||
onAdmit: () => void,
|
||||
) {
|
||||
const request = await buildSubmissionRequest(session, value)
|
||||
|
||||
@@ -500,8 +508,16 @@ async function sendPrompt(
|
||||
if (session.current()?.revert) await session.api.revert.commit({ sessionID: session.id })
|
||||
}
|
||||
|
||||
if (value.delivery === "steer") await applySelection(session, value.selection, track, settle)
|
||||
else await settle()
|
||||
const commit = () => (value.delivery === "steer" ? applySelection(session, value.selection, track, settle) : settle())
|
||||
// The data layer admits the prompt locally before it commits the selection, so a steer shows as pending while the
|
||||
// selection commits. A staged revert settles before admission instead: its committed echo removes every local row
|
||||
// from its boundary on. Only an idle session stages one, and a prompt into it starts work rather than showing as
|
||||
// pending.
|
||||
const staged = !!session.current()?.revert
|
||||
|
||||
if (staged) await commit()
|
||||
// A retried admission reuses the first commit rather than switching again.
|
||||
let committed: Promise<void> | undefined
|
||||
|
||||
const admission = {
|
||||
id: value.id,
|
||||
@@ -523,11 +539,10 @@ async function sendPrompt(
|
||||
agent: value.selection.agent,
|
||||
model: selectionModel(value.selection),
|
||||
},
|
||||
prepare: staged ? undefined : () => (committed ??= commit()),
|
||||
}
|
||||
|
||||
const sending = session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
onAdmit()
|
||||
await sending
|
||||
await session.data.session.prompt(admission).catch(() => session.data.session.prompt(admission))
|
||||
}
|
||||
|
||||
async function buildSubmissionRequest(session: ComposerSession, value: ComposerSubmission) {
|
||||
@@ -559,7 +574,7 @@ function failSubmission(
|
||||
) {
|
||||
if (messageID && session.admitted(messageID)) return
|
||||
|
||||
if (messageID) session.handoff?.clear(messageID)
|
||||
if (messageID) session.handoff.clear(messageID)
|
||||
rollback?.()
|
||||
restore()
|
||||
input.notify.failed(kind, error)
|
||||
|
||||
@@ -149,16 +149,14 @@ export function createHomeProjectsController(home: HomeController) {
|
||||
project: project.worktree,
|
||||
})
|
||||
},
|
||||
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
hasUnread: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
const notification = global.ensureServerCtx(conn).notification
|
||||
|
||||
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
|
||||
return directories(project).some((directory) => notification.project.hasUnread(directory))
|
||||
},
|
||||
clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
|
||||
const notification = global.ensureServerCtx(conn).notification
|
||||
directories(project)
|
||||
.filter((directory) => notification.project.unseenCount(directory) > 0)
|
||||
.forEach((directory) => notification.project.markViewed(directory))
|
||||
directories(project).forEach((directory) => notification.project.markViewed(directory))
|
||||
},
|
||||
choose: (conn: ServerConnection.Any) => {
|
||||
if (authenticate(conn, (next) => choose(next))) return
|
||||
|
||||
@@ -20,7 +20,7 @@ export function HomeProjects(props: {
|
||||
projectsForServer={props.projects.server.projects}
|
||||
collapsed={props.projects.server.collapsed}
|
||||
canRevealProject={props.projects.project.canReveal}
|
||||
unseenCount={props.projects.project.unseenCount}
|
||||
hasUnread={props.projects.project.hasUnread}
|
||||
onWheel={props.scroll.viewport.containWheel}
|
||||
onChooseProject={props.projects.project.choose}
|
||||
onFocusServer={props.projects.server.focus}
|
||||
|
||||
@@ -53,7 +53,7 @@ export type HomeProjectsViewProps = {
|
||||
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
|
||||
collapsed: (server: ServerConnection.Any) => boolean
|
||||
canRevealProject: (server: ServerConnection.Any) => boolean
|
||||
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
|
||||
hasUnread: (server: ServerConnection.Any, project: LocalProject) => boolean
|
||||
onWheel: (event: WheelEvent) => void
|
||||
onChooseProject: (server: ServerConnection.Any) => void
|
||||
onFocusServer: (server: ServerConnection.Any) => void
|
||||
@@ -571,7 +571,7 @@ function HomeProjectSlot(
|
||||
selected={
|
||||
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree
|
||||
}
|
||||
unseen={props.unseenCount(props.server, project())}
|
||||
unread={props.hasUnread(props.server, project())}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -649,7 +649,7 @@ function HomeProjectRow(
|
||||
index: number
|
||||
serverSelected: boolean
|
||||
selected: boolean
|
||||
unseen: number
|
||||
unread: boolean
|
||||
},
|
||||
) {
|
||||
const platform = usePlatform()
|
||||
@@ -782,7 +782,7 @@ function HomeProjectRow(
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item
|
||||
disabled={props.unseen === 0}
|
||||
disabled={!props.unread}
|
||||
onSelect={() => props.onClearNotifications(props.server, props.project)}
|
||||
>
|
||||
{props.language.t("sidebar.project.clearNotifications")}
|
||||
|
||||
@@ -437,6 +437,8 @@ export function HomeSessionStatusController(props: {
|
||||
() => props.server,
|
||||
() => props.record.session.id,
|
||||
() => true,
|
||||
// Home rows can come from the fetched index before the session is loaded.
|
||||
() => props.record.session,
|
||||
)
|
||||
|
||||
return props.render({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Predicate } from "effect"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import type { SessionMessageUser } from "@opencode/client/promise"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { startTransition } from "solid-js"
|
||||
import type { NewSessionComposerAdapter } from "@/composer/adapter"
|
||||
@@ -10,13 +9,13 @@ import { createComposerProjectControls } from "./project/controller"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLocal } from "@/providers/models/selection"
|
||||
import { useData, useServer } from "@/runtime/server/current"
|
||||
import { type ServerSDK, useServerSDK } from "@/runtime/server/client"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { createWorktree } from "@/workspaces/create"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/runtime/server/scope"
|
||||
import { clearSessionMessageHandoff, setSessionMessageHandoff } from "@/session/handoff"
|
||||
import { createSessionMessageHandoff } from "@/session/handoff"
|
||||
import type { DraftMcpControls } from "./mcp"
|
||||
|
||||
export function createNewSessionComposerAdapter(props: {
|
||||
@@ -161,7 +160,7 @@ export function createNewSessionComposerAdapter(props: {
|
||||
session: {
|
||||
id: created.id,
|
||||
directory: sessionDirectory,
|
||||
handoff: createMessageHandoff(sessionKey, created.id, serverSDK.event),
|
||||
handoff: createSessionMessageHandoff(sessionKey, created.id, serverSDK.event),
|
||||
api: {
|
||||
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
|
||||
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
|
||||
@@ -196,28 +195,6 @@ export function createNewSessionComposerAdapter(props: {
|
||||
}
|
||||
}
|
||||
|
||||
function createMessageHandoff(key: string, sessionID: string, event: ServerSDK["event"]) {
|
||||
let unsubscribe: VoidFunction | undefined
|
||||
|
||||
return {
|
||||
set(message: SessionMessageUser) {
|
||||
unsubscribe?.()
|
||||
setSessionMessageHandoff(key, message)
|
||||
unsubscribe = event.on("session.inbox.enqueued", (item) => {
|
||||
if (item.data.sessionID !== sessionID || item.data.inboxID !== message.id) return
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
clearSessionMessageHandoff(key, message.id)
|
||||
})
|
||||
},
|
||||
clear(messageID: string) {
|
||||
unsubscribe?.()
|
||||
unsubscribe = undefined
|
||||
clearSessionMessageHandoff(key, messageID)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveSessionDirectory(input: {
|
||||
projectDirectory: string
|
||||
worktree: string
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, useSearchParams } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createMemo, ErrorBoundary, Show, type ParentProps } from "solid-js"
|
||||
import { CommentsProvider } from "@/composer/comments"
|
||||
import { FileProvider } from "@/workspaces/files/model"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -12,6 +12,7 @@ import { useTabs, type DraftTab } from "@/shell/tabs/tabs"
|
||||
import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
|
||||
import NewSession from "@/new-session/screen"
|
||||
import { IncompatibleServerPanel } from "@/session/incompatible-server-panel"
|
||||
import { ErrorPage } from "@/shell/errors/error"
|
||||
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
|
||||
|
||||
export function DraftRoute() {
|
||||
@@ -67,15 +68,25 @@ function ResolvedDraftContent(props: { draft: DraftTab }) {
|
||||
</SessionRouteFrame>
|
||||
}
|
||||
>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
<ErrorBoundary
|
||||
fallback={(error) => (
|
||||
<SessionRouteFrame padded>
|
||||
<SessionPanelFrame raised>
|
||||
<ErrorPage error={error} />
|
||||
</SessionPanelFrame>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
>
|
||||
<ModelsProvider directory={props.draft.directory}>
|
||||
<LocationProvider directory={props.draft.directory}>
|
||||
<SessionUIProvider directory={props.draft.directory} server={props.draft.server}>
|
||||
<DraftProviders>
|
||||
<NewSession draftId={props.draft.draftID} />
|
||||
</DraftProviders>
|
||||
</SessionUIProvider>
|
||||
</LocationProvider>
|
||||
</ModelsProvider>
|
||||
</ErrorBoundary>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -62,8 +62,8 @@ export function createProviderConnectionController(options: {
|
||||
keyProvider?: () => string
|
||||
directory: () => string | undefined
|
||||
onComplete: () => void
|
||||
/** Picks the method to start without asking when the integration exposes several. */
|
||||
autoSelect?: (methods: ProviderConnectMethod[]) => number | undefined
|
||||
/** Skips auto-selecting a lone method; the caller starts it. */
|
||||
manual?: () => boolean
|
||||
/** Runs after the catalogs refresh; returning false keeps the dialog on a retryable error. */
|
||||
prepare?: (active: () => boolean) => Promise<boolean>
|
||||
pollInterval?: number
|
||||
@@ -148,12 +148,9 @@ export function createProviderConnectionController(options: {
|
||||
)
|
||||
|
||||
const autoIndex = createMemo(() => {
|
||||
if (integration.loading) return undefined
|
||||
const values = methods()
|
||||
if (integration.loading || options.manual?.()) return undefined
|
||||
|
||||
if (values.length === 1) return 0
|
||||
|
||||
return options.autoSelect?.(values)
|
||||
return methods().length === 1 ? 0 : undefined
|
||||
})
|
||||
|
||||
type Action =
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { List } from "@opencode/ui/list"
|
||||
import { RadioGroup, RadioItem } from "@opencode/ui/radio"
|
||||
import { Loader } from "@opencode/ui/loader"
|
||||
import { Spinner } from "@opencode/ui/spinner"
|
||||
import { TextField } from "@opencode/ui/text-field"
|
||||
import { DialogBody, DialogHeader, DialogTitle, Dialog } from "@opencode/ui/dialog"
|
||||
@@ -23,7 +24,6 @@ import {
|
||||
} from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/runtime/platform/external-link"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -43,7 +43,6 @@ import { Persist, persisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { Schema } from "effect"
|
||||
import {
|
||||
CONSOLE_INTEGRATION,
|
||||
CONSOLE_PROVIDERS,
|
||||
consoleIntegration,
|
||||
createProviderConnectionController,
|
||||
@@ -53,6 +52,11 @@ import {
|
||||
import { ConsoleAuthorization } from "./console"
|
||||
import { DialogChatGPTPlanWelcome } from "./chatgpt-welcome"
|
||||
import { authServerName, RemoteAuthNotice } from "./remote"
|
||||
import { providerAccounts, type ProviderAccount } from "@/settings/providers/accounts"
|
||||
import { AccountNameInput } from "@/settings/providers/account-name"
|
||||
import { useProviderAccountActions } from "@/settings/providers/account-actions"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import "./models.css"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
@@ -89,10 +93,12 @@ export const DialogConnectProvider: Component<{
|
||||
modelProvider?: { id: string; name: string }
|
||||
authorization: boolean
|
||||
chatgptWelcome: boolean
|
||||
picked: boolean
|
||||
}>({
|
||||
completed: false,
|
||||
authorization: false,
|
||||
chatgptWelcome: false,
|
||||
picked: false,
|
||||
})
|
||||
|
||||
const language = useLanguage()
|
||||
@@ -112,6 +118,7 @@ export const DialogConnectProvider: Component<{
|
||||
|
||||
const select = (provider?: string) => {
|
||||
back.current = reset
|
||||
setState("picked", true)
|
||||
controller.select(provider)
|
||||
}
|
||||
|
||||
@@ -128,6 +135,7 @@ export const DialogConnectProvider: Component<{
|
||||
{(provider) => (
|
||||
<ProviderConnection
|
||||
provider={provider}
|
||||
accounts={state.picked}
|
||||
directory={props.directory}
|
||||
defaultLocation={props.defaultLocation}
|
||||
onBack={reset}
|
||||
@@ -137,8 +145,7 @@ export const DialogConnectProvider: Component<{
|
||||
onConnected={(methodID) => {
|
||||
props.onConnected?.(provider)
|
||||
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing")
|
||||
setState("chatgptWelcome", true)
|
||||
if (provider === "openai" && methodID === "chatgpt-token-sharing") setState("chatgptWelcome", true)
|
||||
}}
|
||||
onFirstConnection={(provider) => setState("modelProvider", provider)}
|
||||
onAuthorization={(authorization) => setState("authorization", authorization)}
|
||||
@@ -177,7 +184,8 @@ export const DialogConnectProvider: Component<{
|
||||
}}
|
||||
class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]"
|
||||
classList={{
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3": consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-4 [&_[data-slot=dialog-header]]:!pb-3":
|
||||
consoleSelected() && !state.modelProvider,
|
||||
"[&_[data-slot=dialog-header]]:!pt-5": !!state.modelProvider,
|
||||
}}
|
||||
>
|
||||
@@ -232,29 +240,11 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
const featured = ["opencode-go", "opencode", "anthropic", "openai", "google", "openrouter", "vercel"]
|
||||
const custom = () => ({ id: CUSTOM_ID, name: language.t("dialog.provider.custom.label") })
|
||||
|
||||
// Only a stored credential hides a provider: environment and config connections can still be
|
||||
// replaced by a sign-in. OpenCode Zen stays until a Console account (not a key) is connected.
|
||||
const consoleAccount = createMemo(() =>
|
||||
integrations
|
||||
.list()
|
||||
.find((integration) => integration.id === CONSOLE_INTEGRATION)
|
||||
?.connections.some((connection) => connection.type === "credential" && connection.method === "oauth"),
|
||||
)
|
||||
|
||||
const all = createMemo(() => {
|
||||
language.locale()
|
||||
const query = store.filter.trim().toLowerCase()
|
||||
|
||||
const values = [
|
||||
custom(),
|
||||
...integrations
|
||||
.list()
|
||||
.filter((integration) =>
|
||||
integration.id === CONSOLE_INTEGRATION
|
||||
? !consoleAccount()
|
||||
: !integration.connections.some((connection) => connection.type === "credential"),
|
||||
),
|
||||
]
|
||||
const values = [custom(), ...integrations.list()]
|
||||
|
||||
if (!query) return values
|
||||
|
||||
@@ -280,6 +270,10 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
)
|
||||
|
||||
const rows = createMemo(() => [...popular(), ...other()])
|
||||
|
||||
const accountCount = (provider: { id: string }) =>
|
||||
providerAccounts(integrations.list().find((integration) => integration.id === provider.id)).length
|
||||
|
||||
let picker: HTMLDivElement | undefined
|
||||
let search: HTMLInputElement | undefined
|
||||
|
||||
@@ -356,7 +350,18 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
|
||||
>
|
||||
<ProviderModelIcon provider={provider} class="shrink-0 text-v2-icon-icon-base" />
|
||||
<span class="min-w-0 truncate font-[530] text-v2-text-text-base">{provider.name}</span>
|
||||
<Show when={CONSOLE_PROVIDERS.has(provider.id)}>
|
||||
<Show when={accountCount(provider) > 0}>
|
||||
<Badge
|
||||
variant="subtle"
|
||||
class="shrink-0"
|
||||
aria-label={language.plural("dialog.provider.accounts.count", accountCount(provider), {
|
||||
count: accountCount(provider),
|
||||
})}
|
||||
>
|
||||
{accountCount(provider)}
|
||||
</Badge>
|
||||
</Show>
|
||||
<Show when={CONSOLE_PROVIDERS.has(provider.id) && accountCount(provider) === 0}>
|
||||
<span class="min-w-0 truncate font-[440] text-v2-text-text-muted">
|
||||
{language.t(
|
||||
provider.id === "opencode"
|
||||
@@ -409,6 +414,7 @@ function ProviderConnection(props: {
|
||||
onConnected?: (methodID?: string) => void
|
||||
onFirstConnection: (provider: { id: string; name: string }) => void
|
||||
onAuthorization: (authorization: boolean) => void
|
||||
accounts?: boolean
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const params = useParams()
|
||||
@@ -424,6 +430,7 @@ function ProviderConnection(props: {
|
||||
const location = () => (initialDirectory ? { directory: initialDirectory } : undefined)
|
||||
const providers = useProviders(directory)
|
||||
const integrations = useIntegrations(directory)
|
||||
const accountActions = useProviderAccountActions(directory)
|
||||
const integrationID = consoleIntegration(props.provider)
|
||||
const isConsole = CONSOLE_PROVIDERS.has(props.provider)
|
||||
const remote = isConsole && authServerName(sdk.server) !== undefined
|
||||
@@ -437,7 +444,15 @@ function ProviderConnection(props: {
|
||||
catalogPending: boolean
|
||||
selectedModel: string
|
||||
collapsed: Record<string, boolean>
|
||||
adding: boolean
|
||||
direct: boolean
|
||||
confirming?: string
|
||||
switching?: string
|
||||
activating?: string
|
||||
renaming?: string
|
||||
}>({
|
||||
adding: false,
|
||||
direct: false,
|
||||
copied: false,
|
||||
copyFailed: false,
|
||||
models: false,
|
||||
@@ -448,18 +463,23 @@ function ProviderConnection(props: {
|
||||
collapsed: {},
|
||||
})
|
||||
|
||||
// Sorted by name so rows stay put when the server moves the active account first.
|
||||
const serverAccounts = () =>
|
||||
providerAccounts(integrations.list().find((integration) => integration.id === props.provider))
|
||||
|
||||
const accounts = () => serverAccounts().toSorted((a, b) => a.label.localeCompare(b.label) || a.id.localeCompare(b.id))
|
||||
|
||||
const activeAccount = () => state.activating ?? serverAccounts()[0]?.id
|
||||
|
||||
const showAccounts = () => !!props.accounts && !state.adding && accounts().length > 0
|
||||
|
||||
const controller = createProviderConnectionController({
|
||||
provider: () => integrationID,
|
||||
// A Go service-account key still belongs to the `opencode-go` integration (zen/go/v1),
|
||||
// exactly as before; only the sign-in is shared with the Console.
|
||||
keyProvider: () => props.provider,
|
||||
directory,
|
||||
autoSelect: (methods) => {
|
||||
if (!isConsole) return undefined
|
||||
const index = methods.findIndex((method) => method.type === "oauth")
|
||||
|
||||
return index === -1 ? undefined : index
|
||||
},
|
||||
manual: () => isConsole || (!!props.accounts && accounts().length > 0),
|
||||
prepare: isConsole ? prepareConsoleCatalog : undefined,
|
||||
pollInterval: isConsole ? 500 : undefined,
|
||||
onComplete: () => {
|
||||
@@ -467,9 +487,7 @@ function ProviderConnection(props: {
|
||||
props.onConnected?.(method?.type === "oauth" ? method.id : undefined)
|
||||
// The picker only lists the newest model per family by default, which hides most of
|
||||
// what a new connection just unlocked. Show everything the connected integration offers.
|
||||
global.models.show(
|
||||
connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })),
|
||||
)
|
||||
global.models.show(connectionModels().map((model) => ({ providerID: model.providerID, modelID: model.id })))
|
||||
|
||||
if (state.catalogPending) {
|
||||
setState("noModels", true)
|
||||
@@ -593,7 +611,9 @@ function ProviderConnection(props: {
|
||||
})
|
||||
createEffect(() => {
|
||||
const current = controller.auth.state()
|
||||
props.onAuthorization(controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"))
|
||||
props.onAuthorization(
|
||||
controller.authorization() !== undefined && (current === "waiting" || current === "refreshing"),
|
||||
)
|
||||
})
|
||||
|
||||
const provider = createMemo(() => ({
|
||||
@@ -646,13 +666,17 @@ function ProviderConnection(props: {
|
||||
const keyIndex = () => controller.methods().findIndex((method) => method.type === "key")
|
||||
const oauthIndex = () => controller.methods().findIndex((method) => method.type === "oauth")
|
||||
|
||||
// The Console device flow owns the dialog from the first frame until the catalogs are loaded.
|
||||
const consoleSignIn = () =>
|
||||
isConsole &&
|
||||
!state.noModels &&
|
||||
controller.currentMethod()?.type !== "key" &&
|
||||
controller.currentMethod()?.type === "oauth" &&
|
||||
controller.auth.state() !== "error" &&
|
||||
(controller.busy() || controller.authorization()?.mode === "auto")
|
||||
controller.authorization()?.mode === "auto"
|
||||
|
||||
const consoleStarting = () => controller.currentMethod()?.type === "oauth" && controller.auth.state() === "pending"
|
||||
|
||||
const consoleIntro = () =>
|
||||
isConsole && !controller.loading() && (controller.methodIndex() === undefined || consoleStarting())
|
||||
|
||||
function AuthFormView() {
|
||||
const defaults = providerFormDefaults(controller.currentMethod()?.form)
|
||||
@@ -889,15 +913,19 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
// The API key path for the Console is an escape hatch below the sign-in flow, so
|
||||
// "back" returns to the sign-in rather than leaving the provider.
|
||||
if (isConsole && controller.currentMethod()?.type === "key" && oauthIndex() !== -1) {
|
||||
void controller.auth.select(oauthIndex())
|
||||
if (
|
||||
props.accounts &&
|
||||
state.adding &&
|
||||
accounts().length > 0 &&
|
||||
(state.direct || controller.methodIndex() === undefined)
|
||||
) {
|
||||
controller.auth.reset()
|
||||
setState({ adding: false, direct: false })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!isConsole && controller.methods().length > 1 && controller.methodIndex() !== undefined) {
|
||||
if ((isConsole || controller.methods().length > 1) && controller.methodIndex() !== undefined) {
|
||||
controller.auth.reset()
|
||||
|
||||
return
|
||||
@@ -952,7 +980,7 @@ function ProviderConnection(props: {
|
||||
|
||||
function ErrorRow() {
|
||||
return (
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<div class="flex flex-col items-start" classList={{ "gap-3": !isConsole, "gap-4": isConsole }}>
|
||||
<div class="flex items-start gap-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
|
||||
<Icon name="circle-ban-sign" size="small" class="mt-0.5 shrink-0 text-v2-state-fg-danger" />
|
||||
<span role="alert">
|
||||
@@ -961,8 +989,8 @@ function ProviderConnection(props: {
|
||||
: language.t("provider.connect.status.failed", { error: controller.auth.error() ?? "" })}
|
||||
</span>
|
||||
</div>
|
||||
<Button variant="neutral" onClick={() => void controller.auth.retry()}>
|
||||
{language.t("common.retry")}
|
||||
<Button variant={isConsole ? undefined : "neutral"} onClick={() => void controller.auth.retry()}>
|
||||
{language.t(isConsole ? "provider.connect.console.retry" : "common.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
@@ -1001,21 +1029,15 @@ function ProviderConnection(props: {
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
|
||||
<Show
|
||||
when={isConsole}
|
||||
fallback={language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
>
|
||||
<div>
|
||||
{language.t("provider.connect.console.apiKey.description")}{" "}
|
||||
<ExternalLink
|
||||
href="https://opencode.ai/console"
|
||||
class="text-v2-text-text-base focus-visible:rounded-xs focus-visible:outline-2 focus-visible:outline-v2-border-border-focus"
|
||||
>
|
||||
{language.t("provider.connect.console.apiKey.link")}
|
||||
</ExternalLink>
|
||||
</div>
|
||||
</Show>
|
||||
<div
|
||||
class="flex flex-col gap-5 px-3 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted"
|
||||
classList={{ "pb-1": isConsole }}
|
||||
>
|
||||
<p>
|
||||
{isConsole
|
||||
? language.t("provider.connect.console.serviceKeyDescription")
|
||||
: language.t("provider.connect.apiKey.description", { provider: provider().name })}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} class="flex flex-col items-start gap-5 self-stretch">
|
||||
<label class="flex w-full flex-col gap-2 font-[530] leading-4 text-v2-text-text-base">
|
||||
{language.t("provider.connect.apiKey.label", { provider: provider().name })}
|
||||
@@ -1040,7 +1062,12 @@ function ProviderConnection(props: {
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
<Button type="submit" variant="contrast" data-action="provider-connect-submit">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="contrast"
|
||||
class={isConsole ? "!px-3" : undefined}
|
||||
data-action="provider-connect-submit"
|
||||
>
|
||||
{language.t("common.continue")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -1143,22 +1170,170 @@ function ProviderConnection(props: {
|
||||
)
|
||||
}
|
||||
|
||||
// Deliberately quiet: most people should never need a key, so this stays small and at the bottom.
|
||||
function ConsoleApiKeySwitch() {
|
||||
const addAccount = () => {
|
||||
setState({ adding: true, direct: false, confirming: undefined })
|
||||
|
||||
// Go signs in through the Console, which would add a Console account, so Go adds a key instead.
|
||||
const index =
|
||||
props.provider === "opencode-go" ? keyIndex() : !isConsole && controller.methods().length === 1 ? 0 : -1
|
||||
|
||||
if (index === -1) return
|
||||
setState("direct", true)
|
||||
void controller.auth.select(index)
|
||||
}
|
||||
|
||||
const activateAccount = async (account: ProviderAccount) => {
|
||||
if (activeAccount() === account.id || state.switching) return
|
||||
setState({ switching: account.id, activating: account.id, confirming: undefined })
|
||||
await accountActions.activate({ integrationID: props.provider, provider: provider().name, account })
|
||||
setState({ switching: undefined, activating: undefined })
|
||||
}
|
||||
|
||||
const removeAccount = async (account: ProviderAccount) => {
|
||||
setState({ switching: account.id, confirming: undefined })
|
||||
await accountActions.remove({
|
||||
integrationID: props.provider,
|
||||
provider: provider().name,
|
||||
account,
|
||||
final: accounts().length === 1,
|
||||
})
|
||||
setState("switching", undefined)
|
||||
}
|
||||
|
||||
function AccountsView() {
|
||||
return (
|
||||
<div data-component="console-service-account" class="flex h-7 items-center gap-1 px-3 pt-5 text-[13px]">
|
||||
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
|
||||
<Button
|
||||
variant="ghost-muted"
|
||||
data-action="provider-connect-api-key"
|
||||
onClick={() => void controller.auth.select(keyIndex())}
|
||||
>
|
||||
{language.t("provider.connect.console.useApiKey")}
|
||||
<div data-component="provider-accounts" class="flex flex-col gap-3">
|
||||
<section class="flex flex-col">
|
||||
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
|
||||
{language.t("dialog.provider.accounts.connected")}
|
||||
</div>
|
||||
<div role="radiogroup" aria-label={language.t("dialog.provider.accounts.connected")} class="flex flex-col">
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Show
|
||||
when={state.confirming !== account.id}
|
||||
fallback={
|
||||
<div class="flex min-h-9 items-center gap-2 rounded-md px-3 text-[13px] leading-5 tracking-[-0.04px]">
|
||||
<span role="alert" class="min-w-0 flex-1 truncate font-[440] text-v2-text-text-base">
|
||||
{language.t("dialog.provider.accounts.removeConfirm", { account: account.label })}
|
||||
</span>
|
||||
<Button variant="ghost-muted" onClick={() => setState("confirming", undefined)}>
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="neutral" onClick={() => void removeAccount(account)}>
|
||||
{language.t("dialog.provider.accounts.remove")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="group relative flex min-h-9 items-center rounded-md hover:bg-v2-overlay-simple-overlay-hover focus-within:bg-v2-overlay-simple-overlay-hover">
|
||||
<Show
|
||||
when={state.renaming !== account.id}
|
||||
fallback={
|
||||
<div class="flex min-h-9 min-w-0 flex-1 items-center px-3">
|
||||
<AccountNameInput
|
||||
value={account.label}
|
||||
class="-ms-1 max-w-full text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base"
|
||||
onSave={(label) => accountActions.rename(account, label)}
|
||||
onClose={() => setState("renaming", undefined)}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={activeAccount() === account.id}
|
||||
class="flex min-h-9 min-w-0 flex-1 items-center gap-2 px-3 text-left text-[13px] leading-5 tracking-[-0.04px] focus-visible:outline-none"
|
||||
disabled={state.switching !== undefined}
|
||||
onClick={() => void activateAccount(account)}
|
||||
>
|
||||
<span class="min-w-0 truncate font-[440] text-v2-text-text-base">{account.label}</span>
|
||||
<Show when={activeAccount() === account.id}>
|
||||
<Icon name="check" size="small" class="shrink-0 text-v2-icon-icon-base" />
|
||||
</Show>
|
||||
</button>
|
||||
</Show>
|
||||
<div class="flex shrink-0 items-center pe-1.5">
|
||||
<Menu gutter={4} modal={false} placement="bottom-end">
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
icon={<Icon name="outline-dots" />}
|
||||
disabled={state.switching !== undefined}
|
||||
aria-label={language.t("dialog.provider.accounts.options", { account: account.label })}
|
||||
/>
|
||||
{/* The modal dialog aria-hides anything added outside it, this portal included, a frame
|
||||
after it opens. Kobalte leaves a node marked as a top layer visible. */}
|
||||
<Menu.Portal
|
||||
ref={(el) => {
|
||||
el.dataset.reactAriaTopLayer = "true"
|
||||
}}
|
||||
>
|
||||
<Menu.Content onCloseAutoFocus={(event) => state.renaming && event.preventDefault()}>
|
||||
<Menu.Item onSelect={() => setState({ renaming: account.id, confirming: undefined })}>
|
||||
{language.t("common.rename")}
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => setState({ confirming: account.id, renaming: undefined })}>
|
||||
{language.t("dialog.provider.accounts.remove")}
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</section>
|
||||
<Button class="-mt-1 ms-2.5 self-start !px-3" disabled={state.switching !== undefined} onClick={addAccount}>
|
||||
{language.t("dialog.provider.accounts.add")}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsoleIntro() {
|
||||
return (
|
||||
<div class="flex flex-col items-start gap-5 px-3">
|
||||
<p class="text-[13px] leading-5 text-v2-text-text-muted">{language.t("provider.connect.console.intro")}</p>
|
||||
<Button
|
||||
size="large"
|
||||
class="!px-3"
|
||||
variant="contrast"
|
||||
disabled={oauthIndex() === -1 || consoleStarting()}
|
||||
aria-busy={consoleStarting()}
|
||||
onClick={() => void controller.auth.select(oauthIndex())}
|
||||
>
|
||||
<Show when={consoleStarting()}>
|
||||
<span class="absolute inset-0 flex items-center justify-center gap-1.5">
|
||||
<Loader />
|
||||
<span>{language.t("provider.connect.console.openingBrowser")}</span>
|
||||
</span>
|
||||
</Show>
|
||||
<span aria-hidden={consoleStarting() ? "true" : undefined} classList={{ "opacity-0": consoleStarting() }}>
|
||||
{language.t("provider.connect.console.continue")}
|
||||
</span>
|
||||
</Button>
|
||||
<Show when={keyIndex() !== -1}>
|
||||
<div data-component="console-service-account" class="flex h-7 items-center gap-1 text-[13px]">
|
||||
<span class="text-v2-text-text-faint">{language.t("provider.connect.console.serviceAccount")}</span>
|
||||
<Button
|
||||
variant="ghost-muted"
|
||||
data-action="provider-connect-api-key"
|
||||
disabled={consoleStarting()}
|
||||
onClick={() => void controller.auth.select(keyIndex())}
|
||||
>
|
||||
{language.t("provider.connect.console.useApiKey")}
|
||||
</Button>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ConsoleNoModels() {
|
||||
return (
|
||||
<div role="status" class="flex flex-col items-start gap-5 px-3 text-[13px] leading-5 text-v2-text-text-muted">
|
||||
@@ -1352,7 +1527,17 @@ function ProviderConnection(props: {
|
||||
<div class="text-[15px] font-[530] leading-5 tracking-[-0.13px] text-v2-text-text-base">
|
||||
<DialogTitle>
|
||||
<Switch>
|
||||
<Match when={consoleSignIn()}>{language.t("provider.connect.console.title")}</Match>
|
||||
<Match when={showAccounts()}>{provider().name}</Match>
|
||||
<Match when={isConsole && controller.auth.state() === "error"}>
|
||||
{language.t("provider.connect.opencode.errorTitle")}
|
||||
</Match>
|
||||
<Match when={consoleSignIn()}>
|
||||
{language.t(
|
||||
props.provider === "opencode-go"
|
||||
? "provider.connect.console.titleGo"
|
||||
: "provider.connect.console.title",
|
||||
)}
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
props.provider === "anthropic" && controller.currentMethod()?.label?.toLowerCase().includes("max")
|
||||
@@ -1360,6 +1545,9 @@ function ProviderConnection(props: {
|
||||
>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={props.provider === "opencode"}>
|
||||
{language.t("provider.connect.title", { provider: language.t("provider.connect.opencode.name") })}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
</Switch>
|
||||
</DialogTitle>
|
||||
@@ -1390,6 +1578,12 @@ function ProviderConnection(props: {
|
||||
/>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={showAccounts() && controller.methodIndex() === undefined}>
|
||||
<AccountsView />
|
||||
</Match>
|
||||
<Match when={consoleIntro()}>
|
||||
<ConsoleIntro />
|
||||
</Match>
|
||||
<Match when={controller.busy()}>
|
||||
<div class="px-3">
|
||||
<StatusRow>{language.t("provider.connect.status.inProgress")}</StatusRow>
|
||||
@@ -1416,17 +1610,6 @@ function ProviderConnection(props: {
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
<Show
|
||||
when={
|
||||
isConsole &&
|
||||
!controller.loading() &&
|
||||
!state.noModels &&
|
||||
controller.currentMethod()?.type !== "key" &&
|
||||
keyIndex() !== -1
|
||||
}
|
||||
>
|
||||
<ConsoleApiKeySwitch />
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -56,9 +56,7 @@
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
[data-component="first-provider-models"]
|
||||
.provider-model-groups--dialog
|
||||
.provider-model-group-trigger:not(:disabled):hover {
|
||||
[data-component="first-provider-models"] .provider-model-groups--dialog .provider-model-group-header:hover {
|
||||
background-color: var(--v2-background-bg-layer-03);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ function SelectorStory(props: { plan: boolean }) {
|
||||
{models.find((model) => model.id === current())?.name}
|
||||
</Button>
|
||||
)}
|
||||
all={() => models}
|
||||
models={(search) => models.filter((model) => model.name.toLowerCase().includes(search.toLowerCase()))}
|
||||
groups={(items) => [{ category: "openai", items }]}
|
||||
current={`openai:${current()}`}
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
ProviderModelIcon,
|
||||
ProviderModelSections,
|
||||
} from "@/providers/models/provider-group"
|
||||
import { consoleProviderName } from "@/providers/catalog/console"
|
||||
import "@/settings/settings.css"
|
||||
import "./select-dialog.css"
|
||||
|
||||
@@ -299,6 +300,7 @@ export function ModelSelectorPopover(props: {
|
||||
fallback={
|
||||
<ModelSelectorPopoverView
|
||||
trigger={props.trigger}
|
||||
all={controller.all}
|
||||
models={controller.models}
|
||||
groups={controller.groups}
|
||||
current={controller.current()}
|
||||
@@ -494,6 +496,7 @@ function createModelSelectorController(input: {
|
||||
|
||||
export function ModelSelectorPopoverView(props: {
|
||||
trigger: ModelSelectorTrigger
|
||||
all: () => ModelItem[]
|
||||
models: (search: string) => ModelItem[]
|
||||
groups: (models: ModelItem[]) => { category: string; items: ModelItem[] }[]
|
||||
current: string | undefined
|
||||
@@ -512,6 +515,21 @@ export function ModelSelectorPopoverView(props: {
|
||||
const groups = createMemo(() => props.groups(models()))
|
||||
const keys = () => [...groups().flatMap((group) => group.items.map(modelKey)), manageKey]
|
||||
|
||||
const managed = createMemo(() => consoleModelGroup(props.all()))
|
||||
const managedIDs = createMemo(() => new Set(managed()?.providers.map((provider) => provider.id)))
|
||||
const canonical = (provider: ModelItem["provider"]) => provider.canonical ?? provider.id.replace(/^console-/, "")
|
||||
|
||||
// The workspace badge only disambiguates a managed provider that is also connected directly.
|
||||
const direct = createMemo(
|
||||
() =>
|
||||
new Set(
|
||||
props
|
||||
.all()
|
||||
.filter((item) => !managedIDs().has(item.provider.id))
|
||||
.map((item) => canonical(item.provider)),
|
||||
),
|
||||
)
|
||||
|
||||
const initialActive = () => {
|
||||
const selected = props.current
|
||||
const options = keys()
|
||||
@@ -677,8 +695,26 @@ export function ModelSelectorPopoverView(props: {
|
||||
<For each={groups()}>
|
||||
{(group) => (
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel class="gap-2 px-3">
|
||||
<span class="min-w-0 truncate">{group.items[0].provider.name}</span>
|
||||
<Menu.GroupLabel
|
||||
class="gap-1.5 pe-3"
|
||||
style={{ "padding-inline-start": group.items[0].provider.id === "opencode" ? "10px" : "12px" }}
|
||||
>
|
||||
<ProviderModelIcon provider={group.items[0].provider} class="shrink-0" />
|
||||
<Show
|
||||
when={managedIDs().has(group.category) ? managed() : undefined}
|
||||
fallback={<span class="min-w-0 truncate">{group.items[0].provider.name}</span>}
|
||||
>
|
||||
{(workspace) => (
|
||||
<>
|
||||
<span class="min-w-0 truncate">
|
||||
{consoleProviderName(workspace(), group.items[0].provider.name)}
|
||||
</span>
|
||||
<Show when={direct().has(canonical(group.items[0].provider))}>
|
||||
<Badge class="shrink-0">{workspace().workspace}</Badge>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</Show>
|
||||
</Menu.GroupLabel>
|
||||
<Menu.RadioGroup value={props.current}>
|
||||
<For each={group.items}>
|
||||
|
||||
@@ -269,19 +269,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
const current = createMemo(() => {
|
||||
if (!configuredModel.ready()) return
|
||||
|
||||
const item = firstModel(
|
||||
() => scope()?.model,
|
||||
() => {
|
||||
const session = durable()
|
||||
const session = durable()
|
||||
|
||||
if (session?.agent && session.agent !== agent.current()?.name) return
|
||||
const model = session?.model
|
||||
|
||||
return model && { providerID: model.providerID, modelID: model.id }
|
||||
},
|
||||
() => agent.current()?.model,
|
||||
fallback,
|
||||
)
|
||||
// Catalog availability must not replace a user's choice with a default on the next submission.
|
||||
const item =
|
||||
scope()?.model ??
|
||||
(session?.model && (!session.agent || session.agent === agent.current()?.name)
|
||||
? { providerID: session.model.providerID, modelID: session.model.id }
|
||||
: undefined) ??
|
||||
firstModel(() => agent.current()?.model, fallback)
|
||||
|
||||
if (!item) return
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { For, Show, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, onCleanup, type JSX } from "solid-js"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
@@ -75,17 +75,30 @@ export function SideRegion(props: {
|
||||
.toSorted((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
||||
)
|
||||
|
||||
let root: HTMLElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
let selectionEvent: Event | undefined
|
||||
|
||||
createEffect(() => {
|
||||
if (open()) return
|
||||
const active = document.activeElement
|
||||
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
|
||||
if (!root?.contains(active)) return
|
||||
active.blur()
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={isDesktop() && !!params.id}>
|
||||
<aside
|
||||
ref={root}
|
||||
id="review-panel"
|
||||
tabIndex={-1}
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
aria-hidden={!open()}
|
||||
inert={!open()}
|
||||
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)]"
|
||||
class="relative min-w-0 flex overflow-hidden bg-v2-background-bg-base rounded-[10px] shadow-[var(--v2-elevation-raised)] outline-none"
|
||||
classList={{
|
||||
"h-full shrink-0": !props.stacked,
|
||||
"h-full min-h-0": props.stacked,
|
||||
|
||||
@@ -42,6 +42,10 @@ export const dict = {
|
||||
"command.palette": "Command palette",
|
||||
|
||||
"command.session.new": "New session",
|
||||
"command.session.previous": "Previous session",
|
||||
"command.session.next": "Next session",
|
||||
"command.session.previous.unseen": "Previous unread session",
|
||||
"command.session.next.unseen": "Next unread session",
|
||||
"command.file.open": "Open file",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
@@ -50,6 +54,7 @@ export const dict = {
|
||||
"command.input.focus": "Focus input",
|
||||
"command.fileTree.toggle": "Toggle file tree",
|
||||
"command.review.toggle": "Toggle review",
|
||||
"command.sidebar.toggle": "Toggle sidebar",
|
||||
"command.message.previous": "Previous message",
|
||||
"command.message.previous.description": "Go to the previous user message",
|
||||
"command.message.next": "Next message",
|
||||
@@ -68,6 +73,7 @@ export const dict = {
|
||||
"command.prompt.mode.normal": "Prompt",
|
||||
"command.permissions.autoaccept.enable": "Auto-accept permissions",
|
||||
"command.permissions.autoaccept.disable": "Stop auto-accepting permissions",
|
||||
"command.permissions.autoaccept.project": "Auto-accept permissions for this project",
|
||||
"command.session.undo": "Undo",
|
||||
"command.session.undo.description": "Undo the last message",
|
||||
"command.session.redo": "Redo",
|
||||
@@ -117,11 +123,24 @@ export const dict = {
|
||||
"dialog.model.unpaid.viewMoreProviders": "See 70+ more providers",
|
||||
|
||||
"dialog.provider.viewAll": "Show more providers",
|
||||
"dialog.provider.accounts.count.one": "{{count}} account",
|
||||
"dialog.provider.accounts.count.other": "{{count}} accounts",
|
||||
"dialog.provider.accounts.add": "Add another account",
|
||||
"dialog.provider.accounts.connected": "Connected accounts",
|
||||
"dialog.provider.accounts.removeConfirm": "Remove {{account}}?",
|
||||
"dialog.provider.accounts.remove": "Remove",
|
||||
"dialog.provider.accounts.options": "{{account}} options",
|
||||
|
||||
"provider.connect.title": "Connect {{provider}}",
|
||||
"provider.connect.opencode.name": "OpenCode Console",
|
||||
"provider.connect.opencode.freeName": "OpenCode Free",
|
||||
"provider.connect.opencode.errorTitle": "Connect to OpenCode",
|
||||
"provider.connect.console.title": "Connecting to OpenCode Console",
|
||||
"provider.connect.console.titleGo": "Connecting to OpenCode Go",
|
||||
"provider.connect.console.intro": "Sign in with your OpenCode Console account to use the available models.",
|
||||
"provider.connect.console.continue": "Continue in the browser",
|
||||
"provider.connect.console.retry": "Try again",
|
||||
"provider.connect.console.serviceKeyDescription": "Connect using a service-account API key from OpenCode Console.",
|
||||
"provider.connect.console.instructions":
|
||||
"Continue in your browser. Confirm the code shown there matches the one below.",
|
||||
"provider.connect.console.deviceCode": "Device code",
|
||||
@@ -190,9 +209,6 @@ export const dict = {
|
||||
"Your browser opens so you can sign in to {{provider}}. Paste the authorization code it gives you below.",
|
||||
"provider.connect.oauth.openBrowser": "Open browser",
|
||||
"provider.connect.oauth.expired": "Authorization expired",
|
||||
"provider.connect.console.apiKey.description":
|
||||
"Paste an API key for a service account. You create service accounts in the OpenCode Console under Keys.",
|
||||
"provider.connect.console.apiKey.link": "Open the Console",
|
||||
"provider.connect.toast.connected.title": "{{provider}} connected",
|
||||
"provider.connect.toast.connected.description": "{{provider}} models are now available to use.",
|
||||
|
||||
@@ -397,6 +413,9 @@ export const dict = {
|
||||
|
||||
"toast.permissions.autoaccept.on.title": "Auto-accepting permissions",
|
||||
"toast.permissions.autoaccept.on.description": "Permission requests will be automatically approved",
|
||||
"toast.permissions.autoaccept.project.title": "Auto-accepting permissions for this project",
|
||||
"toast.permissions.autoaccept.project.description":
|
||||
"Permission requests in this project will be automatically approved",
|
||||
"toast.permissions.autoaccept.off.title": "Stopped auto-accepting permissions",
|
||||
"toast.permissions.autoaccept.off.description": "Permission requests will require approval",
|
||||
|
||||
@@ -549,6 +568,7 @@ export const dict = {
|
||||
"session.messages.jumpToLatest": "Jump to latest",
|
||||
|
||||
"session.question.progress": "{{current}} of {{total}} questions",
|
||||
"session.question.title": "Question",
|
||||
"session.websearch.title": "Third-party web search",
|
||||
"session.websearch.description": "Select the search provider agents use to search the web",
|
||||
"session.websearch.provider": "Search provider",
|
||||
@@ -720,6 +740,11 @@ export const dict = {
|
||||
"settings.extensions.manageConfig": "Manage in opencode.json",
|
||||
"settings.extensions.addSkills": "How to add skills",
|
||||
"project.settings.title": "Edit project",
|
||||
"project.settings.autoAccept.description": "Approve this project's permission requests without asking",
|
||||
"project.settings.autoAccept.default.on": "Use default (On)",
|
||||
"project.settings.autoAccept.default.off": "Use default (Off)",
|
||||
"project.settings.autoAccept.on": "On",
|
||||
"project.settings.autoAccept.off": "Off",
|
||||
"project.settings.name.title": "Project name",
|
||||
"project.settings.name.description": "The name shown for this project throughout OpenCode",
|
||||
"project.settings.icon.description": "Recommended: 128×128px. Click or drag to upload an image.",
|
||||
@@ -727,6 +752,8 @@ export const dict = {
|
||||
"project.settings.worktree.startup.description": "Runs once after creating a new worktree",
|
||||
"project.settings.worktree.startup.hint.base": "Use $OPENCODE_WORKTREE_BASE for the base worktree.",
|
||||
"project.settings.worktree.startup.hint.new": "Use $OPENCODE_WORKTREE_PATH for the new worktree.",
|
||||
"project.settings.folderMissing.title": "Project folder not found",
|
||||
"project.settings.folderMissing.description": "Restore the folder or close this project.",
|
||||
"project.settings.extensions.description": "View extensions available to this project",
|
||||
"project.settings.extensions.tab.lsps": "LSPs",
|
||||
"project.settings.extensions.added": "Added to this project",
|
||||
@@ -770,6 +797,8 @@ export const dict = {
|
||||
"settings.timeline.description.text-only": "Hide all activity.",
|
||||
"settings.timeline.description.custom": "Uses advanced settings.",
|
||||
"settings.timeline.custom": "Custom",
|
||||
"settings.timeline.row.turnDiffs.title": "Changed files",
|
||||
"settings.timeline.row.turnDiffs.description": "List the files each turn changed when it finishes",
|
||||
"settings.timeline.advanced": "Advanced",
|
||||
"settings.timeline.advanced.description": "Set placement and details for each activity category.",
|
||||
"settings.timeline.group": "Group",
|
||||
@@ -906,8 +935,7 @@ export const dict = {
|
||||
"settings.providers.account.manage": "Manage {{provider}} accounts",
|
||||
"settings.providers.account.group": "Accounts",
|
||||
"settings.providers.account.add": "Add account",
|
||||
"settings.providers.account.remove": "Remove account…",
|
||||
"settings.providers.account.active": "Active",
|
||||
"settings.providers.account.name": "Account name",
|
||||
"settings.providers.account.switched.title": "{{provider}} account switched",
|
||||
"settings.providers.account.switched.description": "Now using {{account}}.",
|
||||
"settings.providers.account.removed.title": "{{account}} removed",
|
||||
@@ -997,6 +1025,8 @@ export const dict = {
|
||||
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
|
||||
"settings.workspaces.delete.blocked.linked":
|
||||
"Linked sessions will remain, but their working directory will no longer exist.",
|
||||
"settings.permissions.autoAccept.default.description":
|
||||
"Approve permission requests without asking. Projects can override this in their settings.",
|
||||
"settings.workspaces.default.title": "Default environment",
|
||||
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||
|
||||
Loaded 100 of 1123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user