Compare commits

...

43 Commits

Author SHA1 Message Date
Kit Langton 1d0b1b3518 refactor(core): move wake scope into coordinator 2026-08-14 23:31:57 -04:00
Kit Langton eb2c80fa23 fix(core): preserve scoped interrupt wakes 2026-08-14 21:12:21 -04:00
Kit Langton 3f2031a325 fix(core): keep queued work parked after interrupt 2026-08-14 21:00:38 -04:00
Kit Langton 014a364dfd docs(core): refresh session architecture 2026-08-14 20:32:06 -04:00
opencode-agent[bot] a45b12cfa4 fix(app): use location VCS state (#42666) 2026-08-15 10:30:46 +10:00
Kit Langton 7484e32f62 refactor(protocol): harden simulation wire contract (#42628) 2026-08-14 20:29:53 -04:00
opencode-agent[bot] 650f7e8cdd chore: generate 2026-08-15 00:20:16 +00:00
Dax 5c7f5840ee feat(core): persist web search provider selection (#42663) 2026-08-14 20:19:02 -04:00
opencode-agent[bot] 08a6d7b619 docs: fix package manager code blocks (#42313)
Co-authored-by: Kit Langton <7587245+kitlangton@users.noreply.github.com>
2026-08-14 20:03:48 -04:00
opencode-agent[bot] 1580e7cc3a chore: generate 2026-08-14 23:41:12 +00:00
Aiden Cline 8640ea3374 minimize system prompt (#42638) 2026-08-14 18:39:27 -05:00
opencode-agent[bot] e6a3b951b5 chore: generate 2026-08-14 22:19:55 +00:00
James Long 62b67f2761 refactor(protocol): move worktree routes out of experimental namespace (#42656) 2026-08-14 18:18:40 -04:00
James Long 182fe17f12 fix(tui): remove shadow scrim under horizontal tab strip (#42650) 2026-08-14 16:06:32 -04:00
opencode-agent[bot] b6ea0c2209 chore: generate 2026-08-14 19:44:43 +00:00
Kit Langton f7b222e95a fix(tui): open footer status dialogs on click 2026-08-14 15:43:07 -04:00
Kit Langton a7288d231e feat(tui): add working directory actions (#42624) 2026-08-14 15:29:22 -04:00
Kit Langton f75244d795 fix(tui): preserve tab drag source (#42619) 2026-08-14 17:59:18 +00:00
Kit Langton 1d44d56d9c fix(tui): use semantic form tokens (#42599) 2026-08-14 17:49:06 +00:00
Kit Langton 9adc9bb5df fix(tui): preserve full-width tab hover 2026-08-14 13:41:39 -04:00
Kit Langton a609acbb67 fix(tui): refine tab marquee motion (#42617) 2026-08-14 13:30:21 -04:00
Kit Langton f4620939aa test(server): remove workerd spike (#42618) 2026-08-14 13:21:26 -04:00
Kit Langton 23258ada86 feat(tui): resize vertical tab rail (#42615) 2026-08-14 13:16:49 -04:00
opencode-agent[bot] 2ca6d8ee49 chore: generate 2026-08-14 17:10:13 +00:00
opencode-agent[bot] d6c299cee7 fix(ai): remove over-safe provider error handling (#42610)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-14 12:08:59 -05:00
Kit Langton 737b6eb7b7 chore: extract Drive and catalog (#42612) 2026-08-14 13:05:24 -04:00
opencode-agent[bot] 923cb28a0a chore: generate 2026-08-14 16:39:28 +00:00
Kit Langton e79f3e8ca2 feat(tui): animate interrupt confirmation (#42602) 2026-08-14 16:38:06 +00:00
Shoubhit Dash 638cb414c4 test(ai): harden websocket lifecycle coverage (#42600) 2026-08-14 22:07:06 +05:30
Shoubhit Dash 445aacf92e feat(ai): add Responses websocket continuation (#42596) 2026-08-14 22:06:29 +05:30
Shoubhit Dash a2a53032d4 feat(core): manage session websocket channels (#41564) 2026-08-14 22:05:43 +05:30
Shoubhit Dash 810156d081 refactor(core): expose session transport eligibility (#41558) 2026-08-14 22:05:02 +05:30
Shoubhit Dash c0ae362996 refactor(ai): unify OpenAI Responses transport (#41048) 2026-08-14 22:04:16 +05:30
Shoubhit Dash de32afe358 refactor(ai): add sequential websocket channel seam (#41045) 2026-08-14 22:03:31 +05:30
Shoubhit Dash 2d953a69de fix(ai): harden websocket error contracts (#40695) 2026-08-14 22:01:37 +05:30
Kit Langton e3bbfdf270 feat(simulation): slim production bridge (#42584) 2026-08-14 15:57:44 +00:00
Shoubhit Dash 6d0c30a125 fix(tui): defer background work hint (#41563) 2026-08-14 20:57:36 +05:30
Kit Langton 1cf97fac5c fix(tui): optimize Mermaid terminal rendering (#42595) 2026-08-14 11:10:32 -04:00
opencode-agent[bot] f7d21babb6 chore: generate 2026-08-14 15:07:36 +00:00
Kit Langton b0480a6f93 feat(core): make portable shell scanner authoritative (#42581) 2026-08-14 11:06:04 -04:00
Kit Langton bdbccca1f3 feat(tui): refine question form interactions (#42493) 2026-08-14 14:54:32 +00:00
Shoubhit Dash 3d3bbd48f9 feat(core): add tavily web search (#42591) 2026-08-14 20:20:10 +05:30
opencode-agent[bot] 51f0b499f9 chore: generate 2026-08-14 14:40:07 +00:00
628 changed files with 8740 additions and 151188 deletions
+9
View File
@@ -0,0 +1,9 @@
---
"@opencode-ai/core": minor
"@opencode-ai/schema": minor
"@opencode-ai/protocol": minor
"@opencode-ai/client": minor
---
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
external directories, while the default tree-sitter path remains unchanged.
-49
View File
@@ -1,49 +0,0 @@
name: deploy-lab-catalog
on:
push:
branches: [v2]
paths:
- ".github/workflows/deploy-lab-catalog.yml"
- "bun.lock"
- "package.json"
- "packages/drive/**"
- "packages/protocol/src/simulation.ts"
- "packages/simulation/**"
- "packages/lab/catalog/**"
workflow_dispatch:
concurrency:
group: deploy-lab-catalog-${{ github.ref_name }}
cancel-in-progress: false
permissions:
contents: read
jobs:
deploy:
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- uses: ./.github/actions/setup-bun
- name: Install ffmpeg
run: |
sudo apt-get update
sudo apt-get install --yes ffmpeg
- name: Validate
run: |
bun --cwd packages/protocol typecheck
bun --cwd packages/simulation typecheck
bun --cwd packages/drive run check
bun --cwd packages/drive run test
bun --cwd packages/lab/catalog run check
- name: Deploy
working-directory: packages/lab/catalog
run: bun run deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+1 -1
View File
@@ -72,7 +72,7 @@ jobs:
- name: Run unit tests
timeout-minutes: 20
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
run: GITHUB_ACTIONS=false bun turbo test
env:
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
-253
View File
@@ -1,253 +0,0 @@
---
name: opencode-drive
description: Use when an agent needs drive OpenCode via a script or interact with an isolated instance
---
# OpenCode Drive
Use `opencode-drive` to launch an isolated OpenCode instance and control it via commands or a script.
There are two modes. Always default to using a script unless specifically directed to be interactive (connect
to an existing running instance, or start a new one, and make a few changes to the UI and read it, and iterate
on changes).
Scripts allow you to run a full walkthrough in one run. When the script is done opencode-drive exits,
stops all processes, and cleans up all artifacts.
# Prepare The Environment
Use `init` when files must be added to the isolated home or project before OpenCode starts. It prints the artifact directory without launching OpenCode. A later `start` with the same name reuses it.
```bash
artifacts=$(opencode-drive init --name demo)
cp -R ./fixtures/home/. "$artifacts/"
cp -R ./fixtures/project/. "$artifacts/files/"
opencode-drive start --name demo --dev ~/projects/opencode
```
The simulated project is under `$artifacts/files`. Running `start` without a prior `init` initializes the artifacts automatically.
# Scripted usage
You can write scripts that walk through entire flows, and gives you full access to controlling
the backend too. See examples of the script API at the bottom of this file.
After creating or editing a script, always typecheck it before running. Never skip this step:
```bash
opencode-drive check ./reproduce-stale-exploring-empty.ts
```
Run it by passing `--script` to start:
```bash
opencode-drive start --name auto-stop-reproduction --script ./reproduce-stale-exploring-empty.ts
```
It will output information about the run, including paths to log files which you can read
to inspect what happened. If you need to dig into failures that aren't clear, read those log
files. If the script is unsuccessful, automatically fix the script and run it again.
Scripts use one typed definition object. `setup` runs before OpenCode starts,
and `fs.writeFile` always writes inside the simulated project.
You can read the full typed API here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/src/script/types.ts
```ts
import { defineScript } from "opencode-drive"
export default defineScript({
async setup({ fs, config }) {
config.autoupdate = false
await fs.writeFile("src/example.ts", "export const value = 1\n")
},
async run({ ui, llm }) {
await ui.submit("Open src/example.ts")
await llm.send(llm.text("The file exports `value`."))
await ui.waitFor("The file exports `value`.")
},
})
```
`setup` receives the current OpenCode config object, which starts from the
default drive config unless the prepared instance already has one. When a script
needs custom config, mutate this `config` parameter instead of generating and
writing a new config object from scratch, so the script keeps the default
provider/model settings unless it intentionally changes them.
Note that the simulated model is a GPT model type, and opencode uses the `patch` tool for working with files Do not use a `edit` or `write` tool to edit files.
Use `launch: "manual"` when the script needs to launch the server and every TUI
itself (this is extremely rare, do not use this unless explicitly asked). In this
mode `ui` is typed as `null`; call `server.launch()` exactly
once before launching clients. Each `clients.launch(name)` result provides the
same UI methods as the automatic client. You can see an example of this API
here: https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/multiple-clients.ts
Use the exported `wait(milliseconds)` utility for an unconditional delay.
`await llm.send(...)` waits for the next request and resolves after OpenCode
acknowledges its complete response. `llm.queue(...)` declares responses in
advance. Chunks may be built with `text`, `reasoning`, `toolCall`, `raw`,
`finish`, and `disconnect`. A normal response receives `finish("stop")`
automatically unless it yields or queues an explicit terminal event.
`llm.text(text, { delay, chunkSize })` defaults to a 2 ms delay and a
15-character target varied by plus or minus 5 per chunk.
`llm.reasoning` accepts the same options, and `llm.pause(milliseconds)` adds a
delay between any two outputs.
Use `llm.serve` for an ongoing typed response generator:
```ts
llm.serve(async function* (request, index) {
yield llm.reasoning(`Handling request ${index + 1}`)
yield llm.text(`Received ${request.id}`)
yield llm.finish("stop")
})
```
The backend connection, response cleanup, cancellation, and recording
completion are automatic.
You can see some example scripts here:
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/simple.ts
- https://raw.githubusercontent.com/anomalyco/opencode/v2/packages/drive/examples/serve.ts
## Prune
- `prune` removes artifact directories. These are always cleaned up after running a script
successfully, but leftover on failed runs. Always call this if a script fails.
```bash
opencode-drive prune --name demo
// --force cleans up all artifcat directories
opencode-dirve prune --force
```
# Live interaction usage
- Always give headless instances a unique `--name`. Visible instances may omit it.
- A normal headless `start` detaches automatically and returns after the instance is ready.
- Do not add `&`; the long-running owner already runs in the background.
- Configure simulated model responses after startup when needed.
- Send ordered UI commands with `send`.
- Always stop the instance when finished.
```bash
opencode-drive start --name demo
opencode-drive send --name demo \
--command.ui.type '{"text":"Explain this project"}' \
--command.ui.enter
opencode-drive stop --name demo
```
## Send UI Commands
- Every `send` opens a connection to the named instance, runs its commands in order, and exits.
- Combine typing and Enter in one command when submitting a prompt.
- JSON-valued commands require one JSON argument.
- Multiple command flags execute from left to right.
Commands:
- `--command.ui.type <json>` types into the focused editor. Arguments: `text` string.
- `--command.ui.press <json>` presses a key. Arguments: `key` string; optional `modifiers` object with boolean `ctrl`, `shift`, `meta`, `super`, or `hyper`.
- `--command.ui.enter` presses Enter. Arguments: none.
- `--command.ui.arrow <json>` presses an arrow key. Arguments: `direction` is `up`, `down`, `left`, or `right`.
- `--command.ui.focus <json>` focuses an element. Arguments: `target` is the numeric element `num` returned by `ui.state`.
- `--command.ui.click <json>` clicks an element. Arguments: numeric `target`, `x`, and `y`; use the element `num` returned by `ui.state` as `target`.
- `--command.ui.state` prints focus and interactive element metadata as JSON. Arguments: none.
- `--command.ui.matches <json>` prints whether literal, case-sensitive text appears on screen. Arguments: `text` string.
```bash
opencode-drive send --name demo \
--command.ui.type '{"text":"Find the relevant code and explain it"}' \
--command.ui.enter
opencode-drive send --name demo \
--command.ui.press '{"key":"p","modifiers":{"ctrl":true}}'
opencode-drive send --name demo \
--command.ui.arrow '{"direction":"down"}'
opencode-drive send --name demo \
--command.ui.focus '{"target":12}'
opencode-drive send --name demo \
--command.ui.click '{"target":12,"x":4,"y":1}'
opencode-drive send --name demo \
--command.ui.matches '{"text":"OpenCode"}'
```
To read the UI state and see information about interactable elements, use the `ui.state` command:
```bash
opencode-drive send --name demo --command.ui.state
```
## Configure LLM Responses
- `responses` controls what the LLM responds with
- Only use this if you are wanting to reproduce an exact type of response
- Defaults are `text,reasoning,diff,tool` with `write,apply_patch`.
- Supported types are `text`, `reasoning`, `diff`, and `tool`.
- `--tools` limits generated tool calls to names offered by OpenCode.
```bash
opencode-drive responses --name demo \
--types text,reasoning,diff,tool \
--tools write,apply_patch
opencode-drive responses --name demo \
--types tool \
--tools read,glob,grep
```
## Inspect The UI
- `ui.state` prints focus and interactive element metadata as JSON.
- `ui.matches` checks for literal, case-sensitive screen text.
- `screenshot` prints the generated image path.
```bash
opencode-drive screenshot --name demo
```
## Lifecycle
- `stop` waits for recording export and owner cleanup before returning.
```bash
opencode-drive stop --name demo
```
# Record The UI
- Start with `--record` to capture a headless instance from its first rendered frame.
- `stop` finishes the recording, exports an MP4, and prints its path.
```bash
opencode-drive start --name demo --record
opencode-drive send --name demo \
--command.ui.type '{"text":"Show me the current architecture"}' \
--command.ui.enter
opencode-drive stop --name demo
```
# Artifacts dir
- `dir` prints the artifact directory for the instance.
```bash
opencode-drive dir --name demo
```
+21 -7
View File
@@ -12,6 +12,20 @@
- The script discovers the server with `opencode2 service status`, injects its private local credential from `opencode2 service get password`, and uses the `next` 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
- 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.
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
## TUI Theme Tokens
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
## Branch Names
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
@@ -161,14 +175,14 @@ const table = sqliteTable("session", {
## V2 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. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
- 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. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. 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 `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. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to 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. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
- 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 bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to 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 delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. 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 EventV2 replay owner claims separate from clustered Session execution ownership.
- 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 only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
- `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.
+44 -652
View File
File diff suppressed because it is too large Load Diff
+34 -29
View File
@@ -27,8 +27,9 @@ exits before expensive server boot. The design does not require clients to
agree on a single initiator.
This proposal does not introduce a supervisor process, warm candidate server,
protocol negotiation, idle background restart, or general execution-recovery
framework.
protocol negotiation, idle background restart, or clustered or exactly-once
execution recovery. Session execution separately provides bounded local recovery
through durable write-ahead claims.
## Architecture at a Glance
@@ -176,9 +177,9 @@ This design gives each concept one authority.
- Adding a permanent steward, proxy, or supervisor process.
- Zero-downtime worker handoff or automatic rollback.
- Application protocol negotiation or automatic TUI self-restart.
- General hard-crash recovery for active Sessions.
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
permissions, questions, or background jobs.
- Exactly-once recovery for provider attempts, tools, shells, sub-agents,
permissions, questions, or background jobs. Top-level Session continuation
after process death is handled separately through durable execution claims.
- Automatically killing a frozen owner.
- Bounding concurrent location cold boots after clients reconnect.
- Multi-machine or clustered service placement.
@@ -202,9 +203,10 @@ This design gives each concept one authority.
diagnosed, non-retryable cause.
8. **Clients do not kill an unresponsive owner automatically.** Destructive
recovery requires the explicit `service restart` command.
9. **Lifecycle does not promise execution semantics.** Graceful replacement
invokes Session suspension and resumption hooks, but tool-level continuity
belongs to a separate design.
9. **Lifecycle does not promise exactly-once execution.** A successor invokes
the Session execution-claim sweep, which resumes from durable history.
Provider-attempt identity and tool-side-effect fencing belong to separate
designs.
## System Model
@@ -485,23 +487,20 @@ The UI derives text from status:
| `ready` | Normal TUI |
## Session Continuity
Every process-local Session busy period writes a durable execution claim before
resumption hooks:
its runner starts. Success, failure, and user interruption release the claim;
shutdown interruption and process death leave it intact. The
successor sweeps claimed top-level Sessions, durably counts a recovery attempt,
appends a continuation instruction, and resumes from projected history. The same
mechanism covers graceful replacement, crash, SIGKILL, and runtime eviction.
Recovery fails stale running tool projections before further model work, but it
2. The successor schedules those Sessions for continuation.
3. The runner reloads durable Session history before continuing.
This lifecycle design does not define what an interrupted physical provider
attempt or tool invocation means. It does not promise that external side effects
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
recover process-local background work.
Those concerns require a separate execution-continuity design covering tools,
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
recovery.
does not prove whether an interrupted provider request or external operation
already took effect. It does not replay the exact interrupted tool, preserve an
in-memory form, recover process-local background work, or guarantee exactly-once
provider or tool behavior.
## Unresponsive Owner
An unreachable registration does not prove that the owner is dead. A contender
@@ -531,13 +530,15 @@ Automatic frozen-owner recovery is deferred.
1. The old service installs vNext but keeps running.
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
3. The old service reports `stopping` and exits. Shutdown interruption preserves
4. Open TUIs enter their indefinite status loops.
the execution claims already written by active Sessions.
4. Open TUIs enter their indefinite status loops.
5. One or more clients spawn contenders.
6. One contender acquires the service lock. Losers exit before heavy boot.
7. The winner binds and registers the lifecycle shell as `starting`.
8. Clients stop spawning and wait on the observable winner.
9. The winner initializes the application, sweeps orphaned execution claims,
10. TUIs rebuild clients, reconcile state, and resume.
and reports `ready`.
10. TUIs rebuild clients, reconcile state, and resume.
### Server crashes while ready
@@ -546,7 +547,9 @@ Automatic frozen-owner recovery is deferred.
3. Process death has released the service lock.
4. One contender wins, replaces registration, and starts normally.
5. Application startup sweeps orphaned top-level execution claims and resumes
them with bounded attempt accounting. External side effects remain
potentially ambiguous.
### Winner crashes during startup
1. Clients observed `starting` and remain alive.
@@ -650,8 +653,9 @@ was the observed incident cost.
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
reconnect never activates replacement.
7. **Integrate Session continuity.** Preserve current background-install and
fresh-launch activation behavior while invoking Session continuity hooks.
8. **Harden explicit recovery.** Verify exact process identity during explicit
fresh-launch activation behavior while invoking startup execution-claim
recovery.
8. **Harden explicit recovery.** Verify exact process identity during explicit
`service restart`; never automatically kill an unresponsive owner.
9. **Run the full multi-process suite.** Include repeated restart cycles and
assert that no contender or child process remains afterward.
@@ -677,7 +681,8 @@ was the observed incident cost.
- Idle background update activation with an admission fence.
- Application protocol compatibility and automatic local TUI re-exec.
- Stronger execution recovery with provider-attempt identity, tool-side-effect
- Shell, sub-agent, permission, question, and background-job continuity.
idempotency or fencing, and clustered ownership.
- Shell, sub-agent, permission, question, and background-job continuity.
- Automatic recovery for a positively identified frozen owner.
- Cold-boot concurrency limits and interaction-prioritized location loading.
- A steward or socket-handoff architecture if zero-downtime replacement becomes
-2
View File
@@ -33,7 +33,6 @@
"packages": [
"packages/*",
"packages/console/*",
"packages/lab/*",
"packages/stats/*",
"packages/slack"
],
@@ -174,7 +173,6 @@
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
}
}
+8 -7
View File
@@ -80,7 +80,7 @@ Route defaults are request-shaping defaults such as `headers`, `limits`, `genera
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
When a provider ships a non-HTTP transport (OpenAI's WebSocket Responses backend, hypothetical bidirectional streaming APIs), the seam is `Transport``WebSocketTransport.jsonTransport.with(...)` constructs an IO template whose `prepare` receives the route endpoint/auth at compile time, builds a WebSocket URL and message, and whose `frames` yields decoded text from the socket. Same protocol and endpoint source, different transport.
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.
### URL Construction
@@ -106,7 +106,7 @@ 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`, `responsesWebSocket`, and `chat`.
- 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`.
@@ -124,11 +124,10 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey,
transport: "websocket",
})
```
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. Keep transport choices inside the semantic entrypoint settings, so OpenAI Responses HTTP and WebSocket share one entrypoint. Provider facades may still expose named selectors such as `responsesWebSocket` for direct typed call sites; the package-like contract maps its settings to those selectors before returning an executable `LanguageModel`.
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(...)`.
@@ -154,14 +153,16 @@ packages/ai/src/
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
framing.ts Framing type + Framing.sse
transport/ transport implementations
index.ts Transport type + HttpTransport / WebSocketTransport namespaces
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
websocket-channel.ts generic sequential channel executor/driver contract
http.ts HttpTransport.httpJson — POST + framing
websocket.ts WebSocketTransport.json + WebSocketExecutor service
websocket.ts direct one-request channel executor + raw socket adapter
protocols/
shared.ts ProviderShared toolkit used inside protocol impls
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
open-responses.ts provider-neutral Responses protocol baseline
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
anthropic-messages.ts
gemini.ts
bedrock-converse.ts
+1 -2
View File
@@ -315,7 +315,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
const selected = model("gpt-5", {
apiKey: process.env.OPENAI_API_KEY,
transport: "websocket",
headers: { "x-application": "opencode" },
limits: { context: 200_000, output: 64_000 },
})
@@ -332,7 +331,7 @@ OpenAI Chat and OpenAI Responses are separate semantic entrypoints:
- `@opencode-ai/ai/providers/google-vertex/responses`
- `@opencode-ai/ai/providers/google-vertex/messages`
Responses HTTP versus WebSocket is a scoped `transport` setting on the OpenAI Responses entrypoint, not another entrypoint. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, defaults, and transports. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
OpenAI Responses has one semantic route and uses HTTP by default. Advanced callers may supply a per-call WebSocket channel executor through `StreamOptions`; transport policy does not change provider settings, model identity, or route identity. The provider-neutral Open Responses implementation owns the reusable WebSocket request and event contract, while each provider opts in with its own handshake and connection policy. Azure follows the same Chat/Responses split at `providers/azure/chat` and `providers/azure/responses`. Generic OpenAI-compatible Chat remains at `providers/openai-compatible`; the Responses adapter at `providers/openai-compatible/responses` uses the provider-neutral Open Responses protocol. OpenAI Responses extends that baseline with OpenAI tools, event variants, metadata, and defaults. Generic Anthropic Messages-compatible providers use `providers/anthropic-compatible`, which the named Anthropic provider composes. Google Gemini and Amazon Bedrock expose their single native API through their existing provider paths.
Vertex Gemini, Vertex Chat, Vertex Responses, and Vertex Messages are separate API entrypoints. All accept `project`, `location`, and an optional `accessToken`; when no explicit token or auth override is supplied they lazily use Google Application Default Credentials. Vertex Gemini instead selects express mode when `apiKey` or `GOOGLE_VERTEX_API_KEY` is present. Vertex Chat targets MaaS models through the OpenAI-compatible Chat Completions endpoint, while Vertex Responses targets Grok models and defaults `store` to `false` as required by Vertex. `providers/google-vertex` remains the default alias for `providers/google-vertex/gemini`.
+33 -34
View File
@@ -1,6 +1,6 @@
# LLM Provider Parity Status
Last reviewed: 2026-07-24
Last reviewed: 2026-08-07
This file tracks the gap between the native `@opencode-ai/ai` package and the AI SDK provider packages that opencode still depends on for many catalog/runtime paths.
@@ -16,8 +16,7 @@ This file tracks the gap between the native `@opencode-ai/ai` package and the AI
| Native slice | Source | Current state | Main gaps |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| OpenAI Chat | `src/protocols/openai-chat.ts`, `src/providers/openai.ts` | Usable. Streams text, reasoning deltas, tool calls, usage, images, and common generation controls. | No typed structured-output / `response_format` path. Limited typed OpenAI option surface compared with SDK escape hatches. |
| OpenAI Responses HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
| OpenAI Responses | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable over HTTP by default, with optional per-call WebSocket channel execution on the same model and route identity. | No incremental `previous_response_id` path or persistent Session channel manager yet. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
| OpenAI-compatible Chat | `src/protocols/openai-compatible-chat.ts`, `src/providers/openai-compatible.ts` | Usable for generic Chat and several profiles: Baseten, Cerebras, DeepInfra, DeepSeek, Fireworks, Groq, TogetherAI. | Family quirks are mostly endpoint defaults, not full typed behavior. |
| Open Responses-compatible | `src/protocols/open-responses.ts`, `src/protocols/openai-compatible-responses.ts`, `src/providers/openai-compatible-responses.ts` | Usable for deployments that implement the provider-neutral Open Responses protocol. The deployment adapter does not inherit OpenAI tools, events, metadata, or defaults. | No named family profiles or recorded deployment coverage yet. |
| Anthropic-compatible Messages | `src/protocols/anthropic-messages.ts`, `src/providers/anthropic-compatible.ts` | Usable for deployments that implement the Anthropic Messages wire protocol. Named Anthropic composes this base; MiniMax M3 has recorded text and tool-loop coverage. | No named compatible family profiles yet. |
@@ -48,19 +47,19 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
## AI SDK Package Parity Matrix
| AI SDK package | Intended native target | Status | Biggest gaps |
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
| AI SDK package | Intended native target | Status | Biggest gaps |
| --------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner execution policy for optional WebSocket channels. |
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
## Highest-Risk Gaps
@@ -78,24 +77,24 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
These are implementation/API slices, not separate npm packages.
| API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
| API slice | Package-like entrypoint | Purpose |
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP default and optional per-call WebSocket execution. |
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
## Suggested Next Work Slices
+18 -41
View File
@@ -67,7 +67,6 @@ Examples:
```ts
OpenAI.responses("gpt-4o")
OpenAI.chat("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
@@ -250,11 +249,6 @@ const openAIChat = Route.make({
auth: Auth.envBearer("OPENAI_API_KEY"),
})
const openAIResponsesWebSocket = openAIResponses.with({
id: "openai-responses-websocket",
transport: WebSocketTransport.json,
})
const openAIConfig = (input: OpenAIConfig) => ({
endpoint: input.endpoint,
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
@@ -266,13 +260,11 @@ const openAIConfig = (input: OpenAIConfig) => ({
const configureOpenAI = (input: OpenAIConfig = {}) => {
const responses = openAIResponses.with(openAIConfig(input))
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
const chat = openAIChat.with(openAIConfig(input))
return {
id: openAIProvider,
responses: responses.model,
responsesWebSocket: responsesWebSocket.model,
chat: chat.model,
model: responses.model,
configure: configureOpenAI,
@@ -342,22 +334,19 @@ const response =
)
```
For direct provider-facade calls, HTTP versus WebSocket is represented as named
route selectors, not as model or request overrides. Same protocol, different
transport, different route:
For direct provider-facade calls, Responses has one semantic model and route:
```ts
OpenAI.responses("gpt-4o")
OpenAI.responsesWebSocket("gpt-4o")
```
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
Responses settings while preserving the same `model(...)` contract:
The package-like OpenAI Responses entrypoint has the same transport-neutral
`model(...)` contract:
```ts
import { model } from "@opencode-ai/ai/providers/openai/responses"
model("gpt-4o", { apiKey, transport: "websocket" })
model("gpt-4o", { apiKey })
```
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
@@ -387,11 +376,9 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
model("claude-sonnet-4-6", { project, location: "global" })
```
The client should not require a different public layer just because a selected
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
capabilities available; routes that do not need WebSocket simply never touch it.
If a WebSocket route is selected in an environment without WebSocket support,
fail with a typed transport configuration error.
The client does not require a different public layer for WebSocket execution.
Responses routes use HTTP by default, and callers may pass a channel executor per
call. Routes without channel support simply ignore that execution capability.
Azure is a route specialization with auth/path/default changes plus input
mapping. The public API configures the Azure resource once, then selects
@@ -497,18 +484,13 @@ generic dynamic resolver:
```ts
const model =
providerID === "azure"
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
: endpoint.websocket
? OpenAI.responsesWebSocket(apiModelID)
: OpenAI.responses(apiModelID)
providerID === "azure" ? Azure.configure(resolvedAzureConfig).responses(apiModelID) : OpenAI.responses(apiModelID)
```
That boundary can branch on durable config/catalog metadata and call typed
provider APIs directly. A direct provider-facade boundary maps metadata like
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
The client runtime only executes the route carried by the resulting model.
provider APIs directly. Transport selection remains execution policy: a Session
or other caller may pass a WebSocket channel executor per call without changing
the model constructed by this boundary.
## Competitive Shape
@@ -544,9 +526,8 @@ App boundary = explicit durable-config -> typed-provider call
id.
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
endpoint/auth/deployment customization happens by configuring the route first.
- No transport override on an executable model or request. Direct provider
facades use `responses` versus `responsesWebSocket`; the package-like Responses
entrypoint maps its scoped `transport` setting before constructing the model.
- No transport setting on a provider or executable model. OpenAI Responses uses
HTTP by default and accepts an optional per-call channel executor as execution policy.
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
client layer with the available transport capabilities.
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
@@ -580,12 +561,10 @@ App boundary = explicit durable-config -> typed-provider call
- [x] Make unconfigured transports reusable constants such as
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
state construction.
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
exposes available transport capabilities and selected routes fail with typed
transport config errors when a required capability is missing.
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
optional per-call channel execution without changing route identity.
- [x] Convert OpenAI provider APIs to provider-facade shape:
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and
`.responsesWebSocket(id)`.
`OpenAI.configure(config).responses(id)` and `.chat(id)`.
- [x] Convert Azure to a configured facade where resource/base URL/api version
setup happens before selecting deployment ids.
- [x] Split Cloudflare products into separate facades such as
@@ -599,10 +578,8 @@ App boundary = explicit durable-config -> typed-provider call
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
or three provider conversions; start with plain objects if duplication is not
yet painful.
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
executable models at the session boundary with explicit provider facade
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
named route selector.
- [x] Keep executable model construction transport-neutral at the Session boundary;
Session-scoped execution policy supplies channel capability separately.
- [ ] Update tests so direct route/provider tests assert route values are carried
by executable models, and opencode/native tests assert boundary-based route
selection.
+3 -4
View File
@@ -1,6 +1,6 @@
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode-ai/ai/route"
import { OpenAI } from "@opencode-ai/ai/providers"
/**
@@ -213,8 +213,7 @@ const FakeEcho = {
// enabled at a time so the tutorial can demonstrate generate, stream, or
// tool-loop behavior without spending tokens on every example.
const requestExecutorLayer = RequestExecutor.fetchLayer
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
const program = Effect.gen(function* () {
// yield* generateOnce
@@ -222,6 +221,6 @@ const program = Effect.gen(function* () {
// yield* generateStructuredObject
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
yield* streamWithTools
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
Effect.runPromise(program)
+1
View File
@@ -7,3 +7,4 @@ export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
export * as OpenAIResponses from "./openai-responses.js"
export * as OpenResponses from "./open-responses.js"
export * as OpenResponsesChannel from "./open-responses-channel.js"
@@ -0,0 +1,191 @@
import { Effect, Schema, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Framing } from "../route/framing.js"
import {
HttpTransport,
WebSocketTransport,
type Transport,
type WebSocketChannelDriver,
type WebSocketChannelExchange,
} from "../route/transport/index.js"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const WebSocketResponseCreate = Schema.StructWithRest(Schema.Struct({ type: Schema.tag("response.create") }), [
Schema.Record(Schema.String, Schema.Unknown),
])
const decodeMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(WebSocketResponseCreate))
const encodeMessage = Schema.encodeSync(Schema.fromJsonString(WebSocketResponseCreate))
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
export interface Options {
readonly id: string
readonly name: string
readonly rotateAfterMs?: number
readonly headers?: (headers: Headers.Headers) => Headers.Headers
readonly driver?: (input: {
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}) => WebSocketChannelDriver
}
export interface Prepared {
readonly http: HttpTransport.HttpPrepared<string>
readonly channel?: {
readonly url: string
readonly headers: Headers.Headers
readonly rotateAfterMs?: number
readonly driver: WebSocketChannelDriver
}
}
const message = (body: unknown) =>
Effect.gen(function* () {
if (!ProviderShared.isRecord(body))
return yield* ProviderShared.invalidRequest("Open Responses WebSocket body must be a JSON object")
const { stream: _stream, stream_options: _streamOptions, background: _background, ...request } = body
const decoded = yield* decodeMessage({ ...request, type: "response.create" })
return { request: decoded, message: encodeMessage(decoded) }
})
const driver = (options: Options, body: string): WebSocketChannelDriver => {
let responseID: string | undefined
let terminal = false
return {
create: () =>
Effect.sync(() => {
responseID = undefined
terminal = false
return { message: body, mode: "full" }
}),
observe: (_create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError(() =>
ProviderShared.eventError(options.id, `Invalid ${options.name} WebSocket event`, frame),
),
)
if (terminal)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted ${event.type} after a terminal event`,
frame,
)
if (event.type === "error") {
terminal = true
yield* OpenResponses.decodeKnownErrorEvent(event).pipe(
Effect.mapError(() =>
ProviderShared.eventError(options.id, `${options.name} returned a malformed error event`, frame),
),
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(options.id, event, `${options.name} stream error`),
}
}
if (event.type === "response.failed") {
terminal = true
if (responseID && event.response?.id && event.response.id !== responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response ID changed during execution`,
frame,
)
return {
type: "provider-failure",
error: OpenResponses.providerFailure(options.id, event, `${options.name} response failed`),
}
}
if (event.type === "response.created") {
const created = event.response?.id
if (responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted duplicate response.created`,
frame,
)
if (!created)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response.created is missing response.id`,
frame,
)
responseID = created
return { type: "frame", frame }
}
if (!responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} emitted ${event.type} before response.created`,
frame,
)
if (event.response?.id && event.response.id !== responseID)
return yield* ProviderShared.eventError(
options.id,
`${options.name} response ID changed during execution`,
frame,
)
if (event.type === "response.completed") {
terminal = true
return { type: "completed", frame }
}
if (event.type === "response.incomplete") {
terminal = true
return { type: "incomplete", frame }
}
return { type: "frame", frame }
}),
}
}
export const transport = <Body>(options: Options): Transport<Body, Prepared, string> => {
const http = HttpTransport.sseJson.with<Body>()
return {
id: http.id,
prepare: (input) =>
Effect.gen(function* () {
const parts = yield* HttpTransport.jsonRequestParts(input)
const headers = Headers.remove(options.headers?.(parts.headers) ?? parts.headers, "content-length")
const channel = input.webSocket
? yield* Effect.gen(function* () {
const create = yield* message(parts.jsonBody)
const base = driver(options, create.message)
return {
url: yield* WebSocketTransport.toWebSocketUrl(parts.url),
headers,
rotateAfterMs: options.rotateAfterMs,
driver: options.driver?.({ request: create.request, message: create.message, base }) ?? base,
}
})
: undefined
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
framing: Framing.sse,
middleware: input.middleware,
},
channel,
}
}),
execute: (prepared, request, runtime, executeOptions) => {
if (!executeOptions?.webSocket || !prepared.channel) return http.execute(prepared.http, request, runtime)
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
connect: {
url: prepared.channel.url,
headers: prepared.channel.headers,
rotateAfterMs: prepared.channel.rotateAfterMs,
},
fallback: () =>
Stream.unwrap(
http.execute(prepared.http, request, runtime).pipe(Effect.map((execution) => execution.frames)),
),
driver: prepared.channel.driver,
}
return executeOptions.webSocket.execute(exchange)
},
}
}
export const OpenResponsesChannel = { transport } as const
+54 -7
View File
@@ -211,11 +211,43 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
// event-level `error` envelope, so accept all three shapes here.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("error"),
status: Schema.optional(Schema.Number),
status_code: Schema.optional(Schema.Number),
code: optionalNull(Schema.String),
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
export const decodeKnownErrorEvent = (event: Event) =>
decodeWebSocketErrorEvent({
...event,
status: typeof event.status === "number" ? event.status : undefined,
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
headers: ProviderShared.isRecord(event.headers)
? Object.fromEntries(
Object.entries(event.headers).filter(
(entry): entry is [string, string | number | boolean] =>
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
),
)
: undefined,
})
export const Event = Schema.StructWithRest(
Schema.Struct({
type: Schema.String,
@@ -240,6 +272,9 @@ export const Event = Schema.StructWithRest(
message: Schema.optional(Schema.String),
param: optionalNull(Schema.String),
error: optionalNull(OpenResponsesErrorPayload),
status: Schema.optional(Schema.Unknown),
status_code: Schema.optional(Schema.Unknown),
headers: Schema.optional(Schema.Unknown),
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
@@ -632,9 +667,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
// `response.completed` / `response.incomplete` are clean finishes that emit a
// `finish` event; `response.failed` is a hard failure. All three end the stream,
// so keep this set aligned with `step` and the protocol's terminal predicate.
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
// `finish` event; `response.failed` and `error` are hard failures. All four end
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
@@ -966,16 +1001,24 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
return message || code || fallback
}
const providerError = (state: ParserState, event: Event, fallback: string) => {
export const providerFailure = (id: string, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
const status =
typeof event.status === "number"
? event.status
: typeof event.status_code === "number"
? event.status_code
: undefined
return new AIError({
module: state.id,
module: id,
method: "stream",
reason: classifyProviderFailure({ message, code }),
reason: classifyProviderFailure({ message, code, status }),
})
}
const providerError = (state: ParserState, event: Event, fallback: string) => providerFailure(state.id, event, fallback)
export const step = (state: ParserState, event: Event) => {
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
@@ -1015,7 +1058,11 @@ export const step = (state: ParserState, event: Event) => {
if (event.type === "response.completed" || event.type === "response.incomplete")
return Effect.succeed(onResponseFinish(state, event))
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
if (event.type === "error")
return decodeKnownErrorEvent(event).pipe(
Effect.mapError(() => ProviderShared.eventError(state.id, `${state.name} returned a malformed error event`)),
Effect.flatMap(() => providerError(state, event, `${state.name} stream error`)),
)
return Effect.succeed<StepResult>([state, NO_EVENTS])
}
@@ -0,0 +1,164 @@
import { AIError, TransportReason } from "../schema/index.js"
import type { ChannelCheckpoint, ChannelObservation, WebSocketChannelDriver } from "../route/transport/index.js"
import { Effect, Option, Schema } from "effect"
import * as ProviderShared from "./shared.js"
import { OpenResponses } from "./open-responses.js"
const PROTOCOL = "openai-responses.websocket.v1"
const VERSION = 1
const decodeEvent = Schema.decodeUnknownEffect(OpenResponses.protocol.stream.event)
interface CheckpointValue {
readonly version: typeof VERSION
readonly responseID: string
readonly request: Readonly<Record<string, unknown>>
readonly output: ReadonlyArray<unknown>
}
export interface DriverInput {
readonly id: string
readonly name: string
readonly request: Readonly<Record<string, unknown>>
readonly message: string
readonly base: WebSocketChannelDriver
}
const checkpointValue = (checkpoint: ChannelCheckpoint | undefined): CheckpointValue | undefined => {
if (checkpoint?.protocol !== PROTOCOL || !ProviderShared.isRecord(checkpoint.value)) return undefined
if (checkpoint.value.version !== VERSION) return undefined
if (typeof checkpoint.value.responseID !== "string" || checkpoint.value.responseID.trim().length === 0)
return undefined
if (!ProviderShared.isRecord(checkpoint.value.request) || !Array.isArray(checkpoint.value.output)) return undefined
return {
version: VERSION,
responseID: checkpoint.value.responseID,
request: checkpoint.value.request,
output: checkpoint.value.output,
}
}
const canonical = (value: unknown): string => {
if (value === undefined) return "undefined"
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`
if (!ProviderShared.isRecord(value)) return ProviderShared.encodeJson(value)
return `{${Object.keys(value)
.sort()
.map((key) => `${ProviderShared.encodeJson(key)}:${canonical(value[key])}`)
.join(",")}}`
}
const json = (value: unknown) => {
if (typeof value !== "string") return value
return Option.getOrElse(Schema.decodeUnknownOption(ProviderShared.Json)(value), () => value)
}
const comparable = (value: unknown) => {
if (!ProviderShared.isRecord(value)) return value
if (value.type === "message" && value.role === "assistant")
return {
role: "assistant",
content: value.content,
...(value.phase === undefined ? {} : { phase: value.phase }),
}
if (value.type === "function_call")
return {
type: value.type,
call_id: value.call_id,
name: value.name,
arguments: json(value.arguments),
}
if (value.type === "reasoning")
return {
type: value.type,
summary: value.summary,
encrypted_content: value.encrypted_content,
}
return value
}
const invariant = (request: Readonly<Record<string, unknown>>) => {
const { type: _type, input: _input, previous_response_id: _previousResponseID, ...rest } = request
return rest
}
const incremental = (
request: Readonly<Record<string, unknown>>,
checkpoint: CheckpointValue,
): ReadonlyArray<unknown> | undefined => {
const input = request.input
const previousInput = checkpoint.request.input
if (!Array.isArray(input) || !Array.isArray(previousInput)) return undefined
if (canonical(invariant(request)) !== canonical(invariant(checkpoint.request))) return undefined
const baseline = [...previousInput, ...checkpoint.output]
if (input.length <= baseline.length) return undefined
if (!baseline.every((item, index) => canonical(comparable(item)) === canonical(comparable(input[index]))))
return undefined
return input.slice(baseline.length)
}
const code = (event: OpenResponses.Event) => event.code || event.error?.code || event.response?.error?.code || undefined
const rejected = (
input: DriverInput,
observation: Extract<ChannelObservation, { readonly type: "provider-failure" }>,
recovery: "retry-full" | "rotate-and-retry-full",
): ChannelObservation => ({
type: "rejected",
recovery,
error: new AIError({
module: input.id,
method: "stream",
reason: new TransportReason({
message: observation.error.message,
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "rejected",
recovery,
}),
}),
})
export const driver = (input: DriverInput): WebSocketChannelDriver => {
const { previous_response_id: _previousResponseID, ...request } = input.request
let output: unknown[] = []
return {
create: (checkpoint) =>
Effect.sync(() => {
output = []
const previous = checkpointValue(checkpoint)
const delta = previous ? incremental(request, previous) : undefined
if (!previous || !delta) return { message: ProviderShared.encodeJson(request), mode: "full" as const }
return {
message: ProviderShared.encodeJson({ ...request, input: delta, previous_response_id: previous.responseID }),
mode: "incremental" as const,
}
}),
observe: (create, frame) =>
Effect.gen(function* () {
const event = yield* decodeEvent(frame).pipe(
Effect.mapError(() => ProviderShared.eventError(input.id, `Invalid ${input.name} WebSocket event`, frame)),
)
const observation = yield* input.base.observe(create, frame)
if (event.type === "response.output_item.done" && event.item) output.push(event.item)
if (observation.type === "provider-failure") {
const rejection = code(event)
if (rejection === "previous_response_not_found") return rejected(input, observation, "retry-full")
if (rejection === "websocket_connection_limit_reached")
return rejected(input, observation, "rotate-and-retry-full")
}
if (observation.type !== "completed") return observation
const responseID = event.response?.id
if (!responseID || responseID.trim().length === 0) return observation
return {
...observation,
checkpoint: {
protocol: PROTOCOL,
value: { version: VERSION, responseID, request, output: output.slice() } satisfies CheckpointValue,
},
}
}),
}
}
export const OpenAIResponsesChannel = { driver } as const
+14 -41
View File
@@ -1,18 +1,23 @@
import { Effect, Encoding, Schema } from "effect"
import { Headers } from "effect/unstable/http"
import { Route } from "../route/client.js"
import { Auth } from "../route/auth.js"
import { Endpoint } from "../route/endpoint.js"
import { Protocol } from "../route/protocol.js"
import { HttpTransport, WebSocketTransport } from "../route/transport/index.js"
import { HttpTransport } from "../route/transport/index.js"
import { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
import { OpenResponses } from "./open-responses.js"
import { optionalArray, ProviderShared } from "./shared.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { OpenAIImage } from "./utils/openai-image.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
import { OpenResponsesChannel } from "./open-responses-channel.js"
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
const ADAPTER = "openai-responses"
const NAME = "OpenAI Responses"
const WEBSOCKET_PROTOCOL_HEADER = "responses_websockets=2026-02-06"
const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
export const PATH = OpenResponses.PATH
@@ -57,16 +62,6 @@ const OpenAIResponsesBody = Schema.Struct({
})
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
Schema.Struct({
type: Schema.tag("response.create"),
...OpenAIResponsesCoreFields,
}),
[Schema.Record(Schema.String, Schema.Unknown)],
)
type OpenAIResponsesWebSocketMessage = Schema.Schema.Type<typeof OpenAIResponsesWebSocketMessage>
const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage))
const extension = {
id: ADAPTER,
name: NAME,
@@ -249,6 +244,13 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
const auth = Auth.none
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
id: ADAPTER,
name: NAME,
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
})
export const route = Route.make({
id: ADAPTER,
@@ -257,36 +259,7 @@ export const route = Route.make({
protocol,
endpoint,
auth,
transport: httpTransport,
defaults: { providerOptions: { openai: { store: false } } },
})
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
Effect.gen(function* () {
if (!ProviderShared.isRecord(body))
return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object")
const { stream: _stream, ...message } = body
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
})
export const webSocketTransport = WebSocketTransport.jsonTransport.with<
OpenAIResponsesBody,
OpenAIResponsesWebSocketMessage
>({
toMessage: webSocketMessage,
encodeMessage: encodeWebSocketMessage,
})
export const webSocketRoute = Route.make({
id: `${ADAPTER}-websocket`,
provider: "openai",
providerMetadataKey: "openai",
protocol,
endpoint,
auth,
transport: webSocketTransport,
transport,
defaults: { providerOptions: { openai: { store: false } } },
})
+1
View File
@@ -67,6 +67,7 @@ const SERVER_CODES = new Set([
"overloaded_error",
"server_error",
"server_is_overloaded",
"slow_down",
"serviceunavailableexception",
])
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
+2 -13
View File
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images.js"
export const id = ProviderID.make("openai")
export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, OpenAIChat.route]
export const routes = [OpenAIResponses.route, OpenAIChat.route]
// This provider facade wraps the lower-level Responses and Chat model factories
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
@@ -63,7 +63,6 @@ export interface Settings extends ProviderPackage.Settings {
readonly organization?: string
readonly project?: string
readonly queryParams?: Readonly<Record<string, string>>
readonly transport?: "http" | "websocket"
readonly providerOptions?: OpenAIProviderOptionsInput
}
@@ -82,17 +81,12 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
export const configure = (input: Config = {}) => {
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
const chatRoute = configuredRoute(OpenAIChat.route, input)
const modelDefaults = defaults(input)
const responses = (id: string | ModelID) =>
responsesRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const responsesWebSocket = (id: string | ModelID) =>
responsesWebSocketRoute
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
const image = (modelID: string | ModelID) =>
@@ -111,7 +105,6 @@ export const configure = (input: Config = {}) => {
id,
model: responses,
responses,
responsesWebSocket,
chat,
image,
configure,
@@ -138,10 +131,7 @@ const config = (settings: Settings): Config => {
}
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
const configured = configure(config(settings))
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
return configure(config(settings)).responses(modelID)
}
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
@@ -149,6 +139,5 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
settings,
) => configure(config(settings)).chat(modelID)
export const responses = provider.responses
export const responsesWebSocket = provider.responsesWebSocket
export const chat = provider.chat
export const image = provider.image
+24 -19
View File
@@ -1,12 +1,10 @@
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
import * as Option from "effect/Option"
import { Auth } from "./auth.js"
import { Endpoint, type EndpointPatch } from "./endpoint.js"
import { RequestExecutor } from "./executor.js"
import { Framing } from "./framing.js"
import { HttpTransport } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport/index.js"
import { WebSocketExecutor } from "./transport/index.js"
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
import type { Protocol } from "./protocol.js"
import { applyCachePolicy } from "../cache-policy.js"
import * as ProviderShared from "../protocols/shared.js"
@@ -58,6 +56,7 @@ export interface Route<Body, Prepared = unknown> {
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
options?: StreamOptions,
) => Stream.Stream<LLMEvent, AIError>
}
@@ -157,6 +156,7 @@ export interface Interface {
export interface StreamOptions {
readonly http?: HttpMiddleware
readonly webSocket?: WebSocketChannelExecutor
}
export interface StreamMethod {
@@ -314,23 +314,29 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
encodeBody,
headers: routeInput.headers,
middleware: options?.http,
webSocket: options?.webSocket,
}),
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime, options?: StreamOptions) => {
const route = `${request.model.provider}/${request.model.route.id}`
const events = routeInput.transport
.frames(prepared, request, runtime)
.pipe(
Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
)
return events.pipe(
Stream.mapAccumEffect(
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
return Stream.unwrap(
routeInput.transport.execute(prepared, request, runtime, options).pipe(
Effect.map((execution) => {
const events = execution.frames.pipe(
Stream.mapEffect(decodeEvent(route)),
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
)
const stream = events.pipe(
Stream.mapAccumEffect(
() => protocol.stream.initial(request),
protocol.stream.step,
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
),
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
)
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
}),
),
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
requireTerminalEvent(route),
)
},
} satisfies Route<Body, Prepared>
@@ -413,7 +419,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
Stream.unwrap(
Effect.gen(function* () {
const compiled = yield* compile(request, options)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime, options)
}),
)
@@ -451,7 +457,6 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
Effect.gen(function* () {
const stream = streamRequestWith({
http: yield* RequestExecutor.Service,
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
})
return Service.of({ stream, generate: generateWith(stream) })
}),
+33 -111
View File
@@ -34,44 +34,8 @@ export type HttpMiddleware = (
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
const BODY_LIMIT = 16_384
const REDACTED = "<redacted>"
// One source of truth for what counts as a sensitive name across headers,
// URL query keys, and field names embedded inside request/response bodies.
//
// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header
// names like `Authorization` / `X-API-Key`) and as the body-field alternation
// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…`
// that are too generic to redact substring-style without false positives.
const SENSITIVE_NAME_SOURCE =
"authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
const SHORT_QUERY_NAME = /^(key|sig)$/i
const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")
const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
Object.fromEntries(
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
name,
String(value),
]),
)
const redactUrl = (value: string) => {
if (!URL.canParse(value)) return REDACTED
const url = new URL(value)
url.searchParams.forEach((_, key) => {
if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED)
})
return url.toString()
}
const headerDetails = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)]))
const normalizedHeaders = (headers: Headers.Headers) =>
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
@@ -144,58 +108,22 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
})
}
const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
new HttpRequestDetails({
method: request.method,
url: redactUrl(request.url),
headers: redactHeaders(request.headers, redactedNames),
url: request.url,
headers: headerDetails(request.headers),
})
const responseDetails = (
response: HttpClientResponse.HttpClientResponse,
redactedNames: ReadonlyArray<string | RegExp>,
) =>
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
new HttpResponseDetails({
status: response.status,
headers: redactHeaders(response.headers, redactedNames),
headers: headerDetails(response.headers),
})
const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
const values = new Set<string>()
const add = (value: string) => {
if (value.length < 4) return
values.add(value)
values.add(encodeURIComponent(value))
}
Object.entries(request.headers).forEach(([name, value]) => {
if (!isSensitiveHeaderName(name)) return
add(value)
const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1]
if (bearer) add(bearer)
})
if (!URL.canParse(request.url)) return values
new URL(request.url).searchParams.forEach((value, key) => {
if (isSensitiveQueryName(key)) add(value)
})
return values
}
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
// for any field name that looks sensitive) plus literal (replace any actual
// secret values we sent in the request, in case the response echoes one back).
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
Array.from(secrets).reduce(
(text, secret) => text.split(secret).join(REDACTED),
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
)
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
const responseBody = (body: string | void) => {
if (body === undefined) return {}
const redacted = redactBody(body, secrets)
if (redacted.length <= BODY_LIMIT) return { body: redacted }
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
return { body }
}
const decodeProviderBody = Schema.decodeUnknownOption(
@@ -207,52 +135,49 @@ const decodeProviderBody = Schema.decodeUnknownOption(
),
)
const providerMessage = (status: number, body: { readonly body?: string }) => {
if (body.body && body.body.length <= 500) {
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
}
return `Provider request failed with HTTP ${status}`
const providerMessage = (status: number, body: string | void) => {
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
return (
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
`Provider request failed with HTTP ${status}`
)
}
const responseHttp = (input: {
readonly request: HttpClientRequest.HttpClientRequest
readonly response: HttpClientResponse.HttpClientResponse
readonly redactedNames: ReadonlyArray<string | RegExp>
readonly body: ReturnType<typeof responseBody>
readonly requestId?: string | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
}) =>
new HttpContext({
request: requestDetails(input.request, input.redactedNames),
response: responseDetails(input.response, input.redactedNames),
request: requestDetails(input.request),
response: responseDetails(input.response),
...input.body,
requestId: input.requestId,
rateLimit: input.rateLimit,
})
const statusError =
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
(response: HttpClientResponse.HttpClientResponse) =>
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
Effect.gen(function* () {
if (response.status < 400) return response
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
const headers = normalizedHeaders(response.headers)
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(body, secretValues(request))
const details = responseBody(body)
return yield* new AIError({
module: "RequestExecutor",
method: "execute",
reason: classifyProviderFailure({
status: response.status,
message: providerMessage(response.status, details),
message: providerMessage(response.status, body),
retryAfterMs: retryAfter,
rateLimit,
http: responseHttp({
request,
response,
redactedNames,
body: details,
requestId: requestId(headers),
rateLimit,
@@ -262,10 +187,10 @@ const statusError =
})
// Classifies an HTTP failure captured outside the executor (for example by the
// AI SDK's own fetch) onto the same reason types and redacted HttpContext that
// AI SDK's own fetch) onto the same reason types and HttpContext that
// executor-driven requests produce. The originating request is not available on
// that path, so the method is assumed (language model calls are always POST),
// request headers are empty, and only structural body redaction applies.
// request headers are empty.
export const classifyHttpFailure = (input: {
readonly message: string
readonly url: string
@@ -277,7 +202,7 @@ export const classifyHttpFailure = (input: {
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
const retryAfter = retryAfterMs(headers)
const rateLimit = rateLimitDetails(headers, retryAfter)
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
const details = responseBody(input.responseBody)
return classifyProviderFailure({
message: input.message,
status: input.status,
@@ -285,11 +210,11 @@ export const classifyHttpFailure = (input: {
retryAfterMs: retryAfter,
rateLimit,
http: new HttpContext({
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
response:
input.status === undefined
? undefined
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
...details,
requestId: requestId(headers),
rateLimit,
@@ -319,7 +244,6 @@ const httpError = (input: {
readonly error: unknown
readonly request: HttpClientRequest.HttpClientRequest
readonly operation: HttpOperation
readonly redactedNames: ReadonlyArray<string | RegExp>
}) => {
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
@@ -331,8 +255,8 @@ const httpError = (input: {
transport: "http",
operation: input.operation,
code: failure.code,
url: redactUrl(request.url),
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
url: request.url,
http: new HttpContext({ request: requestDetails(request) }),
}),
})
@@ -343,7 +267,7 @@ const httpError = (input: {
const native = nativeTransportFailure(source)
const code = native?.code
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
const detail = raw
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
@@ -369,10 +293,9 @@ export const stream = (
): Stream.Stream<Uint8Array, AIError> =>
Stream.unwrap(
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
const response = yield* executor.execute(request, middleware)
return response.stream.pipe(
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
)
}),
)
@@ -383,19 +306,18 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
const http = yield* HttpClient.HttpClient
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
Effect.gen(function* () {
const redactedNames = yield* Headers.CurrentRedactedNames
if (!middleware)
return yield* http.execute(request).pipe(
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
Effect.flatMap(statusError(request, redactedNames)),
Effect.mapError((error) => httpError({ error, request, operation: "request" })),
Effect.flatMap(statusError(request)),
)
const response = yield* middleware(request, (input) =>
http
.execute(input)
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
return yield* statusError(response.request, redactedNames)(response)
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
return yield* statusError(response.request)(response)
})
return Service.of({
execute: executeOnce,
+19 -2
View File
@@ -16,11 +16,28 @@ export { AuthOptions } from "./auth-options.js"
export { Endpoint } from "./endpoint.js"
export { Framing } from "./framing.js"
export { Protocol } from "./protocol.js"
export { HttpTransport, WebSocketExecutor, WebSocketTransport } from "./transport/index.js"
export { HttpTransport, WebSocketTransport } from "./transport/index.js"
export * as Transport from "./transport/index.js"
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js"
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js"
export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js"
export type { Definition as FramingDef } from "./framing.js"
export type { Protocol as ProtocolDef } from "./protocol.js"
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport/index.js"
export type {
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
HttpHandler,
HttpMiddleware,
Transport as TransportDef,
TransportExecuteOptions,
TransportExecution,
TransportRuntime,
WebSocketConnection,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
WebSocketConnector,
WebSocketRequest,
} from "./transport/index.js"
+4 -2
View File
@@ -87,8 +87,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
middleware: prepareInput.middleware,
}
}),
frames: (prepared, _request, runtime) =>
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
execute: (prepared, _request, runtime) =>
Effect.succeed({
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
}),
})
export const sseJson = {
+30 -5
View File
@@ -1,19 +1,33 @@
import type { Effect, Stream } from "effect"
import type { Effect, Scope, Stream } from "effect"
import { Endpoint } from "../endpoint.js"
import { Auth } from "../auth.js"
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.js"
import type { Interface as WebSocketExecutorInterface } from "./websocket.js"
import type { WebSocketChannelExecutor } from "./websocket-channel.js"
import type { AIError, LLMRequest } from "../../schema/index.js"
export interface TransportRuntime {
readonly http: RequestExecutorInterface
readonly webSocket?: WebSocketExecutorInterface
}
export interface TransportExecution<Frame> {
readonly frames: Stream.Stream<Frame, AIError>
/** Optional successful-consumption acknowledgement. HTTP leaves this absent. */
readonly complete?: Effect.Effect<void>
}
export interface TransportExecuteOptions {
readonly webSocket?: WebSocketChannelExecutor
}
export interface Transport<Body, Prepared, Frame> {
readonly id: string
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
readonly execute: (
prepared: Prepared,
request: LLMRequest,
runtime: TransportRuntime,
options?: TransportExecuteOptions,
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
}
export interface TransportPrepareInput<Body> {
@@ -24,8 +38,19 @@ export interface TransportPrepareInput<Body> {
readonly encodeBody: (body: Body) => string
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
readonly middleware?: HttpMiddleware
readonly webSocket?: WebSocketChannelExecutor
}
export * as HttpTransport from "./http.js"
export type { HttpHandler, HttpMiddleware } from "../executor.js"
export { WebSocketExecutor, WebSocketTransport } from "./websocket.js"
export type {
ChannelCheckpoint,
ChannelCreate,
ChannelObservation,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecution,
WebSocketChannelExecutor,
} from "./websocket-channel.js"
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket.js"
export { WebSocketTransport } from "./websocket.js"
@@ -0,0 +1,50 @@
import type { Effect, Scope, Stream } from "effect"
import type { Headers } from "effect/unstable/http"
import type { AIError } from "../../schema/index.js"
export interface WebSocketChannelExecutor {
readonly execute: (
exchange: WebSocketChannelExchange,
) => Effect.Effect<WebSocketChannelExecution, AIError, Scope.Scope>
}
export interface WebSocketChannelExecution {
readonly frames: Stream.Stream<string, AIError>
/** Commits staged state after the decoded Route stream ends successfully. */
readonly complete: Effect.Effect<void>
}
export interface WebSocketChannelExchange {
readonly id: string
readonly connect: {
readonly url: string
readonly headers: Headers.Headers
/** Provider-safe connection age after which Core should rotate before sending. */
readonly rotateAfterMs?: number
}
readonly fallback: () => Stream.Stream<string, AIError>
readonly driver: WebSocketChannelDriver
}
export interface WebSocketChannelDriver {
readonly create: (checkpoint: ChannelCheckpoint | undefined) => Effect.Effect<ChannelCreate, AIError>
readonly observe: (create: ChannelCreate, frame: string) => Effect.Effect<ChannelObservation, AIError>
}
export interface ChannelCreate {
readonly message: string
readonly mode: "full" | "incremental"
}
export type ChannelObservation =
| { readonly type: "frame"; readonly frame: string }
| { readonly type: "completed"; readonly frame: string; readonly checkpoint?: ChannelCheckpoint }
| { readonly type: "incomplete"; readonly frame: string }
| { readonly type: "provider-failure"; readonly error: AIError }
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "retry-full" }
| { readonly type: "rejected"; readonly error: AIError; readonly recovery: "rotate-and-retry-full" }
export interface ChannelCheckpoint {
readonly protocol: string
readonly value: unknown
}
+216 -53
View File
@@ -1,8 +1,15 @@
import { Cause, Context, Effect, Layer, Queue, Stream } from "effect"
import { Cause, Effect, Queue, Stream } from "effect"
import { Headers } from "effect/unstable/http"
import { Socket } from "effect/unstable/socket"
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
import * as HttpTransport from "./http.js"
import type { Transport } from "./index.js"
import type {
ChannelObservation,
WebSocketChannelDriver,
WebSocketChannelExchange,
WebSocketChannelExecutor,
} from "./websocket-channel.js"
export interface WebSocketRequest {
readonly url: string
@@ -15,24 +22,29 @@ export interface WebSocketConnection {
readonly close: Effect.Effect<void, never>
}
export interface Interface {
export interface WebSocketConnector {
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
}
type WebSocketConstructorWithHeaders = new (
type WebSocketConstructorWithHeaders = (
url: string,
options?: { readonly headers?: Headers.Headers },
) => globalThis.WebSocket
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
const MAX_FRAME_BYTES = 16 * 1024 * 1024
const transportError = (
method: string,
message: string,
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
input: {
readonly operation: TransportOperation
readonly url?: string
readonly code?: string
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
},
) =>
new AIError({
module: "WebSocketExecutor",
module: "WebSocketConnector",
method,
reason: new TransportReason({
message,
@@ -40,9 +52,33 @@ const transportError = (
operation: input.operation,
url: input.url,
code: input.code,
phase: input.phase,
delivery: input.delivery,
}),
})
const annotateTransportError = (
error: AIError,
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
) =>
error.reason._tag === "Transport"
? new AIError({
module: error.module,
method: error.method,
reason: new TransportReason({
message: error.reason.message,
transport: error.reason.transport,
operation: error.reason.operation,
code: error.reason.code,
url: error.reason.url,
http: error.reason.http,
phase: input.phase,
delivery: input.delivery,
recovery: error.reason.recovery,
}),
})
: error
const eventMessage = (event: Event) => {
if ("message" in event && typeof event.message === "string") return event.message
return event.type
@@ -63,6 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
url: input.url,
operation: "request",
code: "closed",
phase: "connect",
delivery: "not-sent",
}),
)
}
@@ -89,6 +127,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
url: input.url,
operation: "request",
phase: "connect",
delivery: "not-sent",
}),
),
)
@@ -101,6 +141,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
url: input.url,
operation: "request",
code: String(event.code),
phase: "connect",
delivery: "not-sent",
}),
),
)
@@ -112,7 +154,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
})
}
const webSocketUrl = (value: string) =>
export const toWebSocketUrl = (value: string) =>
Effect.try({
try: () => {
const url = new URL(value)
@@ -131,21 +173,31 @@ const webSocketUrl = (value: string) =>
url: value,
operation: "request",
code: "invalid-url",
phase: "prepare",
delivery: "not-sent",
}),
})
export const open = (input: WebSocketRequest) =>
Effect.try({
try: () =>
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
operation: "request",
}),
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
Effect.gen(function* () {
const constructor = yield* Socket.WebSocketConstructor
const ws = yield* Effect.try({
try: () =>
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
headers: input.headers,
}),
catch: (error) =>
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
url: input.url,
operation: "request",
phase: "connect",
delivery: "not-sent",
}),
})
return yield* fromWebSocket(ws, input)
})
export const fromWebSocket = (
ws: globalThis.WebSocket,
@@ -155,16 +207,52 @@ export const fromWebSocket = (
yield* waitOpen(ws, input)
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
const oversized = (message: string | Uint8Array) =>
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
const rejectOversized = (message: string | Uint8Array) => {
if (!oversized(message)) return false
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
url: input.url,
operation: "read",
code: "message-too-large",
phase: "receive",
}),
),
)
if (ws.readyState === globalThis.WebSocket.OPEN) ws.close(1009, "Message too large")
return true
}
const offer = (message: string | Uint8Array) => {
if (rejectOversized(message)) return
if (Queue.offerUnsafe(messages, message)) return
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "WebSocket inbound queue overflow", {
url: input.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
}),
),
)
}
const onMessage = (event: MessageEvent) => {
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
if (typeof event.data === "string") return offer(event.data)
const binary = binaryMessage(event.data)
if (binary) return Queue.offerUnsafe(messages, binary)
if (binary) return offer(binary)
Queue.failCauseUnsafe(
messages,
Cause.fail(
transportError("message", "Unsupported WebSocket message payload", {
url: input.url,
operation: "read",
code: "message",
phase: "receive",
}),
),
)
@@ -176,12 +264,13 @@ export const fromWebSocket = (
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
url: input.url,
operation: "read",
code: "message",
phase: "receive",
}),
),
)
}
const onClose = (event: CloseEvent) => {
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
Queue.failCauseUnsafe(
messages,
Cause.fail(
@@ -189,6 +278,7 @@ export const fromWebSocket = (
url: input.url,
operation: "read",
code: String(event.code),
phase: "close",
}),
),
)
@@ -205,13 +295,26 @@ export const fromWebSocket = (
return {
sendText: (message) =>
Effect.try({
try: () => ws.send(message),
catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
operation: "write",
}),
Effect.suspend(() => {
if (ws.readyState !== globalThis.WebSocket.OPEN)
return Effect.fail(
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
url: input.url,
operation: "write",
phase: "send",
delivery: "not-sent",
}),
)
return Effect.try({
try: () => ws.send(message),
catch: (error) =>
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
url: input.url,
operation: "write",
phase: "send",
delivery: "not-sent",
}),
})
}),
messages: Stream.fromQueue(messages),
close: cleanup.pipe(
@@ -228,6 +331,57 @@ export const fromWebSocket = (
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
typeof message === "string" ? message : decoder.decode(message)
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecutor => ({
execute: (exchange) =>
Effect.gen(function* () {
const connection = yield* Effect.acquireRelease(
connector
.open(exchange.connect)
.pipe(Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" }))),
(connection) => connection.close,
)
const create = yield* exchange.driver.create(undefined)
yield* connection.sendText(create.message)
const decoder = new TextDecoder()
let observed = false
return {
frames: connection.messages.pipe(
Stream.map((message) => {
observed = true
return messageText(message, decoder)
}),
Stream.mapError((error) =>
annotateTransportError(error, {
phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery: observed ? "accepted" : "ambiguous",
}),
),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.takeUntil(observationTerminal),
Stream.mapEffect(observationFrame),
),
complete: Effect.void,
}
}),
})
export const direct: Effect.Effect<WebSocketChannelExecutor, never, Socket.WebSocketConstructor> = Effect.gen(
function* () {
const constructor = yield* Socket.WebSocketConstructor
return makeDirect({
open: (input) => open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
})
},
)
export interface JsonPrepared {
readonly url: string
readonly headers: Headers.Headers
@@ -254,33 +408,44 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
...prepareInput,
})
return {
url: yield* webSocketUrl(parts.url),
url: yield* toWebSocketUrl(parts.url),
headers: parts.headers,
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
}
}),
frames: (prepared, _request, runtime) => {
const webSocket = runtime.webSocket
execute: (prepared, request, _runtime, options) => {
const webSocket = options?.webSocket
if (!webSocket) {
return Stream.fail(
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
return Effect.fail(
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
url: prepared.url,
operation: "request",
code: "unavailable",
phase: "prepare",
delivery: "not-sent",
}),
)
}
const decoder = new TextDecoder()
return Stream.unwrap(
Effect.gen(function* () {
const connection = yield* Effect.acquireRelease(
webSocket.open({ url: prepared.url, headers: prepared.headers }),
(connection) => connection.close,
)
yield* connection.sendText(prepared.message)
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
}),
)
const driver: WebSocketChannelDriver = {
create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
}
const exchange: WebSocketChannelExchange = {
id: request.id ?? "request",
connect: { url: prepared.url, headers: prepared.headers },
fallback: () =>
Stream.fail(
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
url: prepared.url,
operation: "request",
code: "websocket",
phase: "fallback",
delivery: "not-sent",
}),
),
driver,
}
return webSocket.execute(exchange)
},
})
@@ -289,15 +454,13 @@ export const jsonTransport = {
with: json,
} as const
export const WebSocketExecutor = {
Service,
layer,
open,
fromWebSocket,
messageText,
} as const
export const WebSocketTransport = {
json,
jsonTransport,
direct,
makeDirect,
open,
fromWebSocket,
messageText,
toWebSocketUrl,
} as const
+7
View File
@@ -106,6 +106,13 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
code: Schema.optional(Schema.String),
url: Schema.optional(Schema.String),
http: Schema.optional(HttpContext),
phase: Schema.optional(
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
),
delivery: Schema.optional(Schema.Literals(["not-sent", "rejected", "ambiguous", "accepted"])),
recovery: Schema.optional(
Schema.Literals(["retry-connect", "retry-full", "rotate-and-retry-full", "fallback-http", "fail"]),
),
}) {}
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
+209 -30
View File
@@ -1,12 +1,13 @@
import { describe, expect } from "bun:test"
import { Effect, Layer, Ref, Stream } from "effect"
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLM, AIError } from "../src/index.js"
import { LLMClient, RequestExecutor } from "../src/route.js"
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
import * as OpenAIChat from "../src/protocols/openai-chat.js"
import { dynamicResponse, systemError } from "./lib/http.js"
import * as OpenAI from "../src/providers/openai.js"
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
import { deltaChunk } from "./lib/openai-chunks.js"
import { sseRaw } from "./lib/sse.js"
import { sseEvents, sseRaw } from "./lib/sse.js"
import { it } from "./lib/effect.js"
const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
@@ -65,6 +66,7 @@ const expectAIError = (error: unknown) => {
}
const errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
describe("RequestExecutor", () => {
it.effect("parses response body failures at the executor seam", () =>
@@ -75,11 +77,11 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: disconnected <redacted> <redacted>",
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
transport: "http",
operation: "read",
code: "ECONNRESET",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
url: "https://provider.test/v1/chat?api_key=query-secret-123&debug=1",
})
}).pipe(
Effect.provide(
@@ -152,12 +154,12 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "Transport",
message: "ECONNRESET: proxy disconnected <redacted>",
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
message: "ECONNRESET: proxy disconnected proxy-secret",
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
http: {
request: {
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
headers: { authorization: "<redacted>" },
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
headers: { authorization: "Bearer proxy-secret" },
},
},
})
@@ -217,9 +219,47 @@ describe("RequestExecutor", () => {
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
)
it.effect("preserves structured provider messages from large error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
}).pipe(
Effect.provide(
responsesLayer([
new Response(
JSON.stringify({
model: "gpt-5.6-sol",
error: { type: "invalid_request", message: largeProviderMessage },
}),
{ status: 400 },
),
]),
),
),
)
it.effect("falls back when structured provider messages are empty", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({
_tag: "InvalidRequest",
message: "Provider request failed with HTTP 400",
})
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
)
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
Effect.gen(function* () {
const classify = (body: string) =>
@@ -253,7 +293,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("returns redacted diagnostics for rate limits", () =>
it.effect("returns complete diagnostics for rate limits", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
@@ -268,15 +308,15 @@ describe("RequestExecutor", () => {
requestId: "req_123",
request: {
method: "POST",
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
headers: { authorization: "<redacted>", "x-safe": "visible" },
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
headers: { authorization: "Bearer secret", "x-safe": "visible" },
},
response: {
status: 429,
headers: {
"retry-after-ms": "0",
"x-request-id": "req_123",
"x-api-key": "<redacted>",
"x-api-key": "secret",
},
},
},
@@ -295,14 +335,14 @@ describe("RequestExecutor", () => {
),
)
it.effect("honors current redacted header names in diagnostics", () =>
it.effect("preserves configured header names in diagnostics", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
}).pipe(
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
@@ -421,15 +461,15 @@ describe("RequestExecutor", () => {
}),
)
it.effect("truncates large authentication error bodies", () =>
it.effect("preserves large authentication error bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "Authentication" })
expect(errorHttp(error)?.bodyTruncated).toBe(true)
expect(errorHttp(error)?.body).toHaveLength(16_384)
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
expect(errorHttp(error)?.body).toHaveLength(20_000)
}).pipe(
Effect.provide(
responsesLayer([
@@ -440,16 +480,15 @@ describe("RequestExecutor", () => {
),
)
it.effect("redacts common secret fields in response bodies", () =>
it.effect("preserves response body fields", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
expect(errorHttp(error)?.body).not.toContain("body-secret")
expect(errorHttp(error)?.body).not.toContain("query-secret")
expect(errorHttp(error)?.body).toBe(
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
)
}).pipe(
Effect.provide(
responsesLayer([
@@ -461,16 +500,13 @@ describe("RequestExecutor", () => {
),
)
it.effect("redacts echoed request secret values in response bodies", () =>
it.effect("preserves echoed request values in response bodies", () =>
Effect.gen(function* () {
const executor = yield* RequestExecutor.Service
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
expectAIError(error)
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
expect(errorHttp(error)?.body).toContain("authorization <redacted>")
expect(errorHttp(error)?.body).not.toContain("query-secret-123")
expect(errorHttp(error)?.body).not.toContain("header-secret-456")
expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
}).pipe(
Effect.provide(
responsesLayer([
@@ -511,3 +547,146 @@ describe("RequestExecutor", () => {
}),
)
})
describe("WebSocket channel execution", () => {
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini")
const request = LLM.request({ model, prompt: "Say hello." })
const frames = [
JSON.stringify({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
JSON.stringify({ type: "response.completed", response: { id: "resp_1" } }),
]
it.effect("runs a channel driver through the direct executor", () =>
Effect.gen(function* () {
const sent = yield* Ref.make("")
const closed = yield* Ref.make(false)
const observed = yield* Ref.make(0)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: (message) => Ref.set(sent, message),
messages: Stream.make("one", "done", "late"),
close: Ref.set(closed, true),
}),
})
const received = yield* Effect.scoped(
Effect.gen(function* () {
const execution = yield* webSocket.execute({
id: "exchange_1",
connect: { url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
fallback: () => Stream.empty,
driver: {
create: () => Effect.succeed({ message: "create", mode: "full" }),
observe: (_create, frame) =>
Ref.update(observed, (value) => value + 1).pipe(
Effect.as(
frame === "done" ? { type: "completed" as const, frame } : { type: "frame" as const, frame },
),
),
},
})
return yield* Stream.runCollect(execution.frames)
}),
)
expect(Array.from(received)).toEqual(["one", "done"])
expect(yield* Ref.get(sent)).toBe("create")
expect(yield* Ref.get(observed)).toBe(2)
expect(yield* Ref.get(closed)).toBe(true)
}),
)
it.effect("rejects a closed socket before attempting to send", () =>
Effect.gen(function* () {
class ClosedBeforeSend extends EventTarget {
readyState = globalThis.WebSocket.OPEN
sends = 0
send() {
this.sends++
}
close() {}
}
const socket = new ClosedBeforeSend()
const connection = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
socket as unknown as globalThis.WebSocket,
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
)
socket.readyState = globalThis.WebSocket.CLOSED
const error = yield* connection.sendText("create").pipe(Effect.flip)
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "send", delivery: "not-sent" })
expect(socket.sends).toBe(0)
yield* connection.close
}),
)
it.effect("uses HTTP when no per-call WebSocket executor is provided", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(sseEvents(...frames))))
expect(response.text).toBe("Hi")
}),
)
it.effect("commits channel execution only after complete consumption", () =>
Effect.gen(function* () {
const commits = yield* Ref.make(0)
const executor = (input: Stream.Stream<string, AIError>): WebSocketChannelExecutor => ({
execute: () =>
Effect.succeed({
frames: input,
complete: Ref.update(commits, (value) => value + 1),
}),
})
const response = yield* LLMClient.generate(request, {
webSocket: executor(Stream.fromArray(frames)),
}).pipe(Effect.provide(fixedResponse("")))
expect(response.text).toBe("Hi")
expect(yield* Ref.get(commits)).toBe(1)
yield* LLMClient.generate(request, { webSocket: executor(Stream.make("not-json")) }).pipe(
Effect.provide(fixedResponse("")),
Effect.flip,
)
expect(yield* Ref.get(commits)).toBe(1)
yield* LLMClient.stream(request, { webSocket: executor(Stream.fromArray(frames)) }).pipe(
Stream.take(1),
Stream.runDrain,
Effect.provide(fixedResponse("")),
)
expect(yield* Ref.get(commits)).toBe(1)
}),
)
it.effect("does not commit interrupted channel execution", () =>
Effect.gen(function* () {
const commits = yield* Ref.make(0)
const started = yield* Deferred.make<void>()
const executor: WebSocketChannelExecutor = {
execute: () =>
Effect.succeed({
frames: Stream.fromEffect(
Deferred.succeed(started, undefined).pipe(
Effect.as(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })),
),
).pipe(Stream.concat(Stream.never)),
complete: Ref.update(commits, (value) => value + 1),
}),
}
const fiber = yield* LLMClient.stream(request, { webSocket: executor }).pipe(
Stream.runDrain,
Effect.provide(fixedResponse("")),
Effect.forkChild({ startImmediately: true }),
)
yield* Deferred.await(started)
yield* Fiber.interrupt(fiber)
expect(yield* Ref.get(commits)).toBe(0)
}),
)
})
+4 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
import { Route, Protocol } from "@opencode-ai/ai/route"
import { Route, Protocol, WebSocketTransport } from "@opencode-ai/ai/route"
import { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
import {
CloudflareAIGateway,
@@ -16,6 +16,7 @@ import {
OpenAICompatibleResponses,
OpenAIResponses,
OpenResponses,
OpenResponsesChannel,
} from "@opencode-ai/ai/protocols"
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
import { TestLLM } from "@opencode-ai/ai/testing"
@@ -36,6 +37,7 @@ describe("public exports", () => {
test("route barrel exposes route-authoring APIs", () => {
expect(Route.make).toBeFunction()
expect(Protocol.make).toBeFunction()
expect(WebSocketTransport.makeDirect).toBeFunction()
})
test("provider barrels expose user-facing facades", async () => {
@@ -43,7 +45,6 @@ describe("public exports", () => {
expect(OpenAI.model).toBeFunction()
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
expect(OpenAICompatible.deepseek.model).toBeFunction()
expect(
@@ -65,10 +66,10 @@ describe("public exports", () => {
expect(OpenAIChat.route.id).toBe("openai-chat")
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
expect(OpenResponses.protocol.id).toBe("open-responses")
expect(OpenResponsesChannel.transport).toBeFunction()
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
expect(OpenAIResponses.route.id).toBe("openai-responses")
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
})
})
+4 -6
View File
@@ -1,9 +1,8 @@
import { Effect, Layer, Ref } from "effect"
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
import { LLMClient, RequestExecutor } from "../../src/route.js"
import type { Service as LLMClientService } from "../../src/route/client.js"
import type { Service as RequestExecutorService } from "../../src/route/executor.js"
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket.js"
export type HandlerInput = {
readonly request: HttpClientRequest.HttpClientRequest
@@ -34,7 +33,7 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
),
)
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
export type RuntimeEnv = RequestExecutorService | LLMClientService
export interface SystemError extends Error {
readonly code: string
@@ -44,9 +43,8 @@ export const systemError = (code: string, message: string): SystemError => Objec
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
return Layer.mergeAll(deps, llmClientLayer)
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
}
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
+2 -2
View File
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
test("classifies V1 overloaded provider codes", () => {
expect(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
(message) => classifyProviderFailure({ message })._tag,
),
).toEqual(["ProviderInternal", "ProviderInternal"])
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
})
test("classifies transient client statuses as provider internal", () => {
@@ -1,13 +1,18 @@
import { LLM } from "../../src/index.js"
import { OpenAI } from "../../src/providers.js"
const model = OpenAI.responses("gpt-5")
const selected = OpenAI.responses("gpt-5")
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({ model: selected, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
LLM.request({
model,
model: selected,
prompt: "Hello",
// @ts-expect-error OpenAI reasoning effort must be a string.
providerOptions: { openai: { reasoningEffort: 1 } },
})
OpenAI.configure({
// @ts-expect-error Transport is execution policy, not provider configuration.
transport: "websocket",
})
@@ -80,11 +80,6 @@ describe("provider package entrypoints", () => {
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
})
test("selects transport without changing the semantic API", () => {
expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
})
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
const selected = OpenAICompatibleResponses.model("custom-model", {
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
expect(error.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
}),
)
})
@@ -1098,8 +1098,7 @@ describe("Anthropic Messages route", () => {
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
}),
)
@@ -1275,8 +1275,7 @@ describe("OpenAI Chat route", () => {
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
}),
)
@@ -1,9 +1,10 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
AIError,
HttpOptions,
LLMEvent,
LLMRequest,
Message,
@@ -11,14 +12,23 @@ import {
ToolCallPart,
ToolDefinition,
ToolResultPart,
TransportReason,
Usage,
} from "../../src/index.js"
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
import {
Auth,
LLMClient,
RequestExecutor,
WebSocketTransport,
type ChannelObservation,
type WebSocketChannelDriver,
} from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import * as Azure from "../../src/providers/azure.js"
import * as OpenAI from "../../src/providers/openai.js"
import * as XAI from "../../src/providers/xai.js"
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
import * as ProviderShared from "../../src/protocols/shared.js"
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
import { it } from "../lib/effect.js"
@@ -31,6 +41,47 @@ const model = OpenAIResponses.route
const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
const baseChannelDriver = (message: string): WebSocketChannelDriver => ({
create: () => Effect.succeed({ message, mode: "full" }),
observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> => {
const event = ProviderShared.decodeJson(frame)
if (!ProviderShared.isRecord(event)) return Effect.die("Expected event")
if (event.type === "response.completed") return Effect.succeed({ type: "completed", frame })
if (event.type === "response.incomplete") return Effect.succeed({ type: "incomplete", frame })
if (event.type === "error" || event.type === "response.failed")
return Effect.succeed({
type: "provider-failure",
error: new AIError({
module: "test",
method: "stream",
reason: new TransportReason({
message: "provider rejected request",
transport: "websocket",
operation: "read",
phase: "receive",
}),
}),
})
return Effect.succeed({ type: "frame", frame })
},
})
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
const message = ProviderShared.encodeJson(request)
return OpenAIResponsesChannel.driver({
id: "openai-responses",
name: "OpenAI Responses",
request,
message,
base: baseChannelDriver(message),
})
}
const checkpoint = (observation: ChannelObservation) => {
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected checkpoint")
return observation.checkpoint
}
const request = LLM.request({
id: "req_1",
model,
@@ -216,19 +267,19 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("prepares OpenAI Responses WebSocket target", () =>
it.effect("prepares one OpenAI Responses route for either transport", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLMRequest.update(request, {
model: OpenAIResponses.webSocketRoute
model: OpenAIResponses.route
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" }),
}),
)
expect(prepared.route).toBe("openai-responses-websocket")
expect(prepared.route).toBe("openai-responses")
expect(prepared.protocol).toBe("openai-responses")
expect(prepared.metadata).toEqual({ transport: "websocket-json" })
expect(prepared.metadata).toEqual({ transport: "http-json" })
expect(prepared.body).toMatchObject({ model: "gpt-4.1-mini", store: false, stream: true })
}),
)
@@ -236,47 +287,60 @@ describe("OpenAI Responses route", () => {
it.effect("streams OpenAI Responses over WebSocket", () =>
Effect.gen(function* () {
const sent: string[] = []
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
const opened: Array<{
readonly url: string
readonly authorization: string | undefined
readonly protocol: string | undefined
}> = []
let closed = false
const deps = Layer.mergeAll(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({
execute: () => Effect.die("unexpected HTTP request"),
}),
),
Layer.succeed(
WebSocketExecutor.Service,
WebSocketExecutor.Service.of({
open: (input) =>
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
opened.push({ url: input.url, authorization: input.headers.authorization })
sent.push(message)
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
close: Effect.sync(() => {
closed = true
}),
}),
}),
),
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({
execute: () => Effect.die("unexpected HTTP request"),
}),
)
const webSocket = WebSocketTransport.makeDirect({
open: (input) =>
Effect.succeed({
sendText: (message) =>
Effect.sync(() => {
opened.push({
url: input.url,
authorization: input.headers.authorization,
protocol: input.headers["openai-beta"],
})
sent.push(message)
}),
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
close: Effect.sync(() => {
closed = true
}),
}),
})
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
"gpt-4.1-mini",
),
model: OpenAI.configure({
baseURL: "https://api.openai.test/v1/",
apiKey: "test",
headers: { "openai-beta": "custom-protocol" },
}).responses("gpt-4.1-mini"),
prompt: "Say hello.",
}),
{ webSocket },
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
expect(response.text).toBe("Hi")
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
expect(opened).toEqual([
{
url: "wss://api.openai.test/v1/responses",
authorization: "Bearer test",
protocol: "custom-protocol",
},
])
expect(closed).toBe(true)
expect(sent).toHaveLength(1)
expect(JSON.parse(sent[0])).toEqual({
@@ -288,15 +352,524 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("rejects out-of-order and mismatched WebSocket response events", () =>
Effect.gen(function* () {
const streams = [
Stream.fromArray([
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "late", delta: "Late" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
]),
Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_new" } }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_old" } }),
]),
]
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
close: Effect.void,
}),
})
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const errors = yield* Effect.forEach(["late", "mismatch"], (prompt) =>
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
Effect.flip,
),
)
expect(errors.map((error) => error.reason._tag)).toEqual(["InvalidProviderOutput", "InvalidProviderOutput"])
expect(errors[0]?.message).toContain("before response.created")
expect(errors[1]?.message).toContain("response ID changed")
}),
)
it.effect("continues a tool call with only the new tool output", () =>
Effect.gen(function* () {
const firstRequest = {
type: "response.create",
model: "gpt-5.2",
store: false,
input: [{ role: "user", content: [{ type: "input_text", text: "Weather?" }] }],
}
const first = continuationDriver(firstRequest)
const firstCreate = yield* first.create(undefined)
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_1",
status: "completed",
call_id: "call_1",
name: "weather",
arguments: '{ "city": "Paris" }',
},
}),
)
const saved = checkpoint(
yield* first.observe(
firstCreate,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const second = continuationDriver({
...firstRequest,
input: [
...firstRequest.input,
{ type: "function_call", call_id: "call_1", name: "weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' },
],
})
const create = yield* second.create(saved)
expect(create.mode).toBe("incremental")
expect(ProviderShared.decodeJson(create.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ type: "function_call_output", call_id: "call_1", output: '{"temperature":22}' }],
})
}),
)
it.effect("continues a promoted steer after the completed assistant output", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "First" }] }]
const first = continuationDriver({ type: "response.create", model: "gpt-5.2", store: false, input: firstInput })
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "message",
id: "msg_1",
status: "completed",
role: "assistant",
content: [{ type: "output_text", text: "Hello" }],
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const steer = { role: "user", content: [{ type: "input_text", text: "Actually, be brief" }] }
const next = continuationDriver({
type: "response.create",
model: "gpt-5.2",
store: false,
input: [...firstInput, { role: "assistant", content: [{ type: "output_text", text: "Hello" }] }, steer],
})
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [steer],
})
}),
)
it.effect("continues store-false reasoning without replaying the output-only item ID", () =>
Effect.gen(function* () {
const firstInput = [{ role: "user", content: [{ type: "input_text", text: "Think" }] }]
const request = { type: "response.create", model: "gpt-5.2", store: false, input: firstInput }
const first = continuationDriver(request)
const create = yield* first.create(undefined)
yield* first.observe(
create,
ProviderShared.encodeJson({
type: "response.output_item.done",
item: {
type: "reasoning",
id: "rs_1",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
}),
)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const next = continuationDriver({
...request,
input: [
...firstInput,
{
type: "reasoning",
summary: [{ type: "summary_text", text: "Thought" }],
encrypted_content: "encrypted",
},
{ role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
})
const continued = yield* next.create(saved)
expect(continued.mode).toBe("incremental")
expect(ProviderShared.decodeJson(continued.message)).toMatchObject({
previous_response_id: "resp_1",
input: [{ role: "user", content: [{ type: "input_text", text: "Continue" }] }],
})
}),
)
it.effect("uses a full request when any non-input invariant changes", () =>
Effect.gen(function* () {
const request = {
type: "response.create",
model: "gpt-5.2",
store: false,
metadata: { source: "one" },
input: [{ role: "user", content: [{ type: "input_text", text: "First" }] }],
}
const first = continuationDriver(request)
const create = yield* first.create(undefined)
const saved = checkpoint(
yield* first.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const appended = [...request.input, { role: "user", content: [{ type: "input_text", text: "Second" }] }]
const changes = [
{ ...request, model: "gpt-5.3", input: appended },
{ ...request, instructions: "Changed", input: appended },
{ ...request, tools: [{ type: "function", name: "other" }], input: appended },
{ ...request, temperature: 0.5, input: appended },
{ ...request, metadata: { source: "two" }, input: appended },
{
...request,
input: [{ role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
},
]
const creates = yield* Effect.forEach(changes, (changed) => continuationDriver(changed).create(saved))
expect(creates.map((item) => item.mode)).toEqual(changes.map(() => "full"))
expect(
creates
.map((item) => ProviderShared.decodeJson(item.message))
.every((item) => ProviderShared.isRecord(item) && !("previous_response_id" in item)),
).toBe(true)
}),
)
it.effect("stages no checkpoint for incomplete or ID-less completion", () =>
Effect.gen(function* () {
const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
const create = yield* driver.create(undefined)
const completed = yield* driver.observe(
create,
ProviderShared.encodeJson({ type: "response.completed", response: {} }),
)
expect(completed).toMatchObject({ type: "completed" })
expect(completed).not.toHaveProperty("checkpoint")
expect(
yield* driver.observe(create, ProviderShared.encodeJson({ type: "response.incomplete", response: {} })),
).toMatchObject({ type: "incomplete" })
}),
)
it.effect("classifies explicit continuation rejection for runner-owned recovery", () =>
Effect.gen(function* () {
const driver = continuationDriver({ type: "response.create", model: "gpt-5.2", input: [] })
const create = yield* driver.create(undefined)
const missing = yield* driver.observe(
create,
ProviderShared.encodeJson({
type: "error",
error: { code: "previous_response_not_found", message: "Missing response" },
}),
)
const limit = yield* driver.observe(
create,
ProviderShared.encodeJson({
type: "error",
error: { code: "websocket_connection_limit_reached", message: "Rotate" },
}),
)
expect(missing).toMatchObject({
type: "rejected",
recovery: "retry-full",
error: { reason: { _tag: "Transport", delivery: "rejected", recovery: "retry-full" } },
})
expect(limit).toMatchObject({
type: "rejected",
recovery: "rotate-and-retry-full",
error: {
reason: { _tag: "Transport", delivery: "rejected", recovery: "rotate-and-retry-full" },
},
})
}),
)
it.effect("builds WebSocket and HTTP fallback from the same final request", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
const message = yield* Ref.make("")
const body = yield* Ref.make("")
const response = yield* LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say hello.",
http: {
body: {
model: "overlaid-model",
metadata: { source: "overlay" },
stream_options: { include_usage: true },
background: true,
},
headers: { "x-request": "request" },
query: { mode: "test" },
},
}),
{
webSocket: {
execute: (exchange) =>
Effect.gen(function* () {
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers["openai-beta"]).toBe("responses_websockets=2026-02-06")
expect(exchange.connect.headers["content-length"]).toBeUndefined()
yield* exchange.driver
.create(undefined)
.pipe(Effect.flatMap((create) => Ref.set(message, create.message)))
return { frames: exchange.fallback(), complete: Effect.void }
}),
},
},
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
yield* Ref.set(body, input.text)
expect(input.request.url).toBe("https://api.openai.test/v1/responses?mode=test")
expect(input.request.headers.authorization).toBe("Bearer test")
expect(input.request.headers["x-request"]).toBe("request")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
const httpBody = JSON.parse(yield* Ref.get(body))
const { stream: _stream, stream_options: _streamOptions, background: _background, ...shared } = httpBody
expect(response.finishReason?.normalized).toBe("stop")
expect(yield* Ref.get(attempts)).toBe(1)
expect(JSON.parse(yield* Ref.get(message))).toEqual({ type: "response.create", ...shared })
expect(httpBody).toMatchObject({
model: "overlaid-model",
metadata: { source: "overlay" },
stream: true,
stream_options: { include_usage: true },
background: true,
})
}),
)
it.effect("uses exactly one HTTP request when no WebSocket executor is supplied", () =>
Effect.gen(function* () {
const attempts = yield* Ref.make(0)
yield* LLMClient.generate(
LLMRequest.update(request, { http: new HttpOptions({ body: { input: "raw-http-input" } }) }),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
yield* Ref.update(attempts, (value) => value + 1)
expect(JSON.parse(input.text).input).toBe("raw-http-input")
return input.respond(sseEvents({ type: "response.completed", response: {} }), {
headers: { "content-type": "text/event-stream" },
})
}),
),
),
)
expect(yield* Ref.get(attempts)).toBe(1)
}),
)
it.effect("closes a direct WebSocket execution after partial consumption", () =>
Effect.gen(function* () {
const closed = yield* Ref.make(false)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.fromArray([
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
]),
close: Ref.set(closed, true),
}),
})
yield* LLMClient.stream(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-4.1-mini"),
prompt: "Say hello.",
}),
{ webSocket },
).pipe(
Stream.take(1),
Stream.runDrain,
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
)
expect(yield* Ref.get(closed)).toBe(true)
}),
)
it.effect("terminates WebSocket control events without waiting for the socket to close", () =>
Effect.gen(function* () {
const events = [
{ type: "error", error: { code: "slow_down", message: "Try later" } },
{
type: "error",
status_code: 429,
message: "Rate limited",
headers: { "retry-after": 1, "x-request-id": "request", cached: false, invalid: [] },
},
{
type: "response.failed",
response: { error: { code: "server_error", message: "Unavailable" } },
},
{ type: "error", status: "not-a-status", message: "Malformed status" },
]
const errors = yield* Effect.forEach(events, (event) =>
LLMClient.generate(
LLM.request({
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
),
prompt: "Say hello.",
}),
{
webSocket: WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: Stream.make(ProviderShared.encodeJson(event)).pipe(Stream.concat(Stream.never)),
close: Effect.void,
}),
}),
},
).pipe(
Effect.provide(
LLMClient.layer.pipe(
Layer.provide(
Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
),
),
),
),
Effect.flip,
),
)
expect(errors.map((error) => error.reason._tag)).toEqual([
"ProviderInternal",
"RateLimit",
"ProviderInternal",
"UnknownProvider",
])
}),
)
it.effect("marks post-send WebSocket failures with delivery state", () =>
Effect.gen(function* () {
const failure = new AIError({
module: "test",
method: "receive",
reason: new TransportReason({
message: "socket closed",
transport: "websocket",
operation: "read",
phase: "close",
}),
})
const streams = [
Stream.fail(failure),
Stream.make(ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_observed" } })).pipe(
Stream.concat(Stream.fail(failure)),
),
]
const deps = Layer.succeed(
RequestExecutor.Service,
RequestExecutor.Service.of({ execute: () => Effect.die("unexpected HTTP request") }),
)
const webSocket = WebSocketTransport.makeDirect({
open: () =>
Effect.succeed({
sendText: () => Effect.void,
messages: streams.shift() ?? Stream.die("unexpected WebSocket open"),
close: Effect.void,
}),
})
const model = OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses(
"gpt-4.1-mini",
)
const errors = yield* Effect.forEach(["first", "second"], (prompt) =>
LLMClient.generate(LLM.request({ model, prompt }), { webSocket }).pipe(
Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))),
Effect.flip,
),
)
expect(errors.map((error) => error.reason)).toEqual([
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "ambiguous" }),
expect.objectContaining({ _tag: "Transport", phase: "close", delivery: "accepted" }),
])
}),
)
it.effect("fails immediately when WebSocket is already closed", () =>
Effect.gen(function* () {
const error = yield* WebSocketExecutor.fromWebSocket(
const error = yield* WebSocketTransport.fromWebSocket(
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- fromWebSocket reads readyState before touching WebSocket methods on this branch.
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
).pipe(Effect.flip)
expect(error.message).toContain("closed before opening")
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
}),
)
@@ -329,7 +902,7 @@ describe("OpenAI Responses route", () => {
yield* LLMClient.generate(
LLMRequest.update(request, {
model: Azure.configure({
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
baseURL: "https://opencode-test.openai.azure.com/openai/",
apiKey: "azure-key",
headers: { authorization: "Bearer stale" },
}).responses("gpt-4.1-mini"),
@@ -2037,8 +2610,7 @@ describe("OpenAI Responses route", () => {
)
expect(error).toBeInstanceOf(AIError)
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
expect(error.message).toContain("HTTP 400")
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
}),
)
})
+5 -7
View File
@@ -2,12 +2,11 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
import { Layer } from "effect"
import * as path from "node:path"
import { fileURLToPath } from "node:url"
import { LLMClient, RequestExecutor, WebSocketExecutor } from "../src/route.js"
import { LLMClient, RequestExecutor } from "../src/route.js"
import { ImageClient } from "../src/image-client.js"
import type { Service as ImageClientService } from "../src/image-client.js"
import type { Service as LLMClientService } from "../src/route/client.js"
import type { Service as RequestExecutorService } from "../src/route/executor.js"
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket.js"
import {
recordedEffectGroup,
type RecordedCaseOptions as RunnerCaseOptions,
@@ -17,7 +16,7 @@ import {
const __dirname = path.dirname(fileURLToPath(import.meta.url))
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
type RecordedTestsOptions = RecordedGroupOptions & {
readonly options?: HttpRecorder.RecorderOptions
@@ -82,11 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
}),
),
)
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
return Layer.mergeAll(
deps,
LLMClient.layer.pipe(Layer.provide(deps)),
ImageClient.layer.pipe(Layer.provide(deps)),
requestExecutor,
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
)
},
})
+23
View File
@@ -11,6 +11,7 @@ import {
LanguageModel,
ModelID,
ProviderID,
TransportReason,
Usage,
} from "../src/schema/index.js"
import { ProviderShared } from "../src/protocols/shared.js"
@@ -108,3 +109,25 @@ test("AI errors expose the shared runtime tag", async () => {
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
).toBe("caught")
})
test("transport errors serialize execution facts", () => {
const reason = new TransportReason({
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "ambiguous",
recovery: "fail",
})
expect(Schema.encodeSync(TransportReason)(reason)).toEqual({
_tag: "Transport",
message: "connection closed",
transport: "websocket",
operation: "read",
phase: "receive",
delivery: "ambiguous",
recovery: "fail",
})
expect(Schema.decodeUnknownSync(TransportReason)(Schema.encodeSync(TransportReason)(reason))).toEqual(reason)
})
+2 -2
View File
@@ -278,7 +278,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}
if (path === "/api/project/current")
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
const worktree = path.match(/^\/api\/experimental\/project\/([^/]+)\/worktree$/)?.[1]
const worktree = path.match(/^\/api\/worktree\/([^/]+)$/)?.[1]
if (worktree && route.request().method() === "GET")
return json(route, [
{ directory: config.directory },
@@ -294,7 +294,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
}
if (worktree && route.request().method() === "DELETE")
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (/^\/api\/experimental\/project\/[^/]+\/worktree\/refresh$/.test(path))
if (/^\/api\/worktree\/[^/]+\/refresh$/.test(path))
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
if (path === "/api/permission/request")
return json(route, {
@@ -2,12 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
import { Worktree } from "@opencode-ai/schema/worktree"
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
import type { Message, Part, Project, Todo } from "@/types"
import type {
FileDiffInfo,
PermissionRequest,
SessionInfo,
SessionStatus,
} from "@opencode-ai/client/promise"
import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
import type { State, VcsCache } from "./types"
import { trimSessions } from "./session-trim"
import { dropSessionCaches } from "./session-cache"
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
import {
normalizeNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./new-session-workspace-controller"
@@ -47,4 +48,10 @@ describe("new session workspace selection", () => {
)
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
})
test("uses location VCS state when the project inventory is stale", () => {
expect(resolveNewSessionGit({ branch: "dev" })).toBe(true)
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
expect(resolveNewSessionGit({})).toBe(false)
})
})
@@ -39,6 +39,10 @@ export function resolveNewSessionBranch(input: {
return input.worktreeBranch(input.worktree) ?? input.local
}
export function resolveNewSessionGit(input: { projectVcs?: string; branch?: string }) {
return input.projectVcs === "git" || input.branch !== undefined
}
export function createNewSessionWorkspaceController(input: {
selected: () => string | undefined
setSelected: (worktree: string | undefined) => void
@@ -49,7 +53,10 @@ export function createNewSessionWorkspaceController(input: {
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const settings = useSettings()
const visible = createMemo(() => sync().project?.vcs === "git")
const localVcs = createMemo(() => serverSync.child(sdk().directory)[0].vcs)
const visible = createMemo(() =>
resolveNewSessionGit({ projectVcs: sync().project?.vcs, branch: localVcs()?.branch }),
)
const selected = createMemo(() => {
const project = sync().project
const worktree = input.selected()
@@ -110,7 +117,7 @@ export function createNewSessionWorkspaceController(input: {
const project = sync().project
return project ? workspaceDirectories(project) : []
},
git: () => sync().project?.vcs === "git",
git: visible,
openAll: input.onViewAll,
},
bar: {
+4 -15
View File
@@ -1,7 +1,7 @@
import { $ } from "bun"
import { readdir } from "node:fs/promises"
import path from "node:path"
import { brotliCompressSync, constants } from "node:zlib"
import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return compress({})
@@ -9,8 +9,10 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
const assets = Object.fromEntries(
await Promise.all(
(await files(path.join(root, "dist")))
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
@@ -31,16 +33,3 @@ function compress(assets: object) {
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
}),
)
)
.flat()
.toSorted()
}
+3
View File
@@ -12,6 +12,7 @@ import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "
import { mainConfig } from "../vite.node.config"
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact } from "./verify-artifact"
const NODE_VERSION = "26.4.0"
const dir = path.resolve(import.meta.dirname, "..")
@@ -91,6 +92,7 @@ for (const target of targets) {
await copyNodeAssets(assets)
await build(mainConfig(input))
await assertTextImportsInlined("dist-node/opencode.mjs")
if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs")
const host = target.platform === process.platform && target.arch === process.arch
if (host) {
@@ -139,6 +141,7 @@ for (const target of targets) {
2,
)}\n`,
)
await verifyArtifact(path.join(outdir, name))
if (host) await smoke(output)
}
+14 -1
View File
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
import type { BunPlugin } from "bun"
import pkg from "../package.json"
import { buildAppArchive } from "./app-assets"
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
const dir = path.resolve(import.meta.dirname, "..")
const binary = "opencode2"
@@ -76,6 +77,16 @@ const appAssetsPlugin: BunPlugin = {
}
for (const item of targets) {
const simulationInputs = new Set<string>()
const simulationGraphPlugin: BunPlugin = {
name: "opencode-simulation-graph",
setup(build) {
build.onLoad(
{ filter: /packages[/\\]simulation[/\\]src[/\\](frontend[/\\](simulation|server)|control-server)\.ts$/ },
(args) => void simulationInputs.add(args.path),
)
},
}
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
const parcelWatcherPlugin: BunPlugin = {
name: "parcel-watcher-binding",
@@ -92,7 +103,7 @@ for (const item of targets) {
const result = await Bun.build({
entrypoints: ["./src/index.ts"],
tsconfig: "./tsconfig.json",
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
external: ["node-gyp"],
format: "esm",
minify: true,
@@ -123,6 +134,7 @@ for (const item of targets) {
for (const log of result.logs) console.error(log)
process.exit(1)
}
verifySimulationGraph(simulationInputs)
await Bun.write(
path.join(outdir, name, "package.json"),
@@ -139,6 +151,7 @@ for (const item of targets) {
2,
),
)
await verifyArtifact(path.join(outdir, name))
}
function targetName(item: (typeof allTargets)[number]) {
+13
View File
@@ -0,0 +1,13 @@
import { readdir } from "node:fs/promises"
import path from "node:path"
export async function collectFiles(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map(async (entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? collectFiles(root, target) : [path.relative(root, target)]
}),
)
).flat()
}
+6 -14
View File
@@ -1,9 +1,10 @@
import { createHash } from "node:crypto"
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
import { copyFile, mkdir, readFile, stat } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { getNodeAssets } from "@opentui/core/node-assets"
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
import { collectFiles } from "./files"
const dir = path.resolve(import.meta.dirname, "..")
@@ -16,17 +17,6 @@ export type NodeAsset = {
readonly source: string
}
async function files(root: string, current = root): Promise<string[]> {
return (
await Promise.all(
(await readdir(current, { withFileTypes: true })).map((entry) => {
const target = path.join(current, entry.name)
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
}),
)
).flat()
}
export async function collectNodeAssets(target: NodeTarget) {
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
@@ -51,7 +41,7 @@ export async function collectNodeAssets(target: NodeTarget) {
key,
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
})),
...(await files(ptyRoot))
...(await collectFiles(ptyRoot))
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
.map((relative) => ({
key: `${target.nodePtyPackage}/${relative}`,
@@ -85,5 +75,7 @@ export async function copyNodeAssets(assets: readonly NodeAsset[]) {
export async function seaAssetMap() {
const root = path.join(dir, "dist-node", "assets")
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
return Object.fromEntries(
(await collectFiles(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]),
)
}
+65
View File
@@ -0,0 +1,65 @@
import { stat } from "node:fs/promises"
import path from "node:path"
import { collectFiles } from "./files"
const forbidden = [
"@napi-rs/canvas",
"@fontsource/commit-mono",
"@fontsource/noto-sans",
"SimulationPng",
"frontend/png",
"Failed to register screenshot font",
"commit-mono-latin-400-normal",
"noto-sans-symbols-symbols-400-normal",
"noto-sans-math-math-400-normal",
"CommitMono-400-Regular.otf",
"NotoSansSymbols.ttf",
"src/frontend/png.ts",
"skia.darwin-",
"skia.linux-",
"skia.win32-",
]
const overlap = Math.max(...forbidden.map((value) => value.length)) - 1
export async function verifyArtifact(target: string) {
const files = await artifactFiles(target)
if (files.length === 0) throw new Error(`Artifact contains no published files: ${target}`)
for (const file of files) await scan(file)
}
export function verifySimulationGraph(inputs: Iterable<string>) {
const modules = Array.from(inputs, (input) => input.replaceAll("\\", "/"))
const required = [
"/packages/simulation/src/frontend/simulation.ts",
"/packages/simulation/src/frontend/server.ts",
"/packages/simulation/src/control-server.ts",
]
const missing = required.filter((input) => !modules.some((module) => module.endsWith(input)))
if (missing.length > 0) throw new Error(`Build graph is missing simulation bridge inputs: ${missing.join(", ")}`)
const leaked = modules.find((module) => module.includes("/packages/simulation/src/frontend/png."))
if (leaked) throw new Error(`Build graph contains Drive-only rendering input: ${leaked}`)
}
async function artifactFiles(target: string): Promise<string[]> {
if ((await stat(target)).isFile()) return [target]
return (await collectFiles(target)).map((file) => path.join(target, file))
}
async function scan(file: string) {
let trailing = ""
const reader = Bun.file(file).stream().getReader()
while (true) {
const chunk = await reader.read()
if (chunk.done) return
const text = trailing + Buffer.from(chunk.value).toString("latin1")
const leaked = forbidden.find((marker) => text.includes(marker))
if (leaked) throw new Error(`Artifact file ${file} contains forbidden simulation payload: ${leaked}`)
trailing = text.slice(-overlap)
}
}
if (import.meta.main) {
const target = process.argv[2]
if (!target) throw new Error("Usage: bun run script/verify-artifact.ts <file-or-directory>")
await verifyArtifact(target)
}
-1
View File
@@ -30,7 +30,6 @@ describe("debug config command", () => {
],
},
},
{ type: "file", path: path.join(project, "opencode.json") },
]
let requested: URL | undefined
const authorization: Array<string | null> = []
@@ -1,371 +0,0 @@
import { Effect } from "effect"
import { defineScript, Llm } from "opencode-drive"
import { mkdir } from "node:fs/promises"
import path from "node:path"
export default defineScript({
launch: "manual",
config: { autoupdate: false },
run: ({ artifacts, llm, server }) =>
Effect.gen(function* () {
yield* Effect.promise(() => configureServicePort(artifacts))
yield* server.launch()
const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
const root = path.resolve(import.meta.dir, "../../../..")
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
const session = `mini-stage2-${process.pid}`
const snapshots = path.join(artifacts, "mini-stage2")
const explicitDirectory = path.join(artifacts, "explicit-model")
yield* Effect.promise(() =>
Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))),
)
/** @param {string} directory @param {string | undefined} model */
const mini = (directory, model) => [
"env",
`PWD=${directory}`,
`OPENCODE_PASSWORD=${registration.password}`,
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
`OPENCODE_TEST_HOME=${artifacts}`,
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
"OPENCODE_DISABLE_AUTOUPDATE=1",
"OPENCODE_DIRECT_TRACE=1",
process.execPath,
"--conditions=browser",
`--preload=${preload}`,
path.join(root, "packages/cli/src/index.ts"),
"mini",
"--server",
registration.url,
...(model ? ["--model", model] : []),
]
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-shell",
name: "shell",
input: { command: "printf 'drive-mini-tool-output\\n'" },
}),
Llm.finish("tool-calls"),
)
yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
const journey = Effect.gen(function* () {
yield* Effect.uninterruptible(
Effect.promise(() =>
tmux([
"new-session",
"-d",
"-s",
session,
"-x",
"140",
"-y",
"30",
"--",
...mini(path.join(artifacts, "files"), undefined),
]),
),
)
yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
if (first.includes("drive mini response complete"))
throw new Error("response rendered before prompt submission")
yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
yield* Effect.sleep(500)
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
yield* Effect.promise(() =>
waitForFile(
resizeOutput,
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
),
)
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
const resized = yield* Effect.promise(() => captureVisiblePane(session))
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-question",
name: "question",
input: {
questions: [
{
header: "Drive form",
question: "Choose the Mini Form answer",
options: [{ label: "Accepted", description: "Continue the run" }],
multiple: false,
},
],
},
}),
Llm.finish("tool-calls"),
)
yield* llm.queue(Llm.text("drive mini form complete"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
yield* llm.queue(
Llm.toolCall({
index: 0,
id: "mini-slow-shell",
name: "shell",
input: { command: "sleep 10" },
}),
Llm.finish("tool-calls"),
)
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
yield* Effect.sleep(100)
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
yield* Effect.promise(async () => {
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
})
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
const status = yield* Effect.promise(() => paneDeadStatus(session))
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
const exited = yield* Effect.promise(() => capturePane(session))
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
throw new Error("Mini exit splash was not rendered before teardown")
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
yield* Effect.promise(() =>
tmux(["respawn-pane", "-k", "-t", session, "--", ...mini(explicitDirectory, "simulation/gpt-sim-model")]),
)
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
yield* Effect.promise(() => waitForDeadPane(session))
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
throw new Error("Explicit-model Mini did not exit cleanly")
yield* Effect.promise(async () => {
for (const failure of [
{
args: ["--model", "simulation/definitely-missing"],
capture: "08-unavailable-model.txt",
expected: "Model unavailable: simulation/definitely-missing",
},
{
args: ["--agent", "definitely-missing"],
capture: "09-unavailable-agent.txt",
expected: 'Agent not found: "definitely-missing"',
},
]) {
const child = Bun.spawn(
[
process.execPath,
path.join(root, "packages/cli/src/index.ts"),
"run",
"--server",
registration.url,
...failure.args,
"optimistic selection check",
],
{
cwd: path.join(root, "packages/cli"),
env: {
...process.env,
PWD: path.join(artifacts, "files"),
OPENCODE_PASSWORD: registration.password,
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
if (!stderr.includes(failure.expected))
throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
}
})
})
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
}),
})
/** @param {string[]} args */
async function tmux(args, allowFailure = false) {
const child = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
let timedOut = false
const timeout = setTimeout(() => {
timedOut = true
child.kill("SIGKILL")
}, 5_000)
const [status, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
clearTimeout(timeout)
if (timedOut) throw new Error(`tmux ${args[0]} timed out`)
if (status !== 0 && !allowFailure) throw new Error(`tmux ${args[0]} failed: ${stderr || stdout}`)
return stdout
}
/** @param {string} session */
function capturePane(session) {
return tmux(["capture-pane", "-p", "-t", session, "-S", "-"])
}
/** @param {string} session */
function captureVisiblePane(session) {
return tmux(["capture-pane", "-p", "-t", session])
}
/** @param {string} session @param {string} text @param {number} [timeout] */
async function waitForVisiblePane(session, text, timeout = 5_000) {
const deadline = Date.now() + timeout
let last = ""
while (Date.now() < deadline) {
last = await captureVisiblePane(session)
if (last.includes(text)) return last
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
await Bun.sleep(50)
}
throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
}
/** @param {string} session */
async function paneAlive(session) {
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
}
/** @param {string} session */
async function paneDeadStatus(session) {
return Number((await tmux(["display-message", "-p", "-t", session, "#{pane_dead_status}"])).trim())
}
/**
* @param {string} session
* @param {string} text
* @param {number} [timeout]
* @param {(() => Promise<void>) | undefined} [trigger]
*/
async function waitForPane(session, text, timeout = 5_000, trigger) {
const deadline = Date.now() + timeout
let last = ""
while (Date.now() < deadline) {
await trigger?.()
last = await capturePane(session)
if (last.includes(text)) return last
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
await Bun.sleep(50)
}
throw new Error(`Timed out waiting for ${JSON.stringify(text)}:\n${last}`)
}
/** @param {string} session */
async function waitForDeadPane(session) {
for (let attempt = 0; attempt < 100; attempt++) {
if (!(await paneAlive(session))) return
await Bun.sleep(50)
}
throw new Error("Mini did not tear down after the exit sequence")
}
/**
* @param {string} file
* @param {(value: string) => boolean} accept
*/
async function waitForFile(file, accept) {
let value = ""
for (let attempt = 0; attempt < 100; attempt++) {
value = await Bun.file(file)
.text()
.catch(() => "")
if (accept(value)) return value
await Bun.sleep(50)
}
throw new Error("resize did not replay committed transcript output")
}
/** @param {string} artifacts */
async function configureServicePort(artifacts) {
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
const port = probe.port
await probe.stop(true)
if (!port) throw new Error("Failed to allocate a Drive service port")
const file = path.join(artifacts, "files/.opencode/service-local.json")
await mkdir(path.dirname(file), { recursive: true })
await Bun.write(file, JSON.stringify({ port }))
}
/** @param {string} artifacts */
async function serviceRegistration(artifacts) {
const directory = path.join(artifacts, "home/.local/state/opencode")
for (let attempt = 0; attempt < 200; attempt++) {
for (const name of ["service-local.json", "service.json"]) {
const value = await Bun.file(path.join(directory, name))
.json()
.catch(() => undefined)
if (isRegistration(value)) return value
}
await Bun.sleep(50)
}
throw new Error("Drive service registration was not written")
}
/** @param {unknown} value */
function isRegistration(value) {
return (
typeof value === "object" &&
value !== null &&
"url" in value &&
typeof value.url === "string" &&
"password" in value &&
typeof value.password === "string"
)
}
@@ -1,87 +0,0 @@
import { defineScript } from "opencode-drive"
import { mkdir } from "node:fs/promises"
import path from "node:path"
export default defineScript({
launch: "manual",
setup({ config }) {
config.autoupdate = false
},
async run({ artifacts, llm, server }) {
await configureServicePort(artifacts)
llm.queue(llm.text("drive noninteractive smoke ok"))
await server.launch()
const registration = await serviceRegistration(artifacts)
const root = path.resolve(import.meta.dir, "../../../..")
const directory = path.join(artifacts, "files")
const child = Bun.spawn(
[
process.execPath,
path.join(root, "packages/cli/src/index.ts"),
"run",
"--server",
registration.url,
"drive smoke",
],
{
cwd: path.join(root, "packages/cli"),
env: {
...process.env,
PWD: directory,
OPENCODE_PASSWORD: registration.password,
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
OPENCODE_DISABLE_AUTOUPDATE: "1",
},
stdin: "ignore",
stdout: "pipe",
stderr: "pipe",
},
)
const [exitCode, stdout, stderr] = await Promise.all([
child.exited,
new Response(child.stdout).text(),
new Response(child.stderr).text(),
])
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
},
})
/** @param {string} artifacts */
async function configureServicePort(artifacts) {
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
const port = probe.port
await probe.stop(true)
if (!port) throw new Error("Failed to allocate a Drive service port")
const file = path.join(artifacts, "files/.opencode/service-local.json")
await mkdir(path.dirname(file), { recursive: true })
await Bun.write(file, JSON.stringify({ port }))
}
/** @param {string} artifacts */
async function serviceRegistration(artifacts) {
const directory = path.join(artifacts, "home/.local/state/opencode")
for (let attempt = 0; attempt < 200; attempt++) {
for (const name of ["service-local.json", "service.json"]) {
const value = await Bun.file(path.join(directory, name))
.json()
.catch(() => undefined)
if (isRegistration(value)) return value
}
await Bun.sleep(50)
}
throw new Error("Drive service registration was not written")
}
/** @param {unknown} value */
function isRegistration(value) {
return (
typeof value === "object" &&
value !== null &&
"url" in value &&
typeof value.url === "string" &&
"password" in value &&
typeof value.password === "string"
)
}
+12 -1
View File
@@ -4,6 +4,7 @@ import { createRequire } from "node:module"
import { defineConfig, type Plugin, type UserConfig } from "vite"
import solid from "vite-plugin-solid"
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
import { verifySimulationGraph } from "./script/verify-artifact"
const dir = import.meta.dirname
@@ -48,6 +49,15 @@ function runtimeRequirePlugin(): Plugin {
}
}
function simulationGraphPlugin(): Plugin {
return {
name: "opencode:simulation-graph",
generateBundle() {
verifySimulationGraph(this.getModuleIds())
},
}
}
function fffNodePlugin(): Plugin {
return {
name: "opencode:fff-node",
@@ -240,6 +250,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
rawTextPlugin(),
runtimeRequirePlugin(),
fffNodePlugin(),
simulationGraphPlugin(),
solid({
solid: {
generate: "universal",
@@ -255,6 +266,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
OPENCODE_CHANNEL: JSON.stringify(input.channel),
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
},
ssr: { noExternal: true },
build: {
@@ -264,7 +276,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
emptyOutDir: false,
minify: true,
rollupOptions: {
external: [/^@opencode-ai\/simulation(?:\/|$)/],
output: output("opencode.mjs", nodePrelude(input)),
},
},
@@ -1671,7 +1671,7 @@ export function make(options: ClientOptions) {
request<WorktreeListOutput>(
{
method: "GET",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
@@ -1682,7 +1682,7 @@ export function make(options: ClientOptions) {
request<WorktreeCreateOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
body: {
strategy: input["strategy"],
from: input["from"],
@@ -1699,7 +1699,7 @@ export function make(options: ClientOptions) {
request<WorktreeRemoveOutput>(
{
method: "DELETE",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
body: { directory: input["directory"], force: input["force"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1711,7 +1711,7 @@ export function make(options: ClientOptions) {
request<WorktreeRefreshOutput>(
{
method: "POST",
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
@@ -1787,7 +1787,7 @@ export type ConfigEntry =
| { repository: string; branch?: string; description?: string; hidden?: boolean }
| { path: string; description?: string; hidden?: boolean }
}
websearch?: { provider: string }
websearch?: false | { provider: "random" | (string & {}) }
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
providers?: {
@@ -1835,13 +1835,13 @@ export type ConfigEntry =
}
}
experimental?: {
portable_shell_scanner?: boolean
subagent_depth?: number
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
}
}
}
| { type: "directory"; path: string }
| { type: "file"; path: string }
| { type: "agents"; path: string }
| { type: "claude"; path: string }
+4 -5
View File
@@ -66,7 +66,6 @@ test("config.get returns ordered config entries for a location", async () => {
],
},
},
{ type: "file" as const, path: "/tmp/project/opencode.json" },
]
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
@@ -283,10 +282,10 @@ test("worktree methods use the global project contract", async () => {
await client.worktree.refresh({ projectID: "proj_test" })
expect(requests.map((request) => [request.method, request.url])).toEqual([
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
["GET", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test"],
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
])
expect(await requests[1]?.json()).toEqual({
strategy: "git",
+1
View File
@@ -80,6 +80,7 @@
"@types/bun": "catalog:",
"@types/node": "catalog:",
"@types/which": "3.0.4",
"@opencode-ai/shell-scan": "workspace:*",
"@parcel/watcher-darwin-arm64": "2.5.1",
"@parcel/watcher-darwin-x64": "2.5.1",
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
+10
View File
@@ -20,6 +20,16 @@ const result = await Bun.build({
format: "esm",
packages: "external",
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
plugins: [
{
name: "bundle-shell-scan",
setup(build) {
build.onResolve({ filter: /^@opencode-ai\/shell-scan$/ }, () => ({
path: path.resolve("../shell-scan/src/index.ts"),
}))
},
},
],
splitting: true,
loader: {
".txt": "text",
+1 -1
View File
@@ -321,7 +321,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
transport: {
id: "ai-sdk",
prepare: (input) => Effect.succeed(input.body),
frames: () => Stream.empty,
execute: () => Effect.succeed({ frames: Stream.empty }),
},
defaults: {
headers: info.headers,
+102 -17
View File
@@ -4,13 +4,14 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import path from "path"
import { isDeepStrictEqual } from "node:util"
import { type ParseError, parse } from "jsonc-parser"
import { applyEdits, modify } from "jsonc-parser"
import { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
import { produce, type Draft } from "immer"
import {
AgentsDirectory,
ClaudeDirectory,
Directory,
Document,
File,
Info,
type Entry,
Event,
@@ -36,6 +37,8 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
export interface Interface {
/** Returns location config documents and discovery sources from lowest to highest priority. */
readonly entries: () => Effect.Effect<Entry[]>
/** Updates the first file-backed configuration document. */
readonly update: (update: (draft: Draft<Info>) => void) => Effect.Effect<Info, UpdateError>
/**
* Streams raw filesystem updates under config roots. Config owns root
* topology and watch reconciliation; domain owners filter this feed for the
@@ -44,6 +47,11 @@ export interface Interface {
readonly changes: () => Stream.Stream<Watcher.Update>
}
export class UpdateError extends Schema.TaggedErrorClass<UpdateError>()("Config.UpdateError", {
message: Schema.String,
cause: Schema.optional(Schema.Defect()),
}) {}
export const Options = Schema.Struct({
project: Schema.optional(Schema.Boolean),
file: Schema.optional(Schema.String),
@@ -70,6 +78,19 @@ export const testLayer = (initial: Entry[] = []) =>
const updates = yield* PubSub.unbounded<Watcher.Update>()
const service = Test.of({
entries: () => Ref.get(entries),
update: (update) =>
Effect.gen(function* () {
const current = yield* Ref.get(entries)
const index = current.findIndex((entry) => entry.type === "document" && entry.path !== undefined)
if (index === -1)
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const entry = current[index]
if (!entry || entry.type !== "document")
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const info = produce(entry.info, update)
yield* Ref.set(entries, current.with(index, new Document({ type: "document", path: entry.path, info })))
return info
}),
changes: () => Stream.fromPubSub(updates),
setEntries: (next) => Ref.set(entries, next),
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
@@ -91,6 +112,7 @@ export const layer = (options?: Options) =>
const wellknown = yield* WellKnown.Service
const names = ["opencode.json", "opencode.jsonc"]
const reloadLock = Semaphore.makeUnsafe(1)
const fileTargets = new Set<AbsolutePath>()
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
@@ -131,7 +153,7 @@ export const layer = (options?: Options) =>
const substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
const info = yield* parseInfo(substituted, filepath)
if (!info) return
return new Document({ type: "document", path: filepath, info })
return new Document({ type: "document", path: AbsolutePath.make(filepath), info })
})
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
@@ -224,25 +246,18 @@ export const layer = (options?: Options) =>
const directPaths = discovered
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
.toReversed()
const direct = yield* Effect.forEach(directPaths, (filepath) =>
loadFile(filepath).pipe(
Effect.map((config) => [
...(config ? [config] : []),
new File({ type: "file", path: AbsolutePath.make(filepath) }),
]),
),
).pipe(
fileTargets.clear()
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
Effect.orDie,
Effect.map((entries) => entries.flat()),
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
)
const file = options?.file
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
const explicit = file
? yield* loadFile(path.resolve(file)).pipe(
Effect.map((config) => [
...(config ? [config] : []),
new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }),
]),
Effect.map((config) => (config ? [config] : [])),
Effect.orDie,
)
: []
@@ -285,7 +300,10 @@ export const layer = (options?: Options) =>
const watched = new Set<string>()
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
const files = entries.flatMap((entry) => (entry.type === "file" ? [entry.path] : []))
const files = [
...entries.flatMap((entry) => (entry.type === "document" && entry.path ? [entry.path] : [])),
...fileTargets,
]
const targets = [
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
...files
@@ -308,9 +326,9 @@ export const layer = (options?: Options) =>
reloadLock.withPermit(
Effect.gen(function* () {
const next = yield* discover()
yield* reconcile(next)
if (isDeepStrictEqual(configs, next)) return
configs = next
yield* reconcile(next)
yield* bus.publish(Event.Updated, {})
}),
),
@@ -364,10 +382,54 @@ export const layer = (options?: Options) =>
)
yield* reconcile(initial)
const update = Effect.fn("Config.update")((mutate: (draft: Draft<Info>) => void) =>
reloadLock.withPermit(
Effect.gen(function* () {
// TODO: Replace entry-order selection with an explicit config scope/target model.
const document = configs.find((entry) => entry.type === "document" && entry.path !== undefined)
if (!document || document.type !== "document" || !document.path)
return yield* Effect.fail(new UpdateError({ message: "No editable config document found" }))
const next = yield* Effect.try({
try: () => produce(document.info, mutate),
catch: (cause) => new UpdateError({ message: "Config update failed", cause }),
})
const edits = changes(document.info, next)
if (!edits.length) return document.info
const text = yield* fs
.readFileString(document.path)
.pipe(
Effect.mapError(
(cause) => new UpdateError({ message: `Failed to read config: ${document.path}`, cause }),
),
)
const updated = edits.reduce(
(text, edit) =>
applyEdits(
text,
modify(text, edit.path, edit.value, { formattingOptions: { tabSize: 2, insertSpaces: true } }),
),
text,
)
const info = yield* parseInfo(updated, document.path)
if (!info)
return yield* Effect.fail(new UpdateError({ message: `Invalid config update: ${document.path}` }))
const temporary = document.path + ".tmp"
yield* fs.writeFileString(temporary, updated.endsWith("\n") ? updated : updated + "\n").pipe(
Effect.andThen(fs.rename(temporary, document.path)),
Effect.mapError(
(cause) => new UpdateError({ message: `Failed to write config: ${document.path}`, cause }),
),
)
return info
}),
),
)
return Service.of({
entries: Effect.fn("Config.entries")(function* () {
return configs
}),
update,
changes: () => Stream.fromPubSub(updates),
})
}),
@@ -382,3 +444,26 @@ export function configured(options?: Options) {
}
export const node = configured()
type Edit = { readonly path: (string | number)[]; readonly value: unknown }
function changes(before: unknown, after: unknown, path: (string | number)[] = []): Edit[] {
if (Object.is(before, after)) return []
if (
before !== null &&
after !== null &&
typeof before === "object" &&
typeof after === "object" &&
!Array.isArray(before) &&
!Array.isArray(after)
) {
const previous = before as Record<string, unknown>
const next = after as Record<string, unknown>
return [...new Set([...Object.keys(previous), ...Object.keys(next)])].flatMap((key) => {
if (!(key in next)) return [{ path: [...path, key], value: undefined }]
if (!(key in previous)) return [{ path: [...path, key], value: next[key] }]
return changes(previous[key], next[key], [...path, key])
})
}
return [{ path, value: after }]
}
+9
View File
@@ -401,6 +401,15 @@ function normalizeExperimental(
unsupportedExperimental.forEach((key) =>
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
)
if (own(experimental, "portable_shell_scanner")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.portable_shell_scanner,
experimental.portable_shell_scanner,
["experimental", "portable_shell_scanner"],
diagnostics,
)
if (value !== undefined) result.portable_shell_scanner = value
}
if (own(experimental, "subagent_depth")) {
const value = decodeEncoded(
ConfigExperimental.Info.fields.subagent_depth,
+2 -1
View File
@@ -16,6 +16,7 @@ import { Permission } from "../../permission.js"
import type { LocationMutation } from "../../location-mutation.js"
import type { ReadTool } from "../../tool/plugin/read.js"
import type { EditTool } from "../../tool/plugin/edit.js"
import { AbsolutePath } from "../../schema.js"
const legacySources = [
{ pattern: "{agent,agents}/**/*.md", primary: false },
@@ -210,5 +211,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
}),
)
if (!info) return
return new Document({ type: "document", path: file.filepath, info })
return new Document({ type: "document", path: AbsolutePath.make(file.filepath), info })
}
+3 -2
View File
@@ -10,8 +10,9 @@ export const Plugin = define({
const config = yield* Config.Service
const loaded = { entries: yield* config.entries() }
yield* ctx.websearch.transform((websearch) => {
const providerID = Config.latest(loaded.entries, "websearch")?.provider
if (providerID) websearch.default.set(providerID)
const selection = Config.latest(loaded.entries, "websearch")
if (selection === false) websearch.default.set(false)
if (selection) websearch.default.set(selection.provider)
})
yield* ctx.event.subscribe().pipe(
Stream.filter((event) => event.type === "config.updated"),
@@ -1,4 +1,6 @@
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
import { NodeSocket } from "@effect/platform-node"
import { Socket } from "effect/unstable/socket"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
@@ -10,4 +12,10 @@ export const requestExecutor = makeGlobalNode({
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
export const webSocketConstructor = makeGlobalNode({
service: Socket.WebSocketConstructor,
layer: NodeSocket.layerWebSocketConstructorWS,
deps: [],
})
export * as LayerNodePlatform from "./app-node-platform.js"
+2
View File
@@ -33,6 +33,7 @@ import { WebSearch } from "./websearch.js"
import { ReferenceInstructions } from "./reference/instructions.js"
import { SessionRunnerLLM } from "./session/runner/llm.js"
import { SessionRunnerModel } from "./session/runner/model.js"
import { SessionModelTransport } from "./session/model-transport.js"
import { SessionCompaction } from "./session/compaction.js"
import { SessionTitle } from "./session/title.js"
import { Skill } from "./skill.js"
@@ -91,6 +92,7 @@ const locationServiceNodes = [
McpTool.node,
SessionInstructions.node,
SessionRunnerModel.node,
SessionModelTransport.node,
SessionCompaction.node,
SessionTitle.node,
Snapshot.node,
+7 -1
View File
@@ -28,6 +28,10 @@ interface Failures extends Record<keyof Domains, unknown> {
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
export interface Interface {
readonly has: <Domain extends keyof Domains>(
domain: Domain,
name: keyof Domains[Domain] & keyof Failures[Domain],
) => Effect.Effect<boolean>
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
domain: Domain,
name: Name,
@@ -74,7 +78,9 @@ const layer = Layer.effect(
return event
})
return Service.of({ register, trigger })
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
return Service.of({ has, register, trigger })
}),
)
+4 -1
View File
@@ -332,7 +332,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
}),
default: {
get: draft.default.get,
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
set: (selection) =>
draft.default.set(
selection === false || selection === "random" ? selection : WebSearch.ID.make(selection),
),
},
})
}),
+4 -16
View File
@@ -5,7 +5,6 @@ import { App } from "../../app.js"
import { Credential } from "../../credential.js"
import { Bus } from "../../bus.js"
import { Integration } from "../../integration.js"
import { Model } from "../../model.js"
import { OauthCallbackPage } from "../../oauth/page.js"
import { Provider } from "../../provider.js"
import type { PluginInternal } from "../internal.js"
@@ -203,10 +202,10 @@ export const OpenAIPlugin = define({
if (!item) return
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
const account = chatgpt.metadata?.accountID
item.provider.headers = Provider.mergeHeaders(
item.provider.headers,
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
)
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
originator: "opencode",
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
})
for (const model of item.models.values()) {
// ChatGPT-plan tokens only authorize codex-eligible models, and the
// subscription covers usage, so hide the rest and zero the cost.
@@ -230,17 +229,6 @@ export const OpenAIPlugin = define({
})
}
})
yield* ctx.session.hook("http.request", (evt) =>
Effect.sync(() => {
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
const url = new URL(evt.request.url)
evt.request.headers.set("originator", "opencode")
evt.request.headers.set("session-id", evt.sessionID)
if (url.origin !== "https://api.openai.com") return
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
}),
)
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
+7 -1
View File
@@ -1,5 +1,11 @@
import { WebSearchExa } from "./exa.js"
import { WebSearchFirecrawl } from "./firecrawl.js"
import { WebSearchParallel } from "./parallel.js"
import { WebSearchTavily } from "./tavily.js"
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
export const WebSearchPlugins = [
WebSearchExa.Plugin,
WebSearchFirecrawl.Plugin,
WebSearchParallel.Plugin,
WebSearchTavily.Plugin,
] as const
@@ -0,0 +1,85 @@
export * as WebSearchTavily from "./tavily.js"
import { define } from "@opencode-ai/plugin/effect/plugin"
import { Duration, Effect, Schema, Scope } from "effect"
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
import { App } from "../../app.js"
export const endpoint = "https://api.tavily.com/search"
const SearchRequest = Schema.Struct({
query: Schema.String,
search_depth: Schema.Literal("basic"),
chunks_per_source: Schema.Number,
max_results: Schema.Number,
})
const SearchResponse = Schema.Struct({
results: Schema.Array(
Schema.Struct({
title: Schema.String,
url: Schema.String,
content: Schema.String,
}),
),
})
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
id: "opencode.websearch.tavily",
effect: Effect.fn("WebSearchTavily.Plugin")(function* (ctx) {
const http = yield* HttpClient.HttpClient
yield* ctx.integration.transform((draft) => {
draft.update("tavily", (integration) => (integration.name = "Tavily"))
draft.method.update({
integrationID: "tavily",
method: { type: "key" },
})
draft.method.update({
integrationID: "tavily",
method: { type: "env", names: ["TAVILY_API_KEY"] },
})
})
yield* ctx.websearch.transform((draft) => {
draft.add({
id: "tavily",
name: "Tavily",
execute: (input) =>
Effect.gen(function* () {
const connection = yield* ctx.integration.connection.active("tavily")
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
const request = yield* HttpClientRequest.post(endpoint).pipe(
HttpClientRequest.acceptJson,
HttpClientRequest.setHeaders({
"User-Agent": App.useragent(ctx.app),
"X-Client-Name": "opencode2",
...(credential?.type === "key"
? { Authorization: `Bearer ${credential.key}` }
: { "X-Tavily-Access-Mode": "keyless" }),
}),
HttpClientRequest.schemaBodyJson(SearchRequest)({
query: input.query,
search_depth: "basic",
chunks_per_source: 3,
max_results: 8,
}),
)
const response = yield* Effect.gen(function* () {
const httpResponse = yield* HttpClient.filterStatusOk(http).execute(request)
return yield* HttpClientResponse.schemaBodyJson(SearchResponse)(httpResponse)
}).pipe(
Effect.timeoutOrElse({
duration: Duration.seconds(25),
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
}),
)
return response.results.map((item) => ({
url: item.url,
title: item.title,
...(item.content ? { content: item.content } : {}),
time: {},
}))
}),
})
})
}),
})
+19 -5
View File
@@ -1,7 +1,7 @@
export * as Session from "./session.js"
export * from "./session/schema.js"
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
import { ListAnchor } from "@opencode-ai/schema/session"
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
import { Project } from "./project.js"
@@ -27,6 +27,7 @@ import { fromRow } from "./session/info.js"
import { SessionRunner } from "./session/runner/index.js"
import { SessionStore } from "./session/store.js"
import { SessionExecution } from "./session/execution.js"
import { SessionModelTransport } from "./session/model-transport.js"
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { LocationServiceMap } from "./location-service-map.js"
@@ -306,6 +307,16 @@ const layer = Layer.effect(
const scope = yield* Scope.Scope
const activeShells = new Set<SessionSchema.ID>()
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
const location = Location.Ref.make({
directory: session.location.directory,
workspaceID: session.location.workspaceID,
})
if (!(yield* RcMap.has(locations.rcMap, location))) return
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
Effect.provide(locations.get(location)),
)
})
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
@@ -437,9 +448,10 @@ const layer = Layer.effect(
return session
}),
remove: Effect.fn("Session.remove")(function* (sessionID) {
yield* result.get(sessionID)
const session = yield* result.get(sessionID)
yield* execution.interrupt(sessionID)
yield* execution.awaitIdle(sessionID)
yield* closeTransport(session)
const children = yield* result.list({ parentID: sessionID })
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
yield* bus.publish(SessionEvent.Deleted, { sessionID })
@@ -777,7 +789,10 @@ const layer = Layer.effect(
return false
}),
)
if (recovered) return
if (recovered) {
yield* execution.wakeActive(input.sessionID)
return
}
yield* execution.wake(input.sessionID)
}),
compact: Effect.fn("Session.compact")(function* (input) {
@@ -863,8 +878,7 @@ const layer = Layer.effect(
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
Effect.uninterruptible(
Effect.gen(function* () {
yield* execution.interrupt(sessionID)
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
yield* execution.interrupt(sessionID, options)
}),
),
),
+22 -11
View File
@@ -1,7 +1,8 @@
export * as SessionExecution from "./execution.js"
import { Cause, Context, Effect, Exit, Layer, Stream } from "effect"
import { Cause, Context, Effect, Exit, Layer } from "effect"
import { Bus } from "../bus.js"
import { Database } from "../database/database.js"
import { LocationServiceMap } from "../location-service-map.js"
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
import { SessionEvent } from "./event.js"
@@ -11,6 +12,7 @@ import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
export interface Interface {
/** Snapshots active execution owned by this process. */
@@ -19,8 +21,10 @@ export interface Interface {
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
/** Registers newly recorded work. Repeated wakeups may coalesce. */
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Wakes only an active execution, preserving its current input eligibility. */
readonly wakeActive: (sessionID: SessionSchema.ID) => Effect.Effect<void>
/** Interrupt active work owned by this process. Idle interruption is a no-op. */
readonly interrupt: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly interrupt: (sessionID: SessionSchema.ID, options?: { readonly continue?: boolean }) => Effect.Effect<void>
/** Resolves once this process owns no active execution for the Session. Returns immediately when idle and never starts work. */
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
}
@@ -45,6 +49,7 @@ export const layer = Layer.effect(
const store = yield* SessionStore.Service
const locations = yield* LocationServiceMap.Service
const bus = yield* Bus.Service
const db = (yield* Database.Service).db
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
effect.pipe(
Effect.tapCause((cause) =>
@@ -71,12 +76,13 @@ export const layer = Layer.effect(
sessionID: SessionSchema.ID,
force: boolean,
continuation?: SessionRunner.Continuation,
promotable: SessionInbox.Promotable = "input",
): Effect.Effect<void, SessionRunner.RunError> {
return Effect.gen(function* () {
const session = yield* store.get(sessionID)
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
const result = yield* SessionRunner.Service.use((runner) =>
runner.drain({ sessionID, force, continuation }),
runner.drain({ sessionID, force, continuation, promotable }),
).pipe(
Effect.provide(locations.get(session.location)),
Effect.tapCause((cause) =>
@@ -86,7 +92,7 @@ export const layer = Layer.effect(
),
)
if (result.type === "complete") return
return yield* drain(sessionID, false, result.continuation)
return yield* drain(sessionID, false, result.continuation, promotable)
})
}
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
@@ -95,7 +101,7 @@ export const layer = Layer.effect(
sessionID,
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(sessionID)),
),
drain: (sessionID, force) => drain(sessionID, force),
drain: (sessionID, force, promotable) => drain(sessionID, force, undefined, promotable),
// One terminal observation per busy period, covering every coalesced drain.
settled: (sessionID, exit, reason) =>
reportLifecycle(
@@ -127,16 +133,20 @@ export const layer = Layer.effect(
}),
),
})
yield* bus.subscribe(SessionEvent.Moved).pipe(
Stream.runForEach((event) => coordinator.wake(event.data.sessionID)),
Effect.forkScoped,
)
return Service.of({
active: coordinator.active,
interrupt: (sessionID) => coordinator.interrupt(sessionID, "user"),
interrupt: (sessionID, options) =>
coordinator.interrupt(
sessionID,
"user",
options?.continue
? { continue: { request: "steer", when: SessionInbox.has(db, sessionID, "steer") } }
: undefined,
),
resume: coordinator.run,
wake: coordinator.wake,
wakeActive: coordinator.wakeActive,
awaitIdle: coordinator.awaitIdle,
})
}),
@@ -145,7 +155,7 @@ export const layer = Layer.effect(
export const node = makeGlobalNode({
service: Service,
layer,
deps: [SessionStore.node, LocationServiceMap.node, Bus.node],
deps: [SessionStore.node, LocationServiceMap.node, Bus.node, Database.node],
})
/** Low-level compatibility layer for callers that only need durable Session recording. */
@@ -155,6 +165,7 @@ export const noopLayer = Layer.succeed(
active: Effect.succeed(new Set()),
resume: () => Effect.void,
wake: () => Effect.void,
wakeActive: () => Effect.void,
interrupt: () => Effect.void,
awaitIdle: () => Effect.void,
}),
+7 -2
View File
@@ -13,7 +13,7 @@ import { SessionHistory } from "./history.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { SessionRunnerModel } from "./runner/model.js"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
export const layer = Layer.effect(
@@ -39,7 +39,12 @@ export const layer = Layer.effect(
sessionID: selection.session.id,
agent: selection.agent.id,
model: model.ref,
system: [selection.agent.info.system ? selection.agent.info.system : PROMPT_DEFAULT, history.initial]
system: [
selection.agent.info.system
? selection.agent.info.system
: SessionSystemPrompt.make(toolDefinitions.map((tool) => tool.name)),
history.initial,
]
.filter((part) => part.length > 0)
.map(SystemPart.make),
messages: [
+8
View File
@@ -349,6 +349,14 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
return row ? fromRow(row) : undefined
})
export const nextPromotable = Effect.fn("SessionInbox.nextPromotable")(function* (
db: DatabaseService,
sessionID: SessionSchema.ID,
promotable: Promotable,
) {
return (yield* nextSteer(db, sessionID)) ?? (promotable === "input" ? yield* nextQueued(db, sessionID) : undefined)
})
/**
* Which pending rows count: "any" counts every row, while "input" means any
* item in either delivery mode.
+32 -9
View File
@@ -3,11 +3,11 @@ export * as SessionModelRequest from "./model-request.js"
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
import type { StreamOptions } from "@opencode-ai/ai/route"
import type { Content } from "@opencode-ai/schema/tool"
import { SessionError } from "@opencode-ai/schema/session-error"
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { App } from "../app.js"
import { Model } from "../model.js"
import { Provider } from "../provider.js"
import { Permission } from "../permission.js"
import { PluginHooks } from "../plugin/hooks.js"
import { QuestionTool } from "../tool/plugin/question.js"
@@ -15,10 +15,11 @@ import { Tool } from "../tool.js"
import { SessionContext } from "./context.js"
import { SessionModelHeaders } from "./model-headers.js"
import { SessionModelHttp } from "./model-http.js"
import { SessionModelTransport } from "./model-transport.js"
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
import PROMPT_DEFAULT from "./runner/prompt/base.txt"
import { SessionSystemPrompt } from "./system-prompt.js"
import { toLLMMessages } from "./runner/to-llm-message.js"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
@@ -46,6 +47,8 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
interface Prepared {
readonly request: LLMRequest
readonly options: StreamOptions
/** False when Session HTTP hooks require the request to remain on HTTP. */
readonly webSocketEligible: boolean
/**
* One request-scoped execution operation. Unknown, hook-removed, and
* step-limit-violating calls fail individually through the same seam.
@@ -165,7 +168,12 @@ export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const hooks = yield* PluginHooks.Service
const transport = yield* SessionModelTransport.Service
const app = yield* App.Metadata
const webSocket = yield* Config.boolean("OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET").pipe(
Config.withDefault(false),
Effect.orDie,
)
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
Config.withDefault(false),
Effect.orDie,
@@ -182,7 +190,10 @@ export const layer = Layer.effect(
// The final Step keeps definitions available to protocols with native "none",
// preserving their prompt cache prefix. Calls are still rejected at execution.
const tools = input.context.tools
const system = [agent.info.system ? agent.info.system : PROMPT_DEFAULT, input.context.initial]
const system = [
agent.info.system ? agent.info.system : SessionSystemPrompt.make(tools.definitions.map((tool) => tool.name)),
input.context.initial,
]
.filter((part) => part.length > 0)
.map(SystemPart.make)
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
@@ -226,12 +237,23 @@ export const layer = Layer.effect(
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
toolChoice: stepLimitReached ? "none" : undefined,
})
const webSocketEligible =
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
const http = webSocketEligible
? undefined
: SessionModelHttp.middleware(hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
})
const options: StreamOptions = {
http: SessionModelHttp.middleware(hooks, {
sessionID: session.id,
agent: agent.id,
model: resolved.ref,
}),
...(http ? { http } : {}),
...(webSocket &&
webSocketEligible &&
resolved.ref.providerID === Provider.ID.openai &&
model.route.id === "openai-responses"
? { webSocket: transport.bind(session.id) }
: {}),
}
if (promptCacheSnapshots) {
const current = PromptCacheDiagnostics.snapshot(request)
@@ -263,6 +285,7 @@ export const layer = Layer.effect(
return {
request,
options,
webSocketEligible,
executeTool,
stepLimitReached,
}
@@ -275,5 +298,5 @@ export const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [PluginHooks.node, App.node],
deps: [PluginHooks.node, SessionModelTransport.node, App.node],
})
@@ -0,0 +1,488 @@
export * as SessionModelTransport from "./model-transport.js"
import {
WebSocketTransport,
type ChannelObservation,
type ChannelCheckpoint,
type WebSocketChannelExchange,
type WebSocketChannelExecution,
type WebSocketChannelExecutor,
type WebSocketConnection,
type WebSocketConnector,
} from "@opencode-ai/ai/route"
import { AIError, TransportReason, type TransportOperation } from "@opencode-ai/ai"
import { Hash } from "@opencode-ai/util/hash"
import { Cause, Clock, Context, Effect, Fiber, Layer, Metric, Queue, Scope, Semaphore, Stream } from "effect"
import { Socket } from "effect/unstable/socket"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { SessionSchema } from "./schema.js"
import { webSocketConstructor } from "../effect/app-node-platform.js"
const ROTATE_AFTER_MS = 55 * 60 * 1000
const INBOUND_CAPACITY = 128
const IDLE_TIMEOUT = "5 minutes"
const events = Metric.counter("opencode_session_websocket_events_total", {
description: "Session WebSocket lifecycle events",
incremental: true,
})
const metric = (event: string, attributes: Record<string, string> = {}) =>
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
interface Active {
readonly queue: Queue.Queue<string, AIError>
readonly lifecycle: { delivery: Delivery }
}
interface Channel {
readonly affinity: string
readonly connection: WebSocketConnection
readonly openedAt: number
active?: Active
closing: boolean
poisoned: boolean
checkpoint?: ChannelCheckpoint
pending?: { readonly token: object; readonly checkpoint: ChannelCheckpoint }
reader?: Fiber.Fiber<unknown, unknown>
}
interface State {
readonly lock: Semaphore.Semaphore
closed: boolean
channel?: Channel
}
export interface Interface {
readonly bind: (sessionID: SessionSchema.ID) => WebSocketChannelExecutor
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
readonly closeAll: Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelTransport") {}
const transportError = (
method: string,
message: string,
input: {
readonly operation: TransportOperation
readonly url?: string
readonly code?: string
readonly phase?: TransportReason["phase"]
readonly delivery?: TransportReason["delivery"]
},
) =>
new AIError({
module: "SessionModelTransport",
method,
reason: new TransportReason({ message, transport: "websocket", ...input }),
})
const annotate = (
error: AIError,
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
) => {
if (error.reason._tag !== "Transport") return error
return new AIError({
module: error.module,
method: error.method,
reason: new TransportReason({
message: error.reason.message,
transport: error.reason.transport,
operation: error.reason.operation,
code: error.reason.code,
url: error.reason.url,
http: error.reason.http,
recovery: error.reason.recovery,
...input,
}),
})
}
const affinity = (exchange: WebSocketChannelExchange) =>
`${exchange.connect.url}:${Hash.sha256(JSON.stringify(Object.entries(exchange.connect.headers).sort(([a], [b]) => a.localeCompare(b))))}`
const observationFrame = (observation: ChannelObservation) => {
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
return Effect.succeed(observation.frame)
return Effect.fail(observation.error)
}
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
export const makeLayer = (connector: WebSocketConnector) =>
Layer.effect(
Service,
Effect.gen(function* () {
const scope = yield* Scope.Scope
const states = new Map<SessionSchema.ID, State>()
const state = (sessionID: SessionSchema.ID) => {
const current = states.get(sessionID)
if (current) return current
const created = { lock: Semaphore.makeUnsafe(1), closed: false }
states.set(sessionID, created)
return created
}
const closeChannel = Effect.fn("SessionModelTransport.closeChannel")(function* (owner: State, channel: Channel) {
if (owner.channel === channel) owner.channel = undefined
if (channel.closing) return
channel.closing = true
if (channel.reader) yield* Fiber.interrupt(channel.reader)
yield* channel.connection.close
if (channel.active)
Queue.failCauseUnsafe(
channel.active.queue,
Cause.fail(
transportError("close", "Session WebSocket closed", {
operation: "read",
code: "close",
phase: "close",
delivery:
channel.active.lifecycle.delivery === "queued" ||
channel.active.lifecycle.delivery === "connecting" ||
channel.active.lifecycle.delivery === "ready"
? "not-sent"
: channel.active.lifecycle.delivery === "provider-observed" ||
channel.active.lifecycle.delivery === "terminal"
? "accepted"
: "ambiguous",
}),
),
)
yield* metric("close")
})
const poison = Effect.fn("SessionModelTransport.poison")(function* (
owner: State,
channel: Channel,
error: AIError,
) {
channel.poisoned = true
if (owner.channel === channel) owner.channel = undefined
if (channel.closing) return
channel.closing = true
if (channel.active) Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
yield* metric(
error.reason._tag === "Transport" && error.reason.code === "queue-overflow"
? "queue_overflow"
: "protocol_failure",
)
yield* channel.connection.close
})
const open = Effect.fn("SessionModelTransport.open")(function* (
owner: State,
exchange: WebSocketChannelExchange,
key: string,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const connection = yield* restore(
connector.open(exchange.connect).pipe(Effect.withSpan("SessionModelTransport.connect")),
)
if (owner.closed) {
yield* connection.close
return yield* transportError("open", "Session WebSocket owner closed while connecting", {
operation: "request",
code: "owner-closed",
phase: "connect",
delivery: "not-sent",
})
}
const channel: Channel = {
affinity: key,
connection,
openedAt: yield* Clock.currentTimeMillis,
closing: false,
poisoned: false,
}
owner.channel = channel
channel.reader = yield* connection.messages.pipe(
Stream.runForEach((message) =>
Effect.gen(function* () {
const active = channel.active
if (!active)
return yield* transportError("receive", "WebSocket data arrived without an active exchange", {
url: exchange.connect.url,
operation: "read",
code: "idle-data",
phase: "receive",
})
active.lifecycle.delivery = "provider-observed"
if (typeof message !== "string")
return yield* transportError("receive", "Unsupported binary WebSocket frame", {
url: exchange.connect.url,
operation: "read",
code: "message",
phase: "receive",
})
if (Queue.offerUnsafe(active.queue, message)) return undefined
return yield* transportError("receive", "Session WebSocket inbound queue overflow", {
url: exchange.connect.url,
operation: "read",
code: "queue-overflow",
phase: "receive",
delivery: "accepted",
})
}),
),
Effect.catch((error) =>
channel.closing
? Effect.void
: poison(
owner,
channel,
annotate(error, {
phase:
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
delivery:
channel.active?.lifecycle.delivery === "provider-observed" ||
channel.active?.lifecycle.delivery === "terminal" ||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
? "accepted"
: "ambiguous",
}),
),
),
Effect.forkIn(scope, { startImmediately: true }),
)
yield* Effect.logDebug("session websocket connected", {
sessionTransport: "websocket",
phase: "connect",
})
yield* metric("connect")
return channel
}),
)
})
const fallback = (exchange: WebSocketChannelExchange): WebSocketChannelExecution => ({
frames: exchange.fallback(),
complete: Effect.void,
})
const start = Effect.fn("SessionModelTransport.start")(function* (
owner: State,
exchange: WebSocketChannelExchange,
lifecycle: { delivery: Delivery },
) {
if (owner.closed)
return yield* transportError("start", "Session WebSocket owner is closed", {
operation: "request",
code: "owner-closed",
phase: "queue",
delivery: "not-sent",
})
const key = affinity(exchange)
const now = yield* Clock.currentTimeMillis
const current = owner.channel
const rotateAfterMs = exchange.connect.rotateAfterMs ?? ROTATE_AFTER_MS
const rotation = current
? current.poisoned
? "poisoned"
: current.affinity !== key
? "affinity"
: now - current.openedAt >= rotateAfterMs
? "age"
: undefined
: undefined
if (current && rotation) {
yield* Effect.logDebug("session websocket rotating", {
sessionTransport: "websocket",
phase: "connect",
reason: rotation,
})
yield* metric("rotation", { reason: rotation })
yield* metric("reconnect")
yield* closeChannel(owner, current)
}
lifecycle.delivery = owner.channel ? "ready" : "connecting"
if (owner.channel)
yield* Effect.logDebug("session websocket reused", {
sessionTransport: "websocket",
phase: "connect",
})
if (owner.channel) yield* metric("reuse")
const channel = owner.channel
? owner.channel
: yield* open(owner, exchange, key).pipe(
Effect.catch((error) =>
error.reason._tag === "Transport" && error.reason.code === "owner-closed"
? Effect.fail(error)
: Effect.logWarning("session websocket connect failed; using http", {
sessionTransport: "websocket",
phase: "connect",
delivery: "not-sent",
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
}).pipe(
Effect.andThen(metric("connect_failure")),
Effect.andThen(metric("fallback")),
Effect.andThen(Effect.succeed(undefined)),
),
),
)
if (!channel) return fallback(exchange)
lifecycle.delivery = "ready"
if (channel.pending) {
channel.pending = undefined
channel.checkpoint = undefined
}
const create = yield* exchange.driver.create(channel.checkpoint).pipe(
Effect.tapError(() => closeChannel(owner, channel)),
Effect.onInterrupt(() => closeChannel(owner, channel)),
)
if (create.mode === "full") channel.checkpoint = undefined
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
channel.active = active
lifecycle.delivery = "send-attempted"
const sent = yield* channel.connection.sendText(create.message).pipe(
Effect.withSpan("SessionModelTransport.send"),
Effect.onInterrupt(() => closeChannel(owner, channel)),
Effect.result,
)
if (sent._tag === "Failure") {
const failure = sent.failure
const notSent = failure.reason._tag === "Transport" && failure.reason.delivery === "not-sent"
yield* closeChannel(owner, channel)
if (notSent) {
yield* metric("fallback")
return fallback(exchange)
}
yield* metric("ambiguous_delivery")
return yield* annotate(failure, { phase: "send", delivery: "ambiguous" })
}
yield* metric("send")
let terminal: ChannelObservation | undefined
const token = {}
let staged: ChannelCheckpoint | undefined
const frames = Stream.fromQueue(active.queue).pipe(
Stream.timeoutOrElse({
duration: IDLE_TIMEOUT,
orElse: () =>
Stream.fail(
transportError("receive", "Timed out waiting for WebSocket data", {
url: exchange.connect.url,
operation: "read",
code: "idle-timeout",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
}),
),
}),
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
Stream.tap((observation) =>
Effect.sync(() => {
if (!observationTerminal(observation)) return
terminal = observation
lifecycle.delivery = "terminal"
staged = observation.type === "completed" ? observation.checkpoint : undefined
if (staged) channel.pending = { token, checkpoint: staged }
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
}),
),
Stream.takeUntil(observationTerminal),
Stream.mapEffect(observationFrame),
Stream.ensuring(
Effect.gen(function* () {
if (channel.active === active) channel.active = undefined
const pending = yield* Queue.size(active.queue)
yield* Queue.shutdown(active.queue)
if (terminal && pending === 0) {
yield* metric("terminal", { type: terminal.type })
if (terminal.type === "rejected") yield* metric("rejection", { recovery: terminal.recovery })
if (terminal.type === "rejected" && terminal.recovery === "rotate-and-retry-full")
yield* closeChannel(owner, channel)
return
}
yield* metric("cancellation")
channel.checkpoint = undefined
channel.pending = undefined
const error = terminal
? transportError("receive", "WebSocket data arrived after the terminal event", {
url: exchange.connect.url,
operation: "read",
code: "idle-data",
phase: "receive",
delivery: "accepted",
})
: transportError("execute", "Session WebSocket exchange did not reach a terminal event", {
url: exchange.connect.url,
operation: "read",
code: "incomplete",
phase: "receive",
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
})
yield* poison(owner, channel, error)
}),
),
)
const complete = Effect.sync(() => {
if (owner.channel !== channel || channel.pending?.token !== token) return
channel.checkpoint = channel.pending.checkpoint
channel.pending = undefined
})
return { frames, complete }
})
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
execute: (exchange) => {
const owner = state(sessionID)
const lifecycle = { delivery: "queued" as Delivery }
let complete = Effect.void
return Effect.succeed({
frames: Stream.unwrap(
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
Effect.andThen(start(owner, exchange, lifecycle)),
Effect.tap((execution) =>
Effect.sync(() => {
complete = execution.complete
}),
),
Effect.map((execution) => execution.frames),
),
),
complete: Effect.suspend(() => complete),
})
},
})
const close = Effect.fn("SessionModelTransport.close")(function* (sessionID: SessionSchema.ID) {
const owner = states.get(sessionID)
if (!owner) return
states.delete(sessionID)
owner.closed = true
if (owner.channel) yield* closeChannel(owner, owner.channel)
})
const closeAll = Effect.suspend(() => {
const owners = Array.from(states.values())
states.clear()
return Effect.forEach(
owners,
(owner) => {
owner.closed = true
return owner.channel ? closeChannel(owner, owner.channel) : Effect.void
},
{ discard: true },
)
})
yield* Effect.addFinalizer(() => closeAll)
return Service.of({ bind, close, closeAll })
}),
)
export const layer = Layer.unwrap(
Effect.map(Socket.WebSocketConstructor, (constructor) =>
makeLayer({
open: (input) =>
WebSocketTransport.open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
}),
),
)
export const node = makeLocationNode({ service: Service, layer, deps: [webSocketConstructor] })
+83 -23
View File
@@ -1,6 +1,7 @@
export * as SessionRunCoordinator from "./run-coordinator.js"
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
import type { Promotable } from "./inbox.js"
/** Serializes execution for each key while allowing different keys to run concurrently. */
export interface Coordinator<Key, E, Reason = never> {
@@ -9,26 +10,41 @@ export interface Coordinator<Key, E, Reason = never> {
/** Starts an execution while idle, or joins the active execution and returns its exit. */
readonly run: (key: Key) => Effect.Effect<void, E>
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
readonly wake: (key: Key) => Effect.Effect<void>
readonly wake: (key: Key, request?: Request) => Effect.Effect<void>
/** Rings the current execution's doorbell with its existing request. Idle keys remain idle. */
readonly wakeActive: (key: Key) => Effect.Effect<void>
/** Stops the active execution, clears its doorbell, and waits for cleanup. No-op when idle. */
readonly interrupt: (key: Key, reason?: Reason) => Effect.Effect<void>
readonly interrupt: (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
) => Effect.Effect<void>
/** Resolves once no execution is active for the key. Returns immediately when already idle and never starts work. */
readonly awaitIdle: (key: Key) => Effect.Effect<void>
}
export type Request = Promotable
/**
* One execution is a busy period for one key: one fiber that drains from the first wake
* until the key would stay idle. `pendingWake` is the doorbell: work recorded during the
* execution rings it, and the execution loop drains again instead of ending. The doorbell
* closes the gap between a drain's last eligibility check and the idle transition, since
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
* execution rings it with its eligibility request, and the execution loop drains again
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
* with this execution's exit.
*/
type Execution<E, Reason> = {
readonly done: Deferred.Deferred<void, E>
owner?: Fiber.Fiber<void>
pendingWake: boolean
request: Request
pendingWake?: Request
stopping: boolean
interruptionReason?: Reason
continuation?: {
readonly request: Request
readonly when: Effect.Effect<boolean>
signaled: boolean
}
}
/**
@@ -43,7 +59,7 @@ type Execution<E, Reason> = {
* ```
*/
export const make = <Key, E, Reason = never>(options: {
readonly drain: (key: Key, force: boolean) => Effect.Effect<void, E>
readonly drain: (key: Key, force: boolean, request: Request) => Effect.Effect<void, E>
/** Runs once when a process-local busy period begins, before its first drain. */
readonly started?: (key: Key) => Effect.Effect<void>
/**
@@ -55,23 +71,26 @@ export const make = <Key, E, Reason = never>(options: {
Effect.gen(function* () {
const executions = new Map<Key, Execution<E, Reason>>()
const fork = yield* FiberSet.makeRuntime<never, void, never>()
const merge = (left: Request, right: Request): Request =>
left === "input" || right === "input" ? "input" : "steer"
const loop = (key: Key, execution: Execution<E, Reason>, force: boolean): Effect.Effect<void, E> =>
Effect.suspend(() => options.drain(key, force)).pipe(
Effect.suspend(() => options.drain(key, force, execution.request)).pipe(
Effect.flatMap(() =>
Effect.suspend(() => {
if (execution.stopping || !execution.pendingWake) return Effect.void
execution.pendingWake = false
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
execution.request = execution.pendingWake
execution.pendingWake = undefined
// Trampoline so drains that complete synchronously cannot grow the stack.
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
}),
),
)
const start = (key: Key, force: boolean) => {
const start = (key: Key, force: boolean, request: Request) => {
const execution: Execution<E, Reason> = {
done: Deferred.makeUnsafe<void, E>(),
pendingWake: false,
request,
stopping: false,
}
executions.set(key, execution)
@@ -87,7 +106,7 @@ export const make = <Key, E, Reason = never>(options: {
execution.owner = undefined
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
),
Effect.onExit((exit) => Effect.sync(() => settle(key, execution, exit))),
Effect.onExit((exit) => finish(key, execution, exit)),
Effect.exit,
Effect.asVoid,
),
@@ -97,12 +116,22 @@ export const make = <Key, E, Reason = never>(options: {
// A doorbell that survives the execution loop (rung after the loop decided to end, or
// during failure or interruption cleanup) starts a fresh execution for the remaining work.
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (execution.pendingWake) start(key, false)
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, continuation: boolean) => {
if (continuation && execution.continuation) start(key, false, execution.continuation.request)
else if (execution.pendingWake) start(key, false, execution.pendingWake)
else executions.delete(key)
Deferred.doneUnsafe(execution.done, exit)
}
const finish = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>) => {
if (!execution.continuation) return Effect.sync(() => settle(key, execution, exit, false))
return execution.continuation.when.pipe(
Effect.flatMap((ready) =>
Effect.sync(() => settle(key, execution, exit, ready || execution.continuation?.signaled === true)),
),
)
}
const run = (key: Key): Effect.Effect<void, E> =>
Effect.suspend(() => {
const execution = executions.get(key)
@@ -111,26 +140,57 @@ export const make = <Key, E, Reason = never>(options: {
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
return Deferred.await(execution.done)
}
return Deferred.await(start(key, true).done)
return Deferred.await(start(key, true, "input").done)
})
const wake = (key: Key) =>
const wake = (key: Key, request: Request = "input") =>
Effect.sync(() => {
const execution = executions.get(key)
if (execution !== undefined) {
execution.pendingWake = true
if (execution.stopping) {
if (execution.continuation) execution.continuation.signaled = true
else execution.continuation = { request, when: Effect.succeed(true), signaled: true }
return
}
execution.pendingWake = execution.pendingWake ? merge(execution.pendingWake, request) : request
return
}
start(key, false)
start(key, false, request)
})
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
const wakeActive = (key: Key) =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution?.owner === undefined || execution.stopping) return Effect.void
return execution ? wake(key, execution.request) : Effect.void
})
const interrupt = (
key: Key,
reason?: Reason,
options?: { readonly continue?: { readonly request: Request; readonly when: Effect.Effect<boolean> } },
): Effect.Effect<void> =>
Effect.suspend(() => {
const execution = executions.get(key)
if (execution === undefined) return Effect.void
if (execution.stopping) {
if (options?.continue)
execution.continuation = {
...options.continue,
signaled: execution.continuation?.signaled ?? false,
}
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
if (execution.owner === undefined) {
if (!options?.continue) return Effect.void
execution.stopping = true
execution.pendingWake = undefined
execution.continuation = { ...options.continue, signaled: false }
return Deferred.await(execution.done).pipe(Effect.exit, Effect.asVoid)
}
execution.stopping = true
execution.pendingWake = false
execution.pendingWake = undefined
execution.interruptionReason = reason
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
return Fiber.interrupt(execution.owner)
})
@@ -143,5 +203,5 @@ export const make = <Key, E, Reason = never>(options: {
return Deferred.await(execution.done).pipe(Effect.exit, Effect.andThen(awaitIdle(key)))
})
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, interrupt, awaitIdle }
return { active: Effect.sync(() => new Set(executions.keys())), run, wake, wakeActive, interrupt, awaitIdle }
})
@@ -3,6 +3,7 @@ export * as SessionRunner from "./index.js"
import type { AIError } from "@opencode-ai/ai"
import { Context, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js"
@@ -29,6 +30,8 @@ export interface Interface {
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
/** "steer" settles the active intent without promoting queued next-turn work. */
readonly promotable?: Promotable
}) => Effect.Effect<DrainResult, RunError>
}
+32 -13
View File
@@ -19,6 +19,7 @@ import { SessionContext } from "../context.js"
import { SessionEvent } from "../event.js"
import { SessionInbox } from "../inbox.js"
import { SessionModelRequest } from "../model-request.js"
import { SessionModelTransport } from "../model-transport.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
import { SessionStore } from "../store.js"
@@ -34,7 +35,7 @@ import { SessionRunnerRetry } from "./retry.js"
import { SessionUsage } from "../usage.js"
import { ToolOutput } from "../../tool-output.js"
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
type CallOutcome = Data.TaggedEnum<{
Completed: { readonly needsContinuation: boolean; readonly step: number }
Retry: { readonly step: number }
@@ -43,6 +44,7 @@ type CallOutcome = Data.TaggedEnum<{
readonly error: SessionRunnerRetry.RetryableFailure["error"]
readonly step: number
}
RecoverFull: { readonly step: number }
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
}>
const CallOutcome = Data.taggedEnum<CallOutcome>()
@@ -108,6 +110,7 @@ const layer = Layer.effect(
const store = yield* SessionStore.Service
const context = yield* SessionContext.Service
const modelRequests = yield* SessionModelRequest.Service
const modelTransport = yield* SessionModelTransport.Service
const snapshots = yield* Snapshot.Service
const db = (yield* Database.Service).db
const compaction = yield* SessionCompaction.Service
@@ -125,22 +128,25 @@ const layer = Layer.effect(
readonly sessionID: SessionSchema.ID
readonly force: boolean
readonly continuation?: Continuation
readonly promotable?: SessionInbox.Promotable
}) {
let force = input.force
let continuation = input.continuation
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "any")))
const promotable = input.promotable ?? "input"
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
yield* settleStaleToolCalls(input.sessionID)
while (true) {
if (yield* runPendingCompaction(input.sessionID)) {
if (yield* runPendingCompaction(input.sessionID, promotable)) {
force = false
continue
}
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
return { type: "complete" as const }
const result = yield* runSteps(input.sessionID, continuation)
const result = yield* runSteps(input.sessionID, continuation, promotable)
if (result.type === "moved") return result
if (promotable === "steer") return { type: "complete" as const }
force = false
continuation = undefined
}
@@ -153,13 +159,14 @@ const layer = Layer.effect(
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
sessionID: SessionSchema.ID,
continuation?: Continuation,
initialPromotable: SessionInbox.Promotable = continuation ? "steer" : "input",
) {
// Fresh work may promote queued input; later steps absorb steers only.
let promotable: SessionInbox.Promotable = continuation ? "steer" : "input"
let promotable = initialPromotable
let step = continuation?.step ?? 1
let next = continuation
while (true) {
if (yield* runPendingCompaction(sessionID)) continue
if (yield* runPendingCompaction(sessionID, "steer")) continue
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
const result = yield* runStep(sessionID, promotable, step)
next = result.needsContinuation ? { step: result.step + 1 } : undefined
@@ -205,12 +212,15 @@ const layer = Layer.effect(
let currentStep = step
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
let recoverOverflow = true
// Continuation rejection permits one immediate full-context Physical Attempt without generic backoff.
let recoverContinuation = true
while (true) {
const outcome = yield* callModel(
sessionID,
currentPromotable,
currentStep,
recoverOverflow,
recoverContinuation,
assistantMessageID,
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
@@ -232,6 +242,7 @@ const layer = Layer.effect(
if (outcome.recoveredOverflow) recoverOverflow = false
assistantMessageID = SessionMessage.ID.create()
}
if (outcome._tag === "RecoverFull") recoverContinuation = false
// Neither a retry nor a compaction restart re-promotes input.
currentPromotable = undefined
currentStep = outcome.step
@@ -247,6 +258,7 @@ const layer = Layer.effect(
promotable: SessionInbox.Promotable | undefined,
step: number,
recoverOverflow: boolean,
recoverContinuation: boolean,
assistantMessageID: SessionMessage.ID,
) {
const selected = yield* context.select(sessionID)
@@ -412,6 +424,13 @@ const layer = Layer.effect(
// escapes as a scheduled retry or fails the assistant durably.
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
if (
recoverContinuation &&
llmFailure?.reason._tag === "Transport" &&
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
!publisher.record().outputStarted
)
return CallOutcome.RecoverFull({ step: currentStep })
if (
llmFailure &&
llmError &&
@@ -500,14 +519,14 @@ const layer = Layer.effect(
/** Executes a previously admitted manual compaction request, if one is pending. */
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
sessionID: SessionSchema.ID,
promotable: SessionInbox.Promotable,
) {
return yield* Effect.uninterruptibleMask((restore) =>
Effect.gen(function* () {
const pending = yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const selected =
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (selected?.type !== "compaction") return
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
@@ -549,10 +568,9 @@ const layer = Layer.effect(
return yield* SessionInbox.serialized(
sessionID,
Effect.gen(function* () {
const pending =
(yield* SessionInbox.nextSteer(db, sessionID)) ??
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
if (pending?.type !== "move") return false
yield* modelTransport.close(sessionID)
yield* bus.publishAll([
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
[
@@ -623,6 +641,7 @@ export const node = makeLocationNode({
llmClient,
SessionContext.node,
SessionModelRequest.node,
SessionModelTransport.node,
SessionStore.node,
SessionCompaction.node,
SessionTitle.node,
@@ -1,95 +0,0 @@
You are opencode, an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- /help: Get help with using opencode
- To give feedback, users should report the issue at https://github.com/anomalyco/opencode/issues
When the user directly asks about opencode (eg 'can opencode do...', 'does opencode have...') or asks in second person (eg 'are you able...', 'can you do...'), first use the webfetch tool to gather information to answer the question from opencode docs at https://opencode.ai/v2/docs/
# Tone and style
You should be concise, direct, and to the point. When you run a non-trivial shell command, you should explain what the command does and why you are running it, to make sure the user understands what you are doing (this is especially important when you are running a command that will make changes to the user's system).
Remember that your output will be displayed on a command line interface. Your responses can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like the shell tool or code comments as means to communicate with the user during the session.
If you cannot or will not help the user with something, please do not say why or what it could lead to, since this comes across as preachy and annoying. Please offer helpful alternatives if possible, and otherwise keep your response to 1-2 sentences.
Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
IMPORTANT: You should minimize output tokens as much as possible while maintaining helpfulness, quality, and accuracy. Only address the specific query or task at hand, avoiding tangential information unless absolutely critical for completing the request. If you can answer in 1-3 sentences or a short paragraph, please do.
IMPORTANT: You should NOT answer with unnecessary preamble or postamble (such as explaining your code or summarizing your action), unless the user asks you to.
IMPORTANT: Keep your responses short, since they will be displayed on a command line interface. You MUST answer concisely with fewer than 4 lines (not including tool use or code generation), unless user asks for detail. Answer the user's question directly, without elaboration, explanation, or details. One word answers are best. Avoid introductions, conclusions, and explanations. You MUST avoid text before/after your response, such as "The answer is <answer>.", "Here is the content of the file..." or "Based on the information provided, the answer is..." or "Here is what I will do next...". Here are some examples to demonstrate appropriate verbosity:
<example>
user: what is 2+2?
assistant: 4
</example>
<example>
user: is 11 a prime number?
assistant: Yes
</example>
<example>
user: what command should I run to list files in the current directory?
assistant: ls
</example>
<example>
user: what command should I run to watch files in the current directory?
assistant: [use the read tool to list the files in the current directory, then read docs/commands in the relevant file to find out how to watch files]
npm run dev
</example>
<example>
user: what files are in the directory src/?
assistant: [uses read and sees foo.c, bar.c, baz.c]
user: which file contains the implementation of foo?
assistant: src/foo.c
</example>
<example>
user: write tests for new feature
assistant: [uses grep and glob search tools to find where similar tests are defined, uses concurrent read file tool use blocks in one tool call to read relevant files at the same time, uses edit file tool to write new tests]
</example>
# Proactiveness
You are allowed to be proactive, but only when the user asks you to do something. You should strive to strike a balance between:
1. Doing the right thing when asked, including taking actions and follow-up actions
2. Not surprising the user with actions you take without asking
For example, if the user asks you how to approach something, you should do your best to answer their question first, and not immediately jump into taking actions.
3. Do not add additional code explanation summary unless requested by the user. After working on a file, just stop, rather than providing an explanation of what you did.
# Following conventions
When making changes to files, first understand the file's code conventions. Mimic code style, use existing libraries and utilities, and follow existing patterns.
- NEVER assume that a given library is available, even if it is well known. Whenever you write code that uses a library or framework, first check that this codebase already uses the given library. For example, you might look at neighboring files, or check the package.json (or cargo.toml, and so on depending on the language).
- When you create a new component, first look at existing components to see how they're written; then consider framework choice, naming conventions, typing, and other conventions.
- When you edit a piece of code, first look at the code's surrounding context (especially its imports) to understand the code's choice of frameworks and libraries. Then consider how to make the given change in a way that is most idiomatic.
- Always follow security best practices. Never introduce code that exposes or logs secrets and keys. Never commit secrets or keys to the repository.
# Code style
- IMPORTANT: DO NOT ADD ***ANY*** COMMENTS unless asked
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- Use the available search tools to understand the codebase and the user's query. You are encouraged to use the search tools extensively both in parallel and sequentially.
- Implement the solution using all tools available to you
- Verify the solution if possible with tests. NEVER assume specific test framework or test script. Check the README or search codebase to determine the testing approach.
- VERY IMPORTANT: When you have completed a task, you MUST run the lint and typecheck commands (e.g. npm run lint, npm run typecheck, ruff, etc.) with the shell tool if they were provided to you to ensure your code is correct. If you are unable to find the correct command, ask the user for the command to run and if they supply it, proactively suggest writing it to AGENTS.md so that you will know to run it next time.
NEVER commit changes unless the user explicitly asks you to. It is VERY IMPORTANT to only commit when explicitly asked, otherwise the user will feel that you are being too proactive.
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are NOT part of the user's provided input or the tool result.
# Tool usage policy
- When doing file search, prefer to use the subagent tool in order to reduce context usage.
- You have the capability to call multiple tools in a single response. When multiple independent pieces of information are requested, batch your tool calls together for optimal performance. When making multiple shell tool calls, you MUST send a single message with multiple tools calls to run the calls in parallel. For example, if you need to run "git status" and "git diff", send a single message with two tool calls to run the calls in parallel.
You MUST answer concisely with fewer than 4 lines of text (not including tool use or code generation), unless user asks for detail.
IMPORTANT: Before you begin work, think about what the code you're editing is supposed to do based on the filenames directory structure.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
@@ -0,0 +1,14 @@
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
# Harness
- Responses are rendered as GitHub-flavored Markdown.
- `<system-reminder>` blocks are harness instructions, not user-authored content. Read and follow them.
${OPENCODE_TOOL_GUIDANCE}
# Communication
- Use clear file paths when referring to files.
- Keep responses clear and concise, and avoid unnecessary technical jargon.
# Working in codebases
- Keep changes consistent with the structure, naming, style, and patterns of the surrounding code.
- Treat unfamiliar files or changes as potential user work and investigate before deleting or overwriting them.
+2 -1
View File
@@ -18,8 +18,9 @@ export function isRetryable(error: AIError) {
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
case "Transport":
return true
case "Transport":
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
case "Authentication":
@@ -0,0 +1,24 @@
export * as SessionSystemPrompt from "./system-prompt.js"
import PROMPT from "./runner/prompt/system.txt"
export function make(tools: string[]) {
const instructions: string[] = []
if (tools.includes("write")) {
instructions.push(
"- Use the write tool to create files or completely replace their content. Prefer using the edit tool for targeted changes.",
)
}
if (tools.includes("edit")) {
instructions.push(
"- Use the edit tool for targeted changes to existing text files. It replaces the exact text in `oldString` with `newString`, and the values must differ. By default, `oldString` must occur exactly once. If it occurs multiple times, include more surrounding context to make it unique or set `replaceAll` to true to replace every occurrence.",
)
}
// if (tools.includes("patch")) {
// // instructions.push(...)
// }
if (tools.includes("read")) {
instructions.push("- Prefer using the read tool rather than shell commands like `cat`.")
}
return PROMPT.replace("${OPENCODE_TOOL_GUIDANCE}", instructions.join("\n"))
}
+423 -1
View File
@@ -9,6 +9,7 @@ import { shellParserWasm } from "#shell-parser-wasm"
import { ShellSelect } from "./select.js"
type Part = { type: string; text: string }
type SourceToken = { raw: string; value: string }
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
@@ -152,7 +153,17 @@ const ARITY: Record<string, number> = {
"yarn run": 3,
}
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
export const scan = Effect.fn("ShellParse.scan")(function* (
command: string,
shell: string,
cwd: string,
options?: { portable?: boolean },
) {
if (options?.portable) return yield* Effect.promise(() => scanPortable(command, shell, cwd))
return yield* scanLegacy(command, shell, cwd)
})
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
const parsers = yield* Effect.promise(load)
const powershell = ShellSelect.ps(shell)
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
@@ -186,6 +197,417 @@ export const scan = Effect.fn("ShellParse.scan")(function* (command: string, she
)
})
async function scanPortable(command: string, shell: string, cwd: string) {
const { ShellScan } = await import("@opencode-ai/shell-scan")
const powershell = ShellSelect.ps(shell)
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] }
const carriage = powershell ? command.search(/\r(?!\n)/) : -1
if (carriage >= 0) return { commands: [], directories: [] }
const parsed = result.commands.reduce(
(output, item) => {
const index = item[ShellScan.Nested] ? -1 : command.indexOf(item.resource, output.cursor)
const offset = item[ShellScan.Nested]
? command.lastIndexOf(item.resource, output.cursor - 1)
: index < 0
? command.indexOf(item.resource)
: index
if (index >= 0) output.cursor = index + item.resource.length
const before = command.slice(0, Math.max(0, offset))
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
if (!name) return output
if (powershell && name === "<") return output
if (
powershell &&
name === "foreach-object" &&
item.words.some((word) => word.startsWith("{")) &&
!/\|\s*$/.test(before)
)
return output
const tokens = powershell ? powerShellSourceTokens(item.resource) : sourceTokens(item.resource)
const sourceHead = powershell ? item.words[0] : tokens.find((token) => token.value === item.words[0])?.raw
if (CWD.has(name) && (powershell || sourceHead === item.words[0])) {
output.directories.push(...portableDirectoryArgs(item.words, tokens, powershell, cwd, shell))
return output
}
const save = powershell ? powerShellSourcePrefix(tokens, item.words) : bashSourcePrefix(tokens, item.words)
output.commands.push({
resource: powershell ? item.resource : bashResource(item.resource, before),
save: `${save} *`,
})
return output
},
{
commands: [] as Array<{ resource: string; save: string }>,
directories: [] as string[],
cursor: 0,
},
)
return { commands: parsed.commands, directories: parsed.directories }
}
function bashResource(resource: string, before: string) {
if (!/(?:&&|\|\||\|&)\s*$|\|\s*$/.test(before)) return resource
const redirect = bashRedirect(resource)
return redirect < 0 ? resource : resource.slice(0, redirect).replace(/\d+$/, "").trim()
}
function bashRedirect(resource: string) {
let quote: "single" | "double" | undefined
for (let index = 0; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (quote === "double") {
if (char === "$" && resource[index + 1] === "(") index = bashParenthesizedEnd(resource, index + 1)
else if (char === "`") index = bashBacktickEnd(resource, index)
continue
}
if (char === "'") {
quote = "single"
continue
}
if ((char === "$" || char === "<" || char === ">") && resource[index + 1] === "(") {
index = bashParenthesizedEnd(resource, index + 1)
continue
}
if (char === "`") {
index = bashBacktickEnd(resource, index)
continue
}
if (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">")) return index
}
return -1
}
function bashParenthesizedEnd(resource: string, start: number) {
let level = 1
let quote: "single" | "double" | undefined
for (let index = start + 1; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
if (char === "'") quote = undefined
continue
}
if (char === "\\") {
index++
continue
}
if (char === '"') {
quote = quote === "double" ? undefined : "double"
continue
}
if (quote === "double") continue
if (char === "'") {
quote = "single"
continue
}
if (char === "(") level++
if (char === ")" && --level === 0) return index
}
return resource.length - 1
}
function bashBacktickEnd(resource: string, start: number) {
for (let index = start + 1; index < resource.length; index++) {
if (resource[index] === "\\") index++
else if (resource[index] === "`") return index
}
return resource.length - 1
}
function portableDirectoryArgs(
command: string[],
tokens: SourceToken[],
powershell: boolean,
cwd: string,
shell: string,
) {
if (!powershell) {
const start = tokens.findIndex((token) => token.value === command[0])
if (start < 0) return []
return directoryArgs(
tokens.slice(start).map((token) => ({ type: "word", text: token.raw })),
false,
cwd,
shell,
)
}
const start = tokens.findIndex((token) => token.value.toLowerCase() === command[0]?.toLowerCase())
if (start < 0) return []
const directories: string[] = []
let expectsPath = false
for (const part of tokens.slice(start + 1).map((token) => token.raw)) {
if (expectsPath) {
const value = directoryArgument(part, true, cwd, shell)
if (value) directories.push(value)
expectsPath = false
continue
}
if (part.startsWith("-")) {
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
continue
}
const value = directoryArgument(part, true, cwd, shell)
if (value) directories.push(value)
}
return directories
}
function sourceTokens(resource: string) {
const tokens: SourceToken[] = []
let raw = ""
let value = ""
let quote: "single" | "double" | "backtick" | undefined
let substitution = 0
let redirect = false
const finish = () => {
if (!raw) return
if (!redirect) tokens.push({ raw, value })
raw = ""
value = ""
redirect = false
}
for (let index = 0; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
raw += char
if (char === "'") quote = undefined
else value += char
continue
}
if (quote === "double") {
raw += char
if (char === '"') quote = undefined
else if (char === "\\" && index + 1 < resource.length) {
const next = resource[index + 1]
if ('$`"\\\n'.includes(next)) {
raw += resource[++index]
if (next !== "\n") value += next
} else value += char
} else value += char
continue
}
if (quote === "backtick") {
raw += char
value += char
if (char === "`" && resource[index - 1] !== "\\") quote = undefined
continue
}
if (char === "'") {
raw += char
quote = "single"
continue
}
if (char === '"') {
raw += char
quote = "double"
continue
}
if (char === "`") {
raw += char
value += char
quote = "backtick"
continue
}
if (char === "\\" && index + 1 < resource.length) {
if (resource[index + 1] === "\n") {
finish()
index++
continue
}
if (!raw && /\s/.test(resource[index + 1])) {
index++
continue
}
raw += char + resource[++index]
value += resource[index]
continue
}
if ((char === "<" || char === ">") && resource[index + 1] === "(") {
const end = bashParenthesizedEnd(resource, index + 1)
if (raw) {
raw += resource.slice(index, end + 1)
value += resource.slice(index, end + 1)
}
index = end
continue
}
if (char === "$" && resource[index + 1] === "(") substitution++
if (char === ")" && substitution > 0) substitution--
if (substitution === 0 && /\s/.test(char)) {
finish()
continue
}
if (substitution === 0 && (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">"))) {
if (/^\d+$/.test(value)) {
raw = ""
value = ""
} else finish()
redirect = true
if (char === "&") index++
while (/[<>&|]/.test(resource[index + 1] ?? "")) index++
continue
}
raw += char
value += char
}
finish()
return tokens
}
function bashSourcePrefix(tokens: SourceToken[], words: string[]) {
const start = tokens.findIndex((token) => token.value === words[0])
if (start < 0) {
const command = tokens.findIndex((token) => !/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token.raw))
return prefix(tokens.slice(Math.max(0, command)).map((token) => token.raw)).join(" ")
}
const source = tokens
.slice(start)
.map((token) => token.raw)
.filter((token) => !/^\$\([\s\S]*\)$/.test(token) && !/^`[\s\S]*`$/.test(token))
return prefix(source).join(" ")
}
function powerShellSourcePrefix(tokens: SourceToken[], words: string[]) {
const start = tokens.findIndex((token) => token.value.toLowerCase() === words[0]?.toLowerCase())
if (start < 0) return prefix(words).join(" ")
return prefix(tokens.slice(start).map((token) => token.raw)).join(" ")
}
function powerShellSourceTokens(resource: string) {
const tokens: SourceToken[] = []
let raw = ""
let value = ""
let quote: "single" | "double" | undefined
let redirect = false
const finish = () => {
if (!raw) return
if (!redirect) tokens.push({ raw, value })
raw = ""
value = ""
redirect = false
}
for (let index = 0; index < resource.length; index++) {
const char = resource[index]
if (quote === "single") {
raw += char
if (char === "'" && resource[index + 1] === "'") {
raw += resource[++index]
value += "'"
} else if (char === "'") quote = undefined
else value += char
continue
}
if (quote === "double") {
raw += char
if (char === '"') quote = undefined
else if (char === "`" && index + 1 < resource.length) {
raw += resource[++index]
value += resource[index]
} else value += char
continue
}
if (char === "'") {
raw += char
quote = "single"
continue
}
if (char === '"') {
raw += char
quote = "double"
continue
}
if (char === "`" && index + 1 < resource.length) {
raw += char + resource[++index]
if (resource[index] !== "\n" && resource[index] !== "\r") value += resource[index]
continue
}
if (char === "{" && !raw) {
const end = powerShellBracedEnd(resource, index)
raw = resource.slice(index, end + 1)
value = raw
index = end
continue
}
if (/\s/.test(char)) {
finish()
continue
}
if (char === ">") {
if (resource[index + 1] && !/[\s>&]/.test(resource[index + 1])) {
raw += char
value += char
continue
}
if (/^\d+$/.test(value)) {
raw = ""
value = ""
} else if (raw === "*") {
raw = ""
value = ""
} else finish()
redirect = true
while (/[>&\d]/.test(resource[index + 1] ?? "")) index++
continue
}
if ((char === "&" || char === ".") && !raw && tokens.length === 0) continue
raw += char
value += char
}
finish()
return tokens
}
function powerShellBracedEnd(resource: string, start: number) {
let level = 1
let quote: "single" | "double" | undefined
for (let index = start + 1; index < resource.length; index++) {
const char = resource[index]
if (char === "`" && quote !== "single") {
index++
continue
}
if (quote === "single") {
if (char === "'" && resource[index + 1] === "'") index++
else if (char === "'") quote = undefined
continue
}
if (quote === "double") {
if (char === '"') quote = undefined
continue
}
if (char === "'") {
quote = "single"
continue
}
if (char === '"') {
quote = "double"
continue
}
if (char === "{") level++
if (char === "}" && --level === 0) return index
}
return resource.length - 1
}
function parts(node: Node) {
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
const child = node.child(index)
+5 -1
View File
@@ -163,7 +163,11 @@ export const Plugin = {
invocation.cwd = target.absolute
finalTimeout = invocation.timeout
if (!unrestricted) {
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
const portable =
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
portable,
})
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
)
+24 -10
View File
@@ -4,8 +4,8 @@ import type { Context as PluginContext } from "@opencode-ai/plugin/effect/plugin
import { ToolFailure } from "@opencode-ai/ai"
import { Effect, Schema, Semaphore } from "effect"
import { HttpClientError } from "effect/unstable/http"
import { Config } from "../../config.js"
import { Form } from "../../form.js"
import { KV } from "../../kv.js"
import { Permission } from "../../permission.js"
import { WebSearch } from "../../websearch.js"
@@ -30,7 +30,7 @@ export const Plugin = {
effect: Effect.fn("WebSearchTool.Plugin")(function* (ctx: PluginContext) {
const permission = yield* Permission.Service
const forms = yield* Form.Service
const kv = yield* KV.Service
const config = yield* Config.Service
const websearch = yield* WebSearch.Service
yield* ctx.tool
@@ -65,7 +65,7 @@ export const Plugin = {
return providerSelectionLock
.withPermit(
Effect.gen(function* () {
if (yield* websearch.default()) return yield* Effect.void
if (yield* websearch.default()) return
const providers = (yield* ctx.websearch.providers()).data
const defaultProvider = providers[0]
if (!defaultProvider) return yield* new WebSearch.ProviderRequiredError()
@@ -83,7 +83,7 @@ export const Plugin = {
options: [
{
value: "allow",
label: `Allow web search via ${defaultProvider.name}`,
label: `Allow search via ${providers.map((provider) => provider.name).join(", ")}`,
},
{
value: "choose",
@@ -97,7 +97,9 @@ export const Plugin = {
if (response.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
if (response.answer.choice === "disable") {
yield* kv.set("websearch:provider", false)
yield* config.update((draft) => {
draft.websearch = false
})
return yield* new WebSearch.DisabledError()
}
const selection =
@@ -123,13 +125,19 @@ export const Plugin = {
: undefined
if (selection?.status === "cancelled")
return yield* Effect.fail(new Error("Web search cancelled"))
const providerID = selection?.answer.provider ?? defaultProvider.id
const providerID = selection?.answer.provider ?? "random"
if (
typeof providerID !== "string" ||
!providers.some((provider) => provider.id === providerID)
(providerID !== "random" && !providers.some((provider) => provider.id === providerID))
)
return yield* new WebSearch.ProviderRequiredError()
return yield* kv.set("websearch:provider", providerID)
yield* config.update((draft) => {
draft.websearch = {
provider: providerID === "random" ? "random" : WebSearch.ID.make(providerID),
}
})
if (providerID !== "random") return WebSearch.ID.make(providerID)
return providers[Math.floor(Math.random() * providers.length)]?.id
}),
)
.pipe(
@@ -137,7 +145,12 @@ export const Plugin = {
duration: "1 minute",
orElse: () => Effect.fail(new Error("Web search cancelled")),
}),
Effect.andThen(Effect.suspend(search)),
Effect.flatMap((providerID) => {
if (!providerID) return Effect.suspend(search)
return context
.progress({ provider: providerID })
.pipe(Effect.andThen(ctx.websearch.query({ ...input, providerID })))
}),
)
}),
)
@@ -193,7 +206,8 @@ export const Plugin = {
yield* ctx.session.hook("context", (event) =>
Effect.gen(function* () {
if ((yield* kv.get("websearch:provider")) === false) delete event.tools[name]
const disabled = Config.latest(yield* config.entries(), "websearch") === false
if (disabled) delete event.tools[name]
}),
)
}),
+12 -14
View File
@@ -4,7 +4,6 @@ import { WebSearch } from "@opencode-ai/schema/websearch"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { Bus } from "./bus.js"
import { KV } from "./kv.js"
import { State } from "./state.js"
export const ID = WebSearch.ID
@@ -60,14 +59,14 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/We
type Data = {
readonly providers: Map<ID, ProviderImplementation>
defaultProviderID?: ID
selection?: ID | "random" | false
}
export type Draft = {
add: (provider: ProviderImplementation) => void
default: {
get: () => ID | undefined
set: (providerID: ID) => void
get: () => ID | "random" | false | undefined
set: (selection: ID | "random" | false) => void
}
}
@@ -75,15 +74,14 @@ const layer = Layer.effect(
Service,
Effect.gen(function* () {
const bus = yield* Bus.Service
const kv = yield* KV.Service
const decodeResults = Schema.decodeUnknownEffect(Schema.Array(Result))
const state = State.create<Data, Draft>({
initial: () => ({ providers: new Map() }),
draft: (draft) => ({
add: (provider) => draft.providers.set(provider.id, provider),
default: {
get: () => draft.defaultProviderID,
set: (providerID) => (draft.defaultProviderID = providerID),
get: () => draft.selection,
set: (selection) => (draft.selection = selection),
},
}),
finalize: () => bus.publish(WebSearch.Event.Updated, {}).pipe(Effect.asVoid),
@@ -96,12 +94,12 @@ const layer = Layer.effect(
const defaultProvider = Effect.fn("WebSearch.default")(function* () {
const data = state.get()
const configured = data.defaultProviderID ? data.providers.get(data.defaultProviderID) : undefined
if (configured) return configured
const stored = yield* kv.get("websearch:provider")
if (stored === false) return yield* new DisabledError()
if (typeof stored !== "string") return
return data.providers.get(ID.make(stored))
if (data.selection === false) return yield* new DisabledError()
if (data.selection === "random") {
const providers = Array.from(data.providers.values())
return providers[Math.floor(Math.random() * providers.length)]
}
return data.selection ? data.providers.get(data.selection) : undefined
})
const resolve = Effect.fn("WebSearch.resolve")(function* (input: Input) {
@@ -140,5 +138,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer,
deps: [Bus.node, KV.node],
deps: [Bus.node],
})
+2 -2
View File
@@ -468,7 +468,7 @@ it.effect("derives status and code when the AI SDK error message is empty", () =
}),
)
it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
it.effect("preserves complete HTTP context on AI SDK call errors", () =>
Effect.gen(function* () {
const error = yield* streamFailure(
apiCallError({
@@ -480,7 +480,7 @@ it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
const http = "http" in error.reason ? error.reason.http : undefined
expect(http?.request.url).toBe("https://api.example.com/chat")
expect(http?.response?.status).toBe(404)
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
expect(http?.response?.headers["authorization"]).toBe("Bearer secret-token")
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
}),
)
+53 -5
View File
@@ -72,6 +72,53 @@ const provider = {
}
describe("Config", () => {
it.live("updates the first file-backed document", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) => {
const global = path.join(tmp.path, "global")
const project = path.join(tmp.path, "project")
const globalFile = path.join(global, "opencode.jsonc")
const projectFile = path.join(project, "opencode.json")
return Effect.promise(async () => {
await Promise.all([fs.mkdir(global, { recursive: true }), fs.mkdir(project, { recursive: true })])
await Promise.all([
fs.writeFile(globalFile, '{\n // Keep this comment.\n "shell": "global"\n}\n'),
fs.writeFile(projectFile, JSON.stringify({ shell: "project" })),
])
}).pipe(
Effect.andThen(
Effect.gen(function* () {
const config = yield* Config.Service
const updated = yield* config.update((draft) => {
draft.shell = "updated"
})
expect(updated.shell).toBe("updated")
expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain("// Keep this comment.")
expect(yield* Effect.promise(() => fs.readFile(globalFile, "utf8"))).toContain('"shell": "updated"')
expect(JSON.parse(yield* Effect.promise(() => fs.readFile(projectFile, "utf8")))).toEqual({
shell: "project",
})
}).pipe(Effect.provide(testLayer(project, global))),
),
)
}),
),
)
it.effect("fails updates when no file-backed document exists", () =>
Effect.gen(function* () {
const config = yield* Config.Service
const error = yield* config.update((draft) => void draft).pipe(Effect.flip)
expect(error.message).toBe("No editable config document found")
}).pipe(
Effect.provide(Config.testLayer([new Document({ type: "document", info: new Info({ shell: "virtual" }) })])),
),
)
it.live("loads explicit file and content overrides in priority order", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
@@ -858,7 +905,7 @@ describe("Config", () => {
expect(documents.map((document) => document.type)).toEqual(["document", "document"])
expect(documents.map((document) => document.info.$schema)).toEqual(["base", "last"])
expect(documents[0]).toBeInstanceOf(Document)
expect(documents[0]?.path).toBe(path.join(tmp.path, "opencode.json"))
expect(documents[0]?.path).toBe(AbsolutePath.make(path.join(tmp.path, "opencode.json")))
expect(documents[1]?.info.providers?.last).toBeInstanceOf(ConfigProvider.Info)
yield* Effect.promise(() =>
@@ -1405,9 +1452,14 @@ describe("Config", () => {
)
return yield* Effect.gen(function* () {
const config = yield* Config.Service
const watcher = yield* Watcher.Test
const documents = (yield* config.entries()).filter((entry) => entry.type === "document")
expect(documents.map((document) => document.info.$schema)).toEqual(["base"])
expect(yield* watcher.subscriptions()).toContainEqual({
path: path.join(tmp.path, "opencode.jsonc"),
type: "file",
})
}).pipe(Effect.provide(testLayer(tmp.path)))
}),
),
@@ -1491,13 +1543,9 @@ describe("Config", () => {
"global",
AbsolutePath.make(global),
"outside",
AbsolutePath.make(path.join(tmp.path, "opencode.json")),
"root",
AbsolutePath.make(path.join(root, "opencode.json")),
"parent",
AbsolutePath.make(path.join(parent, "opencode.jsonc")),
"directory",
AbsolutePath.make(path.join(directory, "opencode.json")),
"root-dot",
AbsolutePath.make(path.join(root, ".opencode")),
"directory-dot",
@@ -393,11 +393,13 @@ describe("ConfigNormalize", () => {
enabled_providers: ["anthropic"],
disabled_providers: ["openai"],
experimental: {
portable_shell_scanner: true,
subagent_depth: 0,
policies: [{ action: "provider.use", resource: "custom", effect: "allow" }],
},
}).encoded.experimental,
).toEqual({
portable_shell_scanner: true,
subagent_depth: 0,
policies: [
{ action: "provider.use", resource: "*", effect: "deny" },
+2 -1
View File
@@ -18,6 +18,7 @@ import { Provider } from "@opencode-ai/core/provider"
import { Reference } from "@opencode-ai/core/reference"
import { Skill } from "@opencode-ai/core/skill"
import { Effect, Schema } from "effect"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "../plugin/fixture"
@@ -79,7 +80,7 @@ describe("config plugin reloads", () => {
function config(name: string) {
return new Document({
type: "document",
path: document,
path: AbsolutePath.make(document),
info: decode({
agents: { [name]: { description: `${title(name)} agent`, mode: "subagent" } },
commands: { [name]: { template: `${title(name)} command`, description: `${title(name)} command` } },
@@ -3,8 +3,7 @@ import type { DurableObjectStorage } from "@opencode-ai/core/database/sqlite.wor
// Emulates the Durable Object storage API over bun:sqlite so the workerd
// adapter and the workerd server profile can be verified without workerd or
// Cloudflare runtime dependencies. The real runtime is covered by the
// workerd-spike package, which boots inside an actual isolate.
// Cloudflare runtime dependencies.
export const makeDurableObjectStorage = (): DurableObjectStorage => {
const native = new Database(":memory:")
const toSqlStorageValue = (value: unknown) => {
+2 -11
View File
@@ -1,7 +1,7 @@
import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer, Schema, Stream } from "effect"
import { Effect, Layer, Schema } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Npm } from "@opencode-ai/util/npm"
@@ -27,16 +27,7 @@ function formatterLayer(directory: string, configured?: ConfigInput["formatter"]
}),
]
return AppNodeBuilder.build(Formatter.node, [
[
Config.node,
Layer.succeed(
Config.Service,
Config.Service.of({
entries: () => Effect.succeed(entries),
changes: () => Stream.empty,
}),
),
],
[Config.node, Config.testLayer(entries)],
[
Location.node,
Layer.succeed(Location.Service, Location.Service.of(location({ directory: AbsolutePath.make(directory) }))),
@@ -0,0 +1,71 @@
import { Buffer } from "node:buffer"
import { Effect } from "effect"
interface ConnectionData {
readonly id: number
}
export interface WebSocketServerState {
readonly headers: Array<Record<string, string>>
readonly messages: string[]
opens: number
closes: number
pongs: number
}
export interface WebSocketServerFixture {
readonly url: string
readonly state: WebSocketServerState
}
export interface WebSocketServerOptions {
readonly upgrade?: (request: Request) => boolean
readonly open?: (socket: Bun.ServerWebSocket<ConnectionData>) => void
readonly message?: (socket: Bun.ServerWebSocket<ConnectionData>, message: string | Buffer) => void
}
export const makeWebSocketServer = (options: WebSocketServerOptions = {}) =>
Effect.acquireRelease(
Effect.sync(() => {
const state: WebSocketServerState = { headers: [], messages: [], opens: 0, closes: 0, pongs: 0 }
let connection = 0
const server = Bun.serve<ConnectionData>({
hostname: "127.0.0.1",
port: 0,
fetch(request, server) {
state.headers.push(Object.fromEntries(request.headers.entries()))
if ((options.upgrade?.(request) ?? true) && server.upgrade(request, { data: { id: connection++ } }))
return undefined
return new Response("WebSocket upgrade required", {
status: 426,
headers: { "x-upgrade-rejected": "true" },
})
},
websocket: {
open(socket) {
state.opens++
options.open?.(socket)
},
message(socket, message) {
const text = typeof message === "string" ? message : message.toString()
state.messages.push(text)
options.message?.(socket, message)
},
close() {
state.closes++
},
pong() {
state.pongs++
},
},
})
return {
server,
fixture: {
url: `${server.url.toString().replace(/^http/, "ws")}responses`,
state,
} satisfies WebSocketServerFixture,
}
}),
({ server }) => Effect.promise(() => server.stop(true)),
).pipe(Effect.map((item) => item.fixture))
+5 -1
View File
@@ -185,7 +185,11 @@ function resourceMcpLayer(
overrides?.entries
? Layer.succeed(
Config.Service,
Config.Service.of({ entries: overrides.entries, changes: () => Stream.never }),
Config.Service.of({
entries: overrides.entries,
update: () => Effect.die("unused config update"),
changes: () => Stream.never,
}),
)
: Config.testLayer([
new Document({
+4 -1
View File
@@ -388,7 +388,10 @@ export function webSearchHost(websearch: WebSearch.Interface): Plugin.Context["w
}),
default: {
get: draft.default.get,
set: (providerID) => draft.default.set(WebSearch.ID.make(providerID)),
set: (selection) =>
draft.default.set(
selection === false || selection === "random" ? selection : WebSearch.ID.make(selection),
),
},
})
}),
@@ -1,6 +1,4 @@
import { Money } from "@opencode-ai/schema/money"
import { Agent } from "@opencode-ai/schema/agent"
import { Session } from "@opencode-ai/schema/session"
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { Catalog } from "@opencode-ai/core/catalog"
@@ -29,14 +27,9 @@ function required<T>(value: T | undefined): T {
return value
}
const http = Effect.fn(function* (providerID: Provider.ID, url: string) {
const event = yield* (yield* PluginHooks.Service).trigger("session", "http.request", {
sessionID: Session.ID.make("ses_test"),
agent: Agent.ID.make("build"),
model: Model.Ref.make({ providerID, id: Model.ID.make("gpt-5.5") }),
request: new Request(url, { method: "POST", body: "{}" }),
})
return { url: event.request.url, headers: Object.fromEntries(event.request.headers.entries()) }
const httpMiddlewareCount = Effect.fn(function* () {
const hooks = yield* PluginHooks.Service
return Number(yield* hooks.has("session", "http.request")) + Number(yield* hooks.has("session", "http.response"))
})
describe("OpenAIPlugin", () => {
@@ -110,19 +103,11 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const custom = yield* http(Provider.ID.make("custom-openai"), "https://custom.example/v1/responses")
const proxy = yield* http(Provider.ID.openai, "https://proxy.example/v1/responses?region=us")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
expect(provider.package).toBe("@opencode-ai/ai/providers/openai")
expect(provider.settings).toMatchObject({ baseURL: "https://chatgpt.com/backend-api/codex" })
expect(provider.headers).toMatchObject({ "chatgpt-account-id": "acct_123" })
expect(request.url).toBe("https://chatgpt.com/backend-api/codex/responses")
expect(request.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(custom.headers).not.toHaveProperty("originator")
expect(proxy.url).toBe("https://proxy.example/v1/responses?region=us")
expect(proxy.headers).toMatchObject({ originator: "opencode", "session-id": "ses_test" })
expect(provider.headers).toMatchObject({ originator: "opencode", "chatgpt-account-id": "acct_123" })
expect(yield* httpMiddlewareCount()).toBe(0)
const eligible = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(eligible.package).toBe("@opencode-ai/ai/providers/openai")
expect(eligible.cost).toEqual([])
@@ -166,13 +151,13 @@ describe("OpenAIPlugin", () => {
})
yield* addPlugin()
const request = yield* http(Provider.ID.openai, "https://api.openai.com/v1/responses")
const provider = required(yield* catalog.provider.get(Provider.ID.openai))
const model = required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-5.5")))
expect(model.package).toBe("@opencode-ai/ai/providers/openai")
expect(model.enabled).toBe(true)
expect(model.limit).toEqual({ context: 1_050_000, input: 922_000, output: 128_000 })
expect(request.headers).not.toHaveProperty("originator")
expect(provider.headers).not.toHaveProperty("originator")
expect(yield* httpMiddlewareCount()).toBe(0)
expect(required(yield* catalog.model.get(Provider.ID.openai, Model.ID.make("gpt-4.1"))).enabled).toBe(true)
}),
)
@@ -7,6 +7,7 @@ import { PluginHooks } from "@opencode-ai/core/plugin/hooks"
import { PluginHost } from "@opencode-ai/core/plugin/host"
import { SystemPromptPlugin } from "@opencode-ai/core/plugin/system-prompt"
import { Session } from "@opencode-ai/core/session"
import { SessionSystemPrompt } from "@opencode-ai/core/session/system-prompt"
import type { SessionHooks } from "@opencode-ai/plugin/effect/session"
import { Model } from "@opencode-ai/schema/model"
import { Provider } from "@opencode-ai/schema/provider"
@@ -14,10 +15,9 @@ import { Effect } from "effect"
import { testEffect } from "../lib/effect"
import { PluginTestLayer } from "./fixture"
import PROMPT_META from "../../src/plugin/system-prompt/meta.txt"
import PROMPT_DEFAULT from "../../src/session/runner/prompt/base.txt"
const it = testEffect(PluginTestLayer)
const fallback = PROMPT_DEFAULT
const fallback = SessionSystemPrompt.make([])
const makeHost = Effect.gen(function* () {
const agents = yield* Agent.Service
const plugins = yield* Plugin.Service
@@ -74,7 +74,7 @@ describe("SystemPromptPlugin", () => {
["kimi-k2", "# Prompt and Tool Use"],
["trinity", "what command should I run to list files"],
["meta/muse-spark-1.1", "powered by Muse Spark"],
["llama-3.3", "You are opencode, an interactive CLI tool"],
["llama-3.3", fallback],
] as const
yield* Effect.forEach(
@@ -5,6 +5,7 @@ import { WebSearch } from "@opencode-ai/core/websearch"
import { WebSearchExa } from "@opencode-ai/core/plugin/websearch/exa"
import { WebSearchFirecrawl } from "@opencode-ai/core/plugin/websearch/firecrawl"
import { WebSearchParallel } from "@opencode-ai/core/plugin/websearch/parallel"
import { WebSearchTavily } from "@opencode-ai/core/plugin/websearch/tavily"
import { host, integrationHost, webSearchHost } from "./host"
import { requests, resetWebSearchFixture, webSearchIntegrationTest } from "./websearch-fixture"
@@ -190,4 +191,71 @@ describe("built-in web search providers", () => {
expect(JSON.stringify(output)).not.toContain("parallel-secret")
}),
)
it.effect("registers Tavily with keyless and keyed Search API access", () =>
Effect.gen(function* () {
resetWebSearchFixture(
JSON.stringify({
query: "effect typescript",
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
score: 0.99,
},
],
}),
)
const integrations = yield* Integration.Service
const websearch = yield* WebSearch.Service
yield* WebSearchTavily.Plugin.effect(
host({ integration: integrationHost(integrations), websearch: webSearchHost(websearch) }),
)
expect(yield* integrations.get(Integration.ID.make("tavily"))).toMatchObject({
id: "tavily",
name: "Tavily",
methods: [{ type: "key" }, { type: "env", names: ["TAVILY_API_KEY"] }],
})
const query = {
query: "effect typescript",
providerID: WebSearch.ID.make("tavily"),
}
expect(yield* websearch.query(query)).toEqual(
new WebSearch.Response({
providerID: WebSearch.ID.make("tavily"),
results: [
{
url: "https://effect.website",
title: "Effect",
content: "Effect documentation",
time: {},
},
],
}),
)
expect(requests[0]).toMatchObject({
url: WebSearchTavily.endpoint,
headers: { "x-client-name": "opencode2", "x-tavily-access-mode": "keyless" },
body: {
query: "effect typescript",
search_depth: "basic",
chunks_per_source: 3,
max_results: 8,
},
})
expect(requests[0]?.headers.authorization).toBeUndefined()
yield* integrations.connection.key({
integrationID: Integration.ID.make("tavily"),
key: "tavily-secret",
})
yield* websearch.query(query)
expect(requests[1]).toMatchObject({
headers: { authorization: "Bearer tavily-secret", "x-client-name": "opencode2" },
})
expect(requests[1]?.headers["x-tavily-access-mode"]).toBeUndefined()
}),
)
})

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