mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 07:48:24 -04:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d0b1b3518 | |||
| eb2c80fa23 | |||
| 3f2031a325 | |||
| 014a364dfd | |||
| a45b12cfa4 | |||
| 7484e32f62 | |||
| 650f7e8cdd | |||
| 5c7f5840ee | |||
| 08a6d7b619 | |||
| 1580e7cc3a | |||
| 8640ea3374 | |||
| e6a3b951b5 | |||
| 62b67f2761 | |||
| 182fe17f12 | |||
| b6ea0c2209 | |||
| f7b222e95a | |||
| a7288d231e | |||
| f75244d795 | |||
| 1d44d56d9c | |||
| 9adc9bb5df | |||
| a609acbb67 | |||
| f4620939aa | |||
| 23258ada86 | |||
| 2ca6d8ee49 | |||
| d6c299cee7 | |||
| 737b6eb7b7 | |||
| 923cb28a0a | |||
| e79f3e8ca2 | |||
| 638cb414c4 | |||
| 445aacf92e | |||
| a2a53032d4 | |||
| 810156d081 | |||
| c0ae362996 | |||
| de32afe358 | |||
| 2d953a69de | |||
| e3bbfdf270 | |||
| 6d0c30a125 | |||
| 1cf97fac5c | |||
| f7d21babb6 | |||
| b0480a6f93 | |||
| bdbccca1f3 | |||
| 3d3bbd48f9 | |||
| 51f0b499f9 | |||
| 9872fb8a54 | |||
| baf5ce3b7e | |||
| 70efd3fb38 |
@@ -0,0 +1,8 @@
|
|||||||
|
---
|
||||||
|
"@opencode-ai/core": minor
|
||||||
|
"@opencode-ai/schema": minor
|
||||||
|
"@opencode-ai/protocol": minor
|
||||||
|
"@opencode-ai/client": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Remove the unused question request API and use session forms for question tool interactions.
|
||||||
@@ -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.
|
||||||
@@ -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 }}
|
|
||||||
@@ -72,7 +72,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
run: GITHUB_ACTIONS=false bun turbo test
|
||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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.
|
- 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.
|
- 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
|
## 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/`.
|
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
|
## 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 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.
|
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||||
- Reusing a Session ID adopts the existing Session. Reusing a 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.
|
- 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 `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.
|
- 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.
|
- 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. 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 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 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.
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -27,8 +27,9 @@ exits before expensive server boot. The design does not require clients to
|
|||||||
agree on a single initiator.
|
agree on a single initiator.
|
||||||
|
|
||||||
This proposal does not introduce a supervisor process, warm candidate server,
|
This proposal does not introduce a supervisor process, warm candidate server,
|
||||||
protocol negotiation, idle background restart, or general execution-recovery
|
protocol negotiation, idle background restart, or clustered or exactly-once
|
||||||
framework.
|
execution recovery. Session execution separately provides bounded local recovery
|
||||||
|
through durable write-ahead claims.
|
||||||
|
|
||||||
## Architecture at a Glance
|
## Architecture at a Glance
|
||||||
|
|
||||||
@@ -176,9 +177,9 @@ This design gives each concept one authority.
|
|||||||
- Adding a permanent steward, proxy, or supervisor process.
|
- Adding a permanent steward, proxy, or supervisor process.
|
||||||
- Zero-downtime worker handoff or automatic rollback.
|
- Zero-downtime worker handoff or automatic rollback.
|
||||||
- Application protocol negotiation or automatic TUI self-restart.
|
- Application protocol negotiation or automatic TUI self-restart.
|
||||||
- General hard-crash recovery for active Sessions.
|
- Exactly-once recovery for provider attempts, tools, shells, sub-agents,
|
||||||
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
|
permissions, questions, or background jobs. Top-level Session continuation
|
||||||
permissions, questions, or background jobs.
|
after process death is handled separately through durable execution claims.
|
||||||
- Automatically killing a frozen owner.
|
- Automatically killing a frozen owner.
|
||||||
- Bounding concurrent location cold boots after clients reconnect.
|
- Bounding concurrent location cold boots after clients reconnect.
|
||||||
- Multi-machine or clustered service placement.
|
- Multi-machine or clustered service placement.
|
||||||
@@ -202,9 +203,10 @@ This design gives each concept one authority.
|
|||||||
diagnosed, non-retryable cause.
|
diagnosed, non-retryable cause.
|
||||||
8. **Clients do not kill an unresponsive owner automatically.** Destructive
|
8. **Clients do not kill an unresponsive owner automatically.** Destructive
|
||||||
recovery requires the explicit `service restart` command.
|
recovery requires the explicit `service restart` command.
|
||||||
9. **Lifecycle does not promise execution semantics.** Graceful replacement
|
9. **Lifecycle does not promise exactly-once execution.** A successor invokes
|
||||||
invokes Session suspension and resumption hooks, but tool-level continuity
|
the Session execution-claim sweep, which resumes from durable history.
|
||||||
belongs to a separate design.
|
Provider-attempt identity and tool-side-effect fencing belong to separate
|
||||||
|
designs.
|
||||||
|
|
||||||
## System Model
|
## System Model
|
||||||
|
|
||||||
@@ -485,23 +487,20 @@ The UI derives text from status:
|
|||||||
| `ready` | Normal TUI |
|
| `ready` | Normal TUI |
|
||||||
|
|
||||||
## Session Continuity
|
## Session Continuity
|
||||||
|
|
||||||
Every process-local Session busy period writes a durable execution claim before
|
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
|
Recovery fails stale running tool projections before further model work, but it
|
||||||
2. The successor schedules those Sessions for continuation.
|
does not prove whether an interrupted provider request or external operation
|
||||||
3. The runner reloads durable Session history before continuing.
|
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
|
||||||
This lifecycle design does not define what an interrupted physical provider
|
provider or tool behavior.
|
||||||
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.
|
|
||||||
|
|
||||||
## Unresponsive Owner
|
## Unresponsive Owner
|
||||||
|
|
||||||
An unreachable registration does not prove that the owner is dead. A contender
|
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.
|
1. The old service installs vNext but keeps running.
|
||||||
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
|
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
|
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.
|
5. One or more clients spawn contenders.
|
||||||
6. One contender acquires the service lock. Losers exit before heavy boot.
|
6. One contender acquires the service lock. Losers exit before heavy boot.
|
||||||
7. The winner binds and registers the lifecycle shell as `starting`.
|
7. The winner binds and registers the lifecycle shell as `starting`.
|
||||||
8. Clients stop spawning and wait on the observable winner.
|
8. Clients stop spawning and wait on the observable winner.
|
||||||
9. The winner initializes the application, sweeps orphaned execution claims,
|
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
|
### Server crashes while ready
|
||||||
|
|
||||||
@@ -546,7 +547,9 @@ Automatic frozen-owner recovery is deferred.
|
|||||||
3. Process death has released the service lock.
|
3. Process death has released the service lock.
|
||||||
4. One contender wins, replaces registration, and starts normally.
|
4. One contender wins, replaces registration, and starts normally.
|
||||||
5. Application startup sweeps orphaned top-level execution claims and resumes
|
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
|
### Winner crashes during startup
|
||||||
|
|
||||||
1. Clients observed `starting` and remain alive.
|
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;
|
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
|
||||||
reconnect never activates replacement.
|
reconnect never activates replacement.
|
||||||
7. **Integrate Session continuity.** Preserve current background-install and
|
7. **Integrate Session continuity.** Preserve current background-install and
|
||||||
fresh-launch activation behavior while invoking Session continuity hooks.
|
fresh-launch activation behavior while invoking startup execution-claim
|
||||||
8. **Harden explicit recovery.** Verify exact process identity during explicit
|
recovery.
|
||||||
|
8. **Harden explicit recovery.** Verify exact process identity during explicit
|
||||||
`service restart`; never automatically kill an unresponsive owner.
|
`service restart`; never automatically kill an unresponsive owner.
|
||||||
9. **Run the full multi-process suite.** Include repeated restart cycles and
|
9. **Run the full multi-process suite.** Include repeated restart cycles and
|
||||||
assert that no contender or child process remains afterward.
|
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.
|
- Idle background update activation with an admission fence.
|
||||||
- Application protocol compatibility and automatic local TUI re-exec.
|
- Application protocol compatibility and automatic local TUI re-exec.
|
||||||
- Stronger execution recovery with provider-attempt identity, tool-side-effect
|
- 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.
|
- Automatic recovery for a positively identified frozen owner.
|
||||||
- Cold-boot concurrency limits and interaction-prioritized location loading.
|
- Cold-boot concurrency limits and interaction-prioritized location loading.
|
||||||
- A steward or socket-handoff architecture if zero-downtime replacement becomes
|
- A steward or socket-handoff architecture if zero-downtime replacement becomes
|
||||||
|
|||||||
@@ -33,7 +33,6 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
"packages/lab/*",
|
|
||||||
"packages/stats/*",
|
"packages/stats/*",
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
@@ -174,7 +173,6 @@
|
|||||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
"@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",
|
"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",
|
"@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"
|
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
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
|
### URL Construction
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ const proxied = gateway.model("openai/gpt-4o-mini")
|
|||||||
Keep provider facades small and explicit:
|
Keep provider facades small and explicit:
|
||||||
|
|
||||||
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
|
- 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.
|
- 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.
|
- 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`.
|
- 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", {
|
const selected = model("gpt-5", {
|
||||||
apiKey,
|
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(...)`.
|
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
|
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
|
||||||
framing.ts Framing type + Framing.sse
|
framing.ts Framing type + Framing.sse
|
||||||
transport/ transport implementations
|
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
|
http.ts HttpTransport.httpJson — POST + framing
|
||||||
websocket.ts WebSocketTransport.json + WebSocketExecutor service
|
websocket.ts direct one-request channel executor + raw socket adapter
|
||||||
protocols/
|
protocols/
|
||||||
shared.ts ProviderShared toolkit used inside protocol impls
|
shared.ts ProviderShared toolkit used inside protocol impls
|
||||||
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
||||||
open-responses.ts provider-neutral Responses protocol baseline
|
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
|
anthropic-messages.ts
|
||||||
gemini.ts
|
gemini.ts
|
||||||
bedrock-converse.ts
|
bedrock-converse.ts
|
||||||
|
|||||||
@@ -315,7 +315,6 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
|||||||
|
|
||||||
const selected = model("gpt-5", {
|
const selected = model("gpt-5", {
|
||||||
apiKey: process.env.OPENAI_API_KEY,
|
apiKey: process.env.OPENAI_API_KEY,
|
||||||
transport: "websocket",
|
|
||||||
headers: { "x-application": "opencode" },
|
headers: { "x-application": "opencode" },
|
||||||
limits: { context: 200_000, output: 64_000 },
|
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/responses`
|
||||||
- `@opencode-ai/ai/providers/google-vertex/messages`
|
- `@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`.
|
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
@@ -1,6 +1,6 @@
|
|||||||
# LLM Provider Parity Status
|
# 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.
|
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 |
|
| 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 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 | `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 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-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. |
|
| 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. |
|
| 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. |
|
| 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 Parity Matrix
|
||||||
|
|
||||||
| AI SDK package | Intended native target | Status | Biggest gaps |
|
| 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` | `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/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/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` | 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` | 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/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/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/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/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` | 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/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
|
## 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.
|
These are implementation/API slices, not separate npm packages.
|
||||||
|
|
||||||
| API slice | Package-like entrypoint | Purpose |
|
| API slice | Package-like entrypoint | Purpose |
|
||||||
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
| 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 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`. |
|
| 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`. |
|
| 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-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini 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 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 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 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. |
|
| 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 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 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. |
|
| 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 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. |
|
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||||
|
|
||||||
## Suggested Next Work Slices
|
## Suggested Next Work Slices
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ Examples:
|
|||||||
```ts
|
```ts
|
||||||
OpenAI.responses("gpt-4o")
|
OpenAI.responses("gpt-4o")
|
||||||
OpenAI.chat("gpt-4o")
|
OpenAI.chat("gpt-4o")
|
||||||
OpenAI.responsesWebSocket("gpt-4o")
|
|
||||||
|
|
||||||
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
|
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
|
||||||
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
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"),
|
auth: Auth.envBearer("OPENAI_API_KEY"),
|
||||||
})
|
})
|
||||||
|
|
||||||
const openAIResponsesWebSocket = openAIResponses.with({
|
|
||||||
id: "openai-responses-websocket",
|
|
||||||
transport: WebSocketTransport.json,
|
|
||||||
})
|
|
||||||
|
|
||||||
const openAIConfig = (input: OpenAIConfig) => ({
|
const openAIConfig = (input: OpenAIConfig) => ({
|
||||||
endpoint: input.endpoint,
|
endpoint: input.endpoint,
|
||||||
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
||||||
@@ -266,13 +260,11 @@ const openAIConfig = (input: OpenAIConfig) => ({
|
|||||||
|
|
||||||
const configureOpenAI = (input: OpenAIConfig = {}) => {
|
const configureOpenAI = (input: OpenAIConfig = {}) => {
|
||||||
const responses = openAIResponses.with(openAIConfig(input))
|
const responses = openAIResponses.with(openAIConfig(input))
|
||||||
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
|
|
||||||
const chat = openAIChat.with(openAIConfig(input))
|
const chat = openAIChat.with(openAIConfig(input))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: openAIProvider,
|
id: openAIProvider,
|
||||||
responses: responses.model,
|
responses: responses.model,
|
||||||
responsesWebSocket: responsesWebSocket.model,
|
|
||||||
chat: chat.model,
|
chat: chat.model,
|
||||||
model: responses.model,
|
model: responses.model,
|
||||||
configure: configureOpenAI,
|
configure: configureOpenAI,
|
||||||
@@ -342,22 +334,19 @@ const response =
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
For direct provider-facade calls, HTTP versus WebSocket is represented as named
|
For direct provider-facade calls, Responses has one semantic model and route:
|
||||||
route selectors, not as model or request overrides. Same protocol, different
|
|
||||||
transport, different route:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
OpenAI.responses("gpt-4o")
|
OpenAI.responses("gpt-4o")
|
||||||
OpenAI.responsesWebSocket("gpt-4o")
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
|
The package-like OpenAI Responses entrypoint has the same transport-neutral
|
||||||
Responses settings while preserving the same `model(...)` contract:
|
`model(...)` contract:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { model } from "@opencode-ai/ai/providers/openai/responses"
|
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,
|
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" })
|
model("claude-sonnet-4-6", { project, location: "global" })
|
||||||
```
|
```
|
||||||
|
|
||||||
The client should not require a different public layer just because a selected
|
The client does not require a different public layer for WebSocket execution.
|
||||||
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
|
Responses routes use HTTP by default, and callers may pass a channel executor per
|
||||||
capabilities available; routes that do not need WebSocket simply never touch it.
|
call. Routes without channel support simply ignore that execution capability.
|
||||||
If a WebSocket route is selected in an environment without WebSocket support,
|
|
||||||
fail with a typed transport configuration error.
|
|
||||||
|
|
||||||
Azure is a route specialization with auth/path/default changes plus input
|
Azure is a route specialization with auth/path/default changes plus input
|
||||||
mapping. The public API configures the Azure resource once, then selects
|
mapping. The public API configures the Azure resource once, then selects
|
||||||
@@ -497,18 +484,13 @@ generic dynamic resolver:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const model =
|
const model =
|
||||||
providerID === "azure"
|
providerID === "azure" ? Azure.configure(resolvedAzureConfig).responses(apiModelID) : OpenAI.responses(apiModelID)
|
||||||
? Azure.configure(resolvedAzureConfig).responses(apiModelID)
|
|
||||||
: endpoint.websocket
|
|
||||||
? OpenAI.responsesWebSocket(apiModelID)
|
|
||||||
: OpenAI.responses(apiModelID)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
That boundary can branch on durable config/catalog metadata and call typed
|
That boundary can branch on durable config/catalog metadata and call typed
|
||||||
provider APIs directly. A direct provider-facade boundary maps metadata like
|
provider APIs directly. Transport selection remains execution policy: a Session
|
||||||
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
|
or other caller may pass a WebSocket channel executor per call without changing
|
||||||
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
|
the model constructed by this boundary.
|
||||||
The client runtime only executes the route carried by the resulting model.
|
|
||||||
|
|
||||||
## Competitive Shape
|
## Competitive Shape
|
||||||
|
|
||||||
@@ -544,9 +526,8 @@ App boundary = explicit durable-config -> typed-provider call
|
|||||||
id.
|
id.
|
||||||
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
||||||
endpoint/auth/deployment customization happens by configuring the route first.
|
endpoint/auth/deployment customization happens by configuring the route first.
|
||||||
- No transport override on an executable model or request. Direct provider
|
- No transport setting on a provider or executable model. OpenAI Responses uses
|
||||||
facades use `responses` versus `responsesWebSocket`; the package-like Responses
|
HTTP by default and accepts an optional per-call channel executor as execution policy.
|
||||||
entrypoint maps its scoped `transport` setting before constructing the model.
|
|
||||||
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
||||||
client layer with the available transport capabilities.
|
client layer with the available transport capabilities.
|
||||||
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
|
- 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
|
- [x] Make unconfigured transports reusable constants such as
|
||||||
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
|
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
|
||||||
state construction.
|
state construction.
|
||||||
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
|
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
|
||||||
exposes available transport capabilities and selected routes fail with typed
|
optional per-call channel execution without changing route identity.
|
||||||
transport config errors when a required capability is missing.
|
|
||||||
- [x] Convert OpenAI provider APIs to provider-facade shape:
|
- [x] Convert OpenAI provider APIs to provider-facade shape:
|
||||||
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and
|
`OpenAI.configure(config).responses(id)` and `.chat(id)`.
|
||||||
`.responsesWebSocket(id)`.
|
|
||||||
- [x] Convert Azure to a configured facade where resource/base URL/api version
|
- [x] Convert Azure to a configured facade where resource/base URL/api version
|
||||||
setup happens before selecting deployment ids.
|
setup happens before selecting deployment ids.
|
||||||
- [x] Split Cloudflare products into separate facades such as
|
- [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
|
- [ ] Decide whether a tiny `Provider.define(...)` helper is warranted after two
|
||||||
or three provider conversions; start with plain objects if duplication is not
|
or three provider conversions; start with plain objects if duplication is not
|
||||||
yet painful.
|
yet painful.
|
||||||
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
|
- [x] Keep executable model construction transport-neutral at the Session boundary;
|
||||||
executable models at the session boundary with explicit provider facade
|
Session-scoped execution policy supplies channel capability separately.
|
||||||
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
|
|
||||||
named route selector.
|
|
||||||
- [ ] Update tests so direct route/provider tests assert route values are carried
|
- [ ] Update tests so direct route/provider tests assert route values are carried
|
||||||
by executable models, and opencode/native tests assert boundary-based route
|
by executable models, and opencode/native tests assert boundary-based route
|
||||||
selection.
|
selection.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||||
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
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"
|
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
|
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||||
// tool-loop behavior without spending tokens on every example.
|
// tool-loop behavior without spending tokens on every example.
|
||||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||||
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
const program = Effect.gen(function* () {
|
||||||
// yield* generateOnce
|
// yield* generateOnce
|
||||||
@@ -222,6 +221,6 @@ const program = Effect.gen(function* () {
|
|||||||
// yield* generateStructuredObject
|
// yield* generateStructuredObject
|
||||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||||
yield* streamWithTools
|
yield* streamWithTools
|
||||||
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
|
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||||
|
|
||||||
Effect.runPromise(program)
|
Effect.runPromise(program)
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export * as OpenAICompatibleChat from "./openai-compatible-chat.js"
|
|||||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||||
export * as OpenAIResponses from "./openai-responses.js"
|
export * as OpenAIResponses from "./openai-responses.js"
|
||||||
export * as OpenResponses from "./open-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
|
||||||
@@ -211,11 +211,43 @@ export type StreamItem = Schema.Schema.Type<typeof StreamItem>
|
|||||||
// event-level `error` envelope, so accept all three shapes here.
|
// event-level `error` envelope, so accept all three shapes here.
|
||||||
// https://www.openresponses.org/specification
|
// https://www.openresponses.org/specification
|
||||||
const OpenResponsesErrorPayload = Schema.Struct({
|
const OpenResponsesErrorPayload = Schema.Struct({
|
||||||
|
type: optionalNull(Schema.String),
|
||||||
code: optionalNull(Schema.String),
|
code: optionalNull(Schema.String),
|
||||||
message: optionalNull(Schema.String),
|
message: optionalNull(Schema.String),
|
||||||
param: 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(
|
export const Event = Schema.StructWithRest(
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.String,
|
type: Schema.String,
|
||||||
@@ -240,6 +272,9 @@ export const Event = Schema.StructWithRest(
|
|||||||
message: Schema.optional(Schema.String),
|
message: Schema.optional(Schema.String),
|
||||||
param: optionalNull(Schema.String),
|
param: optionalNull(Schema.String),
|
||||||
error: optionalNull(OpenResponsesErrorPayload),
|
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)],
|
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||||
)
|
)
|
||||||
@@ -632,9 +667,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
|||||||
const NO_EVENTS: StepResult["1"] = []
|
const NO_EVENTS: StepResult["1"] = []
|
||||||
|
|
||||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||||
// `finish` event; `response.failed` is a hard failure. All three end the stream,
|
// `finish` event; `response.failed` and `error` are hard failures. All four end
|
||||||
// so keep this set aligned with `step` and the protocol's terminal predicate.
|
// 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"])
|
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
|
||||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||||
|
|
||||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||||
@@ -966,16 +1001,24 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
|||||||
return message || code || fallback
|
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 code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||||
const message = providerErrorMessage(event, fallback)
|
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({
|
return new AIError({
|
||||||
module: state.id,
|
module: id,
|
||||||
method: "stream",
|
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) => {
|
export const step = (state: ParserState, event: Event) => {
|
||||||
if (event.type === "response.output_text.delta" || event.type === "response.output_text.done") {
|
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`)
|
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")
|
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||||
return Effect.succeed(onResponseFinish(state, event))
|
return Effect.succeed(onResponseFinish(state, event))
|
||||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
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])
|
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
|
||||||
@@ -1,18 +1,23 @@
|
|||||||
import { Effect, Encoding, Schema } from "effect"
|
import { Effect, Encoding, Schema } from "effect"
|
||||||
|
import { Headers } from "effect/unstable/http"
|
||||||
import { Route } from "../route/client.js"
|
import { Route } from "../route/client.js"
|
||||||
import { Auth } from "../route/auth.js"
|
import { Auth } from "../route/auth.js"
|
||||||
import { Endpoint } from "../route/endpoint.js"
|
import { Endpoint } from "../route/endpoint.js"
|
||||||
import { Protocol } from "../route/protocol.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 { LLMEvent, LLMRequest, type JsonSchema, type ToolDefinition } from "../schema/index.js"
|
||||||
import { OpenResponses } from "./open-responses.js"
|
import { OpenResponses } from "./open-responses.js"
|
||||||
import { optionalArray, ProviderShared } from "./shared.js"
|
import { optionalArray, ProviderShared } from "./shared.js"
|
||||||
import { Lifecycle } from "./utils/lifecycle.js"
|
import { Lifecycle } from "./utils/lifecycle.js"
|
||||||
import { OpenAIImage } from "./utils/openai-image.js"
|
import { OpenAIImage } from "./utils/openai-image.js"
|
||||||
import { ToolSchemaProjection } from "./utils/tool-schema.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 ADAPTER = "openai-responses"
|
||||||
const NAME = "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 DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||||
export const PATH = OpenResponses.PATH
|
export const PATH = OpenResponses.PATH
|
||||||
|
|
||||||
@@ -57,16 +62,6 @@ const OpenAIResponsesBody = Schema.Struct({
|
|||||||
})
|
})
|
||||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
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 = {
|
const extension = {
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
name: NAME,
|
name: NAME,
|
||||||
@@ -249,6 +244,13 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
|||||||
const auth = Auth.none
|
const auth = Auth.none
|
||||||
|
|
||||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
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({
|
export const route = Route.make({
|
||||||
id: ADAPTER,
|
id: ADAPTER,
|
||||||
@@ -257,36 +259,7 @@ export const route = Route.make({
|
|||||||
protocol,
|
protocol,
|
||||||
endpoint,
|
endpoint,
|
||||||
auth,
|
auth,
|
||||||
transport: httpTransport,
|
transport,
|
||||||
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,
|
|
||||||
defaults: { providerOptions: { openai: { store: false } } },
|
defaults: { providerOptions: { openai: { store: false } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ const SERVER_CODES = new Set([
|
|||||||
"overloaded_error",
|
"overloaded_error",
|
||||||
"server_error",
|
"server_error",
|
||||||
"server_is_overloaded",
|
"server_is_overloaded",
|
||||||
|
"slow_down",
|
||||||
"serviceunavailableexception",
|
"serviceunavailableexception",
|
||||||
])
|
])
|
||||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images.js"
|
|||||||
|
|
||||||
export const id = ProviderID.make("openai")
|
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
|
// This provider facade wraps the lower-level Responses and Chat model factories
|
||||||
// with OpenAI-specific conveniences: typed options, API-key sugar, env fallback,
|
// 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 organization?: string
|
||||||
readonly project?: string
|
readonly project?: string
|
||||||
readonly queryParams?: Readonly<Record<string, string>>
|
readonly queryParams?: Readonly<Record<string, string>>
|
||||||
readonly transport?: "http" | "websocket"
|
|
||||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,17 +81,12 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
|
|||||||
|
|
||||||
export const configure = (input: Config = {}) => {
|
export const configure = (input: Config = {}) => {
|
||||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||||
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
|
|
||||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||||
const modelDefaults = defaults(input)
|
const modelDefaults = defaults(input)
|
||||||
const responses = (id: string | ModelID) =>
|
const responses = (id: string | ModelID) =>
|
||||||
responsesRoute
|
responsesRoute
|
||||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||||
.model<OpenAIProviderOptionsInput>({ id })
|
.model<OpenAIProviderOptionsInput>({ id })
|
||||||
const responsesWebSocket = (id: string | ModelID) =>
|
|
||||||
responsesWebSocketRoute
|
|
||||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
|
||||||
.model<OpenAIProviderOptionsInput>({ id })
|
|
||||||
const chat = (id: string | ModelID) =>
|
const chat = (id: string | ModelID) =>
|
||||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||||
const image = (modelID: string | ModelID) =>
|
const image = (modelID: string | ModelID) =>
|
||||||
@@ -111,7 +105,6 @@ export const configure = (input: Config = {}) => {
|
|||||||
id,
|
id,
|
||||||
model: responses,
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
responsesWebSocket,
|
|
||||||
chat,
|
chat,
|
||||||
image,
|
image,
|
||||||
configure,
|
configure,
|
||||||
@@ -138,10 +131,7 @@ const config = (settings: Settings): Config => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
export const model: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (modelID, settings) => {
|
||||||
const configured = configure(config(settings))
|
return configure(config(settings)).responses(modelID)
|
||||||
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)}`)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
@@ -149,6 +139,5 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
|
|||||||
settings,
|
settings,
|
||||||
) => configure(config(settings)).chat(modelID)
|
) => configure(config(settings)).chat(modelID)
|
||||||
export const responses = provider.responses
|
export const responses = provider.responses
|
||||||
export const responsesWebSocket = provider.responsesWebSocket
|
|
||||||
export const chat = provider.chat
|
export const chat = provider.chat
|
||||||
export const image = provider.image
|
export const image = provider.image
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||||
import * as Option from "effect/Option"
|
|
||||||
import { Auth } from "./auth.js"
|
import { Auth } from "./auth.js"
|
||||||
import { Endpoint, type EndpointPatch } from "./endpoint.js"
|
import { Endpoint, type EndpointPatch } from "./endpoint.js"
|
||||||
import { RequestExecutor } from "./executor.js"
|
import { RequestExecutor } from "./executor.js"
|
||||||
import { Framing } from "./framing.js"
|
import { Framing } from "./framing.js"
|
||||||
import { HttpTransport } from "./transport/index.js"
|
import { HttpTransport } from "./transport/index.js"
|
||||||
import type { HttpMiddleware, Transport, TransportRuntime } from "./transport/index.js"
|
import type { HttpMiddleware, Transport, TransportRuntime, WebSocketChannelExecutor } from "./transport/index.js"
|
||||||
import { WebSocketExecutor } from "./transport/index.js"
|
|
||||||
import type { Protocol } from "./protocol.js"
|
import type { Protocol } from "./protocol.js"
|
||||||
import { applyCachePolicy } from "../cache-policy.js"
|
import { applyCachePolicy } from "../cache-policy.js"
|
||||||
import * as ProviderShared from "../protocols/shared.js"
|
import * as ProviderShared from "../protocols/shared.js"
|
||||||
@@ -58,6 +56,7 @@ export interface Route<Body, Prepared = unknown> {
|
|||||||
prepared: Prepared,
|
prepared: Prepared,
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
runtime: TransportRuntime,
|
runtime: TransportRuntime,
|
||||||
|
options?: StreamOptions,
|
||||||
) => Stream.Stream<LLMEvent, AIError>
|
) => Stream.Stream<LLMEvent, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +156,7 @@ export interface Interface {
|
|||||||
|
|
||||||
export interface StreamOptions {
|
export interface StreamOptions {
|
||||||
readonly http?: HttpMiddleware
|
readonly http?: HttpMiddleware
|
||||||
|
readonly webSocket?: WebSocketChannelExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface StreamMethod {
|
export interface StreamMethod {
|
||||||
@@ -314,23 +314,29 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
encodeBody,
|
encodeBody,
|
||||||
headers: routeInput.headers,
|
headers: routeInput.headers,
|
||||||
middleware: options?.http,
|
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 route = `${request.model.provider}/${request.model.route.id}`
|
||||||
const events = routeInput.transport
|
return Stream.unwrap(
|
||||||
.frames(prepared, request, runtime)
|
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
||||||
.pipe(
|
Effect.map((execution) => {
|
||||||
Stream.mapEffect(decodeEvent(route)),
|
const events = execution.frames.pipe(
|
||||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
Stream.mapEffect(decodeEvent(route)),
|
||||||
)
|
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||||
return events.pipe(
|
)
|
||||||
Stream.mapAccumEffect(
|
const stream = events.pipe(
|
||||||
() => protocol.stream.initial(request),
|
Stream.mapAccumEffect(
|
||||||
protocol.stream.step,
|
() => protocol.stream.initial(request),
|
||||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
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>
|
} satisfies Route<Body, Prepared>
|
||||||
@@ -413,7 +419,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
|
|||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const compiled = yield* compile(request, options)
|
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* () {
|
Effect.gen(function* () {
|
||||||
const stream = streamRequestWith({
|
const stream = streamRequestWith({
|
||||||
http: yield* RequestExecutor.Service,
|
http: yield* RequestExecutor.Service,
|
||||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
|
||||||
})
|
})
|
||||||
return Service.of({ stream, generate: generateWith(stream) })
|
return Service.of({ stream, generate: generateWith(stream) })
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -34,44 +34,8 @@ export type HttpMiddleware = (
|
|||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||||
|
|
||||||
const BODY_LIMIT = 16_384
|
const headerDetails = (headers: Headers.Headers) =>
|
||||||
const REDACTED = "<redacted>"
|
Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)]))
|
||||||
|
|
||||||
// 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 normalizedHeaders = (headers: Headers.Headers) =>
|
const normalizedHeaders = (headers: Headers.Headers) =>
|
||||||
Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]))
|
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({
|
new HttpRequestDetails({
|
||||||
method: request.method,
|
method: request.method,
|
||||||
url: redactUrl(request.url),
|
url: request.url,
|
||||||
headers: redactHeaders(request.headers, redactedNames),
|
headers: headerDetails(request.headers),
|
||||||
})
|
})
|
||||||
|
|
||||||
const responseDetails = (
|
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
|
||||||
response: HttpClientResponse.HttpClientResponse,
|
|
||||||
redactedNames: ReadonlyArray<string | RegExp>,
|
|
||||||
) =>
|
|
||||||
new HttpResponseDetails({
|
new HttpResponseDetails({
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: redactHeaders(response.headers, redactedNames),
|
headers: headerDetails(response.headers),
|
||||||
})
|
})
|
||||||
|
|
||||||
const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
const responseBody = (body: string | void) => {
|
||||||
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>) => {
|
|
||||||
if (body === undefined) return {}
|
if (body === undefined) return {}
|
||||||
const redacted = redactBody(body, secrets)
|
return { body }
|
||||||
if (redacted.length <= BODY_LIMIT) return { body: redacted }
|
|
||||||
return { body: redacted.slice(0, BODY_LIMIT), bodyTruncated: true }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const decodeProviderBody = Schema.decodeUnknownOption(
|
const decodeProviderBody = Schema.decodeUnknownOption(
|
||||||
@@ -207,52 +135,49 @@ const decodeProviderBody = Schema.decodeUnknownOption(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
const providerMessage = (status: number, body: string | void) => {
|
||||||
if (body.body && body.body.length <= 500) {
|
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
|
||||||
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
return (
|
||||||
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
|
||||||
}
|
`Provider request failed with HTTP ${status}`
|
||||||
return `Provider request failed with HTTP ${status}`
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const responseHttp = (input: {
|
const responseHttp = (input: {
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request: HttpClientRequest.HttpClientRequest
|
||||||
readonly response: HttpClientResponse.HttpClientResponse
|
readonly response: HttpClientResponse.HttpClientResponse
|
||||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
|
||||||
readonly body: ReturnType<typeof responseBody>
|
readonly body: ReturnType<typeof responseBody>
|
||||||
readonly requestId?: string | undefined
|
readonly requestId?: string | undefined
|
||||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||||
}) =>
|
}) =>
|
||||||
new HttpContext({
|
new HttpContext({
|
||||||
request: requestDetails(input.request, input.redactedNames),
|
request: requestDetails(input.request),
|
||||||
response: responseDetails(input.response, input.redactedNames),
|
response: responseDetails(input.response),
|
||||||
...input.body,
|
...input.body,
|
||||||
requestId: input.requestId,
|
requestId: input.requestId,
|
||||||
rateLimit: input.rateLimit,
|
rateLimit: input.rateLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusError =
|
const statusError =
|
||||||
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
|
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
|
||||||
(response: HttpClientResponse.HttpClientResponse) =>
|
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (response.status < 400) return response
|
if (response.status < 400) return response
|
||||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
|
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
|
||||||
const headers = normalizedHeaders(response.headers)
|
const headers = normalizedHeaders(response.headers)
|
||||||
const retryAfter = retryAfterMs(headers)
|
const retryAfter = retryAfterMs(headers)
|
||||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||||
const details = responseBody(body, secretValues(request))
|
const details = responseBody(body)
|
||||||
return yield* new AIError({
|
return yield* new AIError({
|
||||||
module: "RequestExecutor",
|
module: "RequestExecutor",
|
||||||
method: "execute",
|
method: "execute",
|
||||||
reason: classifyProviderFailure({
|
reason: classifyProviderFailure({
|
||||||
status: response.status,
|
status: response.status,
|
||||||
message: providerMessage(response.status, details),
|
message: providerMessage(response.status, body),
|
||||||
retryAfterMs: retryAfter,
|
retryAfterMs: retryAfter,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
http: responseHttp({
|
http: responseHttp({
|
||||||
request,
|
request,
|
||||||
response,
|
response,
|
||||||
redactedNames,
|
|
||||||
body: details,
|
body: details,
|
||||||
requestId: requestId(headers),
|
requestId: requestId(headers),
|
||||||
rateLimit,
|
rateLimit,
|
||||||
@@ -262,10 +187,10 @@ const statusError =
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
// 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
|
// executor-driven requests produce. The originating request is not available on
|
||||||
// that path, so the method is assumed (language model calls are always POST),
|
// 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: {
|
export const classifyHttpFailure = (input: {
|
||||||
readonly message: string
|
readonly message: string
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -277,7 +202,7 @@ export const classifyHttpFailure = (input: {
|
|||||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||||
const retryAfter = retryAfterMs(headers)
|
const retryAfter = retryAfterMs(headers)
|
||||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||||
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
const details = responseBody(input.responseBody)
|
||||||
return classifyProviderFailure({
|
return classifyProviderFailure({
|
||||||
message: input.message,
|
message: input.message,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
@@ -285,11 +210,11 @@ export const classifyHttpFailure = (input: {
|
|||||||
retryAfterMs: retryAfter,
|
retryAfterMs: retryAfter,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
http: new HttpContext({
|
http: new HttpContext({
|
||||||
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
|
||||||
response:
|
response:
|
||||||
input.status === undefined
|
input.status === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
||||||
...details,
|
...details,
|
||||||
requestId: requestId(headers),
|
requestId: requestId(headers),
|
||||||
rateLimit,
|
rateLimit,
|
||||||
@@ -319,7 +244,6 @@ const httpError = (input: {
|
|||||||
readonly error: unknown
|
readonly error: unknown
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
readonly request: HttpClientRequest.HttpClientRequest
|
||||||
readonly operation: HttpOperation
|
readonly operation: HttpOperation
|
||||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
|
||||||
}) => {
|
}) => {
|
||||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
||||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
||||||
@@ -331,8 +255,8 @@ const httpError = (input: {
|
|||||||
transport: "http",
|
transport: "http",
|
||||||
operation: input.operation,
|
operation: input.operation,
|
||||||
code: failure.code,
|
code: failure.code,
|
||||||
url: redactUrl(request.url),
|
url: request.url,
|
||||||
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
|
http: new HttpContext({ request: requestDetails(request) }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -343,7 +267,7 @@ const httpError = (input: {
|
|||||||
const native = nativeTransportFailure(source)
|
const native = nativeTransportFailure(source)
|
||||||
const code = native?.code
|
const code = native?.code
|
||||||
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
|
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
|
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
|
||||||
|
|
||||||
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
||||||
@@ -369,10 +293,9 @@ export const stream = (
|
|||||||
): Stream.Stream<Uint8Array, AIError> =>
|
): Stream.Stream<Uint8Array, AIError> =>
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
|
||||||
const response = yield* executor.execute(request, middleware)
|
const response = yield* executor.execute(request, middleware)
|
||||||
return response.stream.pipe(
|
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 http = yield* HttpClient.HttpClient
|
||||||
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
const executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
|
||||||
if (!middleware)
|
if (!middleware)
|
||||||
return yield* http.execute(request).pipe(
|
return yield* http.execute(request).pipe(
|
||||||
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
Effect.mapError((error) => httpError({ error, request, operation: "request" })),
|
||||||
Effect.flatMap(statusError(request, redactedNames)),
|
Effect.flatMap(statusError(request)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const response = yield* middleware(request, (input) =>
|
const response = yield* middleware(request, (input) =>
|
||||||
http
|
http
|
||||||
.execute(input)
|
.execute(input)
|
||||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
|
||||||
return yield* statusError(response.request, redactedNames)(response)
|
return yield* statusError(response.request)(response)
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
execute: executeOnce,
|
execute: executeOnce,
|
||||||
|
|||||||
@@ -16,11 +16,28 @@ export { AuthOptions } from "./auth-options.js"
|
|||||||
export { Endpoint } from "./endpoint.js"
|
export { Endpoint } from "./endpoint.js"
|
||||||
export { Framing } from "./framing.js"
|
export { Framing } from "./framing.js"
|
||||||
export { Protocol } from "./protocol.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 * as Transport from "./transport/index.js"
|
||||||
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js"
|
export type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js"
|
||||||
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js"
|
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js"
|
||||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js"
|
export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js"
|
||||||
export type { Definition as FramingDef } from "./framing.js"
|
export type { Definition as FramingDef } from "./framing.js"
|
||||||
export type { Protocol as ProtocolDef } from "./protocol.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"
|
||||||
|
|||||||
@@ -87,8 +87,10 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
middleware: prepareInput.middleware,
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, _request, runtime) =>
|
execute: (prepared, _request, runtime) =>
|
||||||
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
Effect.succeed({
|
||||||
|
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sseJson = {
|
export const sseJson = {
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import type { Effect, Stream } from "effect"
|
import type { Effect, Scope, Stream } from "effect"
|
||||||
import { Endpoint } from "../endpoint.js"
|
import { Endpoint } from "../endpoint.js"
|
||||||
import { Auth } from "../auth.js"
|
import { Auth } from "../auth.js"
|
||||||
import type { HttpMiddleware, Interface as RequestExecutorInterface } from "../executor.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"
|
import type { AIError, LLMRequest } from "../../schema/index.js"
|
||||||
|
|
||||||
export interface TransportRuntime {
|
export interface TransportRuntime {
|
||||||
readonly http: RequestExecutorInterface
|
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> {
|
export interface Transport<Body, Prepared, Frame> {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
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> {
|
export interface TransportPrepareInput<Body> {
|
||||||
@@ -24,8 +38,19 @@ export interface TransportPrepareInput<Body> {
|
|||||||
readonly encodeBody: (body: Body) => string
|
readonly encodeBody: (body: Body) => string
|
||||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||||
readonly middleware?: HttpMiddleware
|
readonly middleware?: HttpMiddleware
|
||||||
|
readonly webSocket?: WebSocketChannelExecutor
|
||||||
}
|
}
|
||||||
|
|
||||||
export * as HttpTransport from "./http.js"
|
export * as HttpTransport from "./http.js"
|
||||||
export type { HttpHandler, HttpMiddleware } from "../executor.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
|
||||||
|
}
|
||||||
@@ -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 { Headers } from "effect/unstable/http"
|
||||||
|
import { Socket } from "effect/unstable/socket"
|
||||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
||||||
import * as HttpTransport from "./http.js"
|
import * as HttpTransport from "./http.js"
|
||||||
import type { Transport } from "./index.js"
|
import type { Transport } from "./index.js"
|
||||||
|
import type {
|
||||||
|
ChannelObservation,
|
||||||
|
WebSocketChannelDriver,
|
||||||
|
WebSocketChannelExchange,
|
||||||
|
WebSocketChannelExecutor,
|
||||||
|
} from "./websocket-channel.js"
|
||||||
|
|
||||||
export interface WebSocketRequest {
|
export interface WebSocketRequest {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -15,24 +22,29 @@ export interface WebSocketConnection {
|
|||||||
readonly close: Effect.Effect<void, never>
|
readonly close: Effect.Effect<void, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Interface {
|
export interface WebSocketConnector {
|
||||||
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSocketConstructorWithHeaders = new (
|
type WebSocketConstructorWithHeaders = (
|
||||||
url: string,
|
url: string,
|
||||||
options?: { readonly headers?: Headers.Headers },
|
options?: { readonly headers?: Headers.Headers },
|
||||||
) => globalThis.WebSocket
|
) => globalThis.WebSocket
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||||
|
|
||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
method: string,
|
||||||
message: 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({
|
new AIError({
|
||||||
module: "WebSocketExecutor",
|
module: "WebSocketConnector",
|
||||||
method,
|
method,
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({
|
||||||
message,
|
message,
|
||||||
@@ -40,9 +52,33 @@ const transportError = (
|
|||||||
operation: input.operation,
|
operation: input.operation,
|
||||||
url: input.url,
|
url: input.url,
|
||||||
code: input.code,
|
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) => {
|
const eventMessage = (event: Event) => {
|
||||||
if ("message" in event && typeof event.message === "string") return event.message
|
if ("message" in event && typeof event.message === "string") return event.message
|
||||||
return event.type
|
return event.type
|
||||||
@@ -63,6 +99,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
operation: "request",
|
||||||
code: "closed",
|
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)}`, {
|
transportError("open", `Failed to open WebSocket: ${eventMessage(event)}`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
operation: "request",
|
||||||
|
phase: "connect",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -101,6 +141,8 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
operation: "request",
|
||||||
code: String(event.code),
|
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({
|
Effect.try({
|
||||||
try: () => {
|
try: () => {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
@@ -131,21 +173,31 @@ const webSocketUrl = (value: string) =>
|
|||||||
url: value,
|
url: value,
|
||||||
operation: "request",
|
operation: "request",
|
||||||
code: "invalid-url",
|
code: "invalid-url",
|
||||||
|
phase: "prepare",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const open = (input: WebSocketRequest) =>
|
export const open = (input: WebSocketRequest) =>
|
||||||
Effect.try({
|
Effect.gen(function* () {
|
||||||
try: () =>
|
const constructor = yield* Socket.WebSocketConstructor
|
||||||
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
const ws = yield* Effect.try({
|
||||||
catch: (error) =>
|
try: () =>
|
||||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
||||||
url: input.url,
|
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||||
operation: "request",
|
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
||||||
}),
|
headers: input.headers,
|
||||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
}),
|
||||||
|
catch: (error) =>
|
||||||
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
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 = (
|
export const fromWebSocket = (
|
||||||
ws: globalThis.WebSocket,
|
ws: globalThis.WebSocket,
|
||||||
@@ -155,16 +207,52 @@ export const fromWebSocket = (
|
|||||||
yield* waitOpen(ws, input)
|
yield* waitOpen(ws, input)
|
||||||
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
|
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) => {
|
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)
|
const binary = binaryMessage(event.data)
|
||||||
if (binary) return Queue.offerUnsafe(messages, binary)
|
if (binary) return offer(binary)
|
||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
transportError("message", "Unsupported WebSocket message payload", {
|
transportError("message", "Unsupported WebSocket message payload", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "read",
|
operation: "read",
|
||||||
|
code: "message",
|
||||||
|
phase: "receive",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -176,12 +264,13 @@ export const fromWebSocket = (
|
|||||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "read",
|
operation: "read",
|
||||||
|
code: "message",
|
||||||
|
phase: "receive",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const onClose = (event: CloseEvent) => {
|
const onClose = (event: CloseEvent) => {
|
||||||
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
|
|
||||||
Queue.failCauseUnsafe(
|
Queue.failCauseUnsafe(
|
||||||
messages,
|
messages,
|
||||||
Cause.fail(
|
Cause.fail(
|
||||||
@@ -189,6 +278,7 @@ export const fromWebSocket = (
|
|||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "read",
|
operation: "read",
|
||||||
code: String(event.code),
|
code: String(event.code),
|
||||||
|
phase: "close",
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -205,13 +295,26 @@ export const fromWebSocket = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sendText: (message) =>
|
sendText: (message) =>
|
||||||
Effect.try({
|
Effect.suspend(() => {
|
||||||
try: () => ws.send(message),
|
if (ws.readyState !== globalThis.WebSocket.OPEN)
|
||||||
catch: (error) =>
|
return Effect.fail(
|
||||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "write",
|
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),
|
messages: Stream.fromQueue(messages),
|
||||||
close: cleanup.pipe(
|
close: cleanup.pipe(
|
||||||
@@ -228,6 +331,57 @@ export const fromWebSocket = (
|
|||||||
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
||||||
typeof message === "string" ? message : decoder.decode(message)
|
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 {
|
export interface JsonPrepared {
|
||||||
readonly url: string
|
readonly url: string
|
||||||
readonly headers: Headers.Headers
|
readonly headers: Headers.Headers
|
||||||
@@ -254,33 +408,44 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
...prepareInput,
|
...prepareInput,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
url: yield* webSocketUrl(parts.url),
|
url: yield* toWebSocketUrl(parts.url),
|
||||||
headers: parts.headers,
|
headers: parts.headers,
|
||||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
frames: (prepared, _request, runtime) => {
|
execute: (prepared, request, _runtime, options) => {
|
||||||
const webSocket = runtime.webSocket
|
const webSocket = options?.webSocket
|
||||||
if (!webSocket) {
|
if (!webSocket) {
|
||||||
return Stream.fail(
|
return Effect.fail(
|
||||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||||
url: prepared.url,
|
url: prepared.url,
|
||||||
operation: "request",
|
operation: "request",
|
||||||
code: "unavailable",
|
code: "unavailable",
|
||||||
|
phase: "prepare",
|
||||||
|
delivery: "not-sent",
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const decoder = new TextDecoder()
|
const driver: WebSocketChannelDriver = {
|
||||||
return Stream.unwrap(
|
create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
|
||||||
Effect.gen(function* () {
|
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
||||||
const connection = yield* Effect.acquireRelease(
|
}
|
||||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
const exchange: WebSocketChannelExchange = {
|
||||||
(connection) => connection.close,
|
id: request.id ?? "request",
|
||||||
)
|
connect: { url: prepared.url, headers: prepared.headers },
|
||||||
yield* connection.sendText(prepared.message)
|
fallback: () =>
|
||||||
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
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,
|
with: json,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const WebSocketExecutor = {
|
|
||||||
Service,
|
|
||||||
layer,
|
|
||||||
open,
|
|
||||||
fromWebSocket,
|
|
||||||
messageText,
|
|
||||||
} as const
|
|
||||||
|
|
||||||
export const WebSocketTransport = {
|
export const WebSocketTransport = {
|
||||||
json,
|
json,
|
||||||
jsonTransport,
|
jsonTransport,
|
||||||
|
direct,
|
||||||
|
makeDirect,
|
||||||
|
open,
|
||||||
|
fromWebSocket,
|
||||||
|
messageText,
|
||||||
|
toWebSocketUrl,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -106,6 +106,13 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
|||||||
code: Schema.optional(Schema.String),
|
code: Schema.optional(Schema.String),
|
||||||
url: Schema.optional(Schema.String),
|
url: Schema.optional(Schema.String),
|
||||||
http: Schema.optional(HttpContext),
|
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>(
|
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { describe, expect } from "bun:test"
|
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 { Headers, HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||||
import { LLM, AIError } from "../src/index.js"
|
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 * 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 { 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"
|
import { it } from "./lib/effect.js"
|
||||||
|
|
||||||
const request = HttpClientRequest.post("https://provider.test/v1/chat?api_key=secret&key=secret&debug=1").pipe(
|
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 errorHttp = (error: AIError) => ("http" in error.reason ? error.reason.http : undefined)
|
||||||
|
const largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
|
||||||
|
|
||||||
describe("RequestExecutor", () => {
|
describe("RequestExecutor", () => {
|
||||||
it.effect("parses response body failures at the executor seam", () =>
|
it.effect("parses response body failures at the executor seam", () =>
|
||||||
@@ -75,11 +77,11 @@ describe("RequestExecutor", () => {
|
|||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(error.reason).toMatchObject({
|
expect(error.reason).toMatchObject({
|
||||||
_tag: "Transport",
|
_tag: "Transport",
|
||||||
message: "ECONNRESET: disconnected <redacted> <redacted>",
|
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
|
||||||
transport: "http",
|
transport: "http",
|
||||||
operation: "read",
|
operation: "read",
|
||||||
code: "ECONNRESET",
|
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(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
@@ -152,12 +154,12 @@ describe("RequestExecutor", () => {
|
|||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(error.reason).toMatchObject({
|
expect(error.reason).toMatchObject({
|
||||||
_tag: "Transport",
|
_tag: "Transport",
|
||||||
message: "ECONNRESET: proxy disconnected <redacted>",
|
message: "ECONNRESET: proxy disconnected proxy-secret",
|
||||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||||
http: {
|
http: {
|
||||||
request: {
|
request: {
|
||||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||||
headers: { authorization: "<redacted>" },
|
headers: { authorization: "Bearer proxy-secret" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -217,9 +219,47 @@ describe("RequestExecutor", () => {
|
|||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
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 })]))),
|
}).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", () =>
|
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const classify = (body: string) =>
|
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* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
@@ -268,15 +308,15 @@ describe("RequestExecutor", () => {
|
|||||||
requestId: "req_123",
|
requestId: "req_123",
|
||||||
request: {
|
request: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
headers: { authorization: "Bearer secret", "x-safe": "visible" },
|
||||||
},
|
},
|
||||||
response: {
|
response: {
|
||||||
status: 429,
|
status: 429,
|
||||||
headers: {
|
headers: {
|
||||||
"retry-after-ms": "0",
|
"retry-after-ms": "0",
|
||||||
"x-request-id": "req_123",
|
"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* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
|
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
|
||||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
|
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
||||||
Effect.provideService(Headers.CurrentRedactedNames, ["x-safe"]),
|
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* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||||
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||||
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
expect(errorHttp(error)?.body).toHaveLength(20_000)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
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* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||||
|
|
||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
|
expect(errorHttp(error)?.body).toBe(
|
||||||
expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
|
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
|
||||||
expect(errorHttp(error)?.body).not.toContain("body-secret")
|
)
|
||||||
expect(errorHttp(error)?.body).not.toContain("query-secret")
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
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* () {
|
Effect.gen(function* () {
|
||||||
const executor = yield* RequestExecutor.Service
|
const executor = yield* RequestExecutor.Service
|
||||||
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
|
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
|
||||||
|
|
||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
|
expect(errorHttp(error)?.body).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
||||||
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")
|
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { AIError, ImageInput, LanguageModel, LLM, LLMClient, Provider } from "@opencode-ai/ai"
|
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 { Provider as ProviderSubpath } from "@opencode-ai/ai/provider"
|
||||||
import {
|
import {
|
||||||
CloudflareAIGateway,
|
CloudflareAIGateway,
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
OpenAICompatibleResponses,
|
OpenAICompatibleResponses,
|
||||||
OpenAIResponses,
|
OpenAIResponses,
|
||||||
OpenResponses,
|
OpenResponses,
|
||||||
|
OpenResponsesChannel,
|
||||||
} from "@opencode-ai/ai/protocols"
|
} from "@opencode-ai/ai/protocols"
|
||||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||||
@@ -36,6 +37,7 @@ describe("public exports", () => {
|
|||||||
test("route barrel exposes route-authoring APIs", () => {
|
test("route barrel exposes route-authoring APIs", () => {
|
||||||
expect(Route.make).toBeFunction()
|
expect(Route.make).toBeFunction()
|
||||||
expect(Protocol.make).toBeFunction()
|
expect(Protocol.make).toBeFunction()
|
||||||
|
expect(WebSocketTransport.makeDirect).toBeFunction()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("provider barrels expose user-facing facades", async () => {
|
test("provider barrels expose user-facing facades", async () => {
|
||||||
@@ -43,7 +45,6 @@ describe("public exports", () => {
|
|||||||
|
|
||||||
expect(OpenAI.model).toBeFunction()
|
expect(OpenAI.model).toBeFunction()
|
||||||
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
||||||
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
|
|
||||||
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
||||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||||
expect(
|
expect(
|
||||||
@@ -65,10 +66,10 @@ describe("public exports", () => {
|
|||||||
expect(OpenAIChat.route.id).toBe("openai-chat")
|
expect(OpenAIChat.route.id).toBe("openai-chat")
|
||||||
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
|
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
|
||||||
expect(OpenResponses.protocol.id).toBe("open-responses")
|
expect(OpenResponses.protocol.id).toBe("open-responses")
|
||||||
|
expect(OpenResponsesChannel.transport).toBeFunction()
|
||||||
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
|
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
|
||||||
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
|
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
|
||||||
expect(OpenAIResponses.route.id).toBe("openai-responses")
|
expect(OpenAIResponses.route.id).toBe("openai-responses")
|
||||||
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
|
|
||||||
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
|
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { Effect, Layer, Ref } from "effect"
|
import { Effect, Layer, Ref } from "effect"
|
||||||
import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
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 LLMClientService } from "../../src/route/client.js"
|
||||||
import type { Service as RequestExecutorService } from "../../src/route/executor.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 = {
|
export type HandlerInput = {
|
||||||
readonly request: HttpClientRequest.HttpClientRequest
|
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 {
|
export interface SystemError extends Error {
|
||||||
readonly code: string
|
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> => {
|
export const runtimeLayer = (layer: Layer.Layer<HttpClient.HttpClient>): Layer.Layer<RuntimeEnv> => {
|
||||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
|
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
|
||||||
return Layer.mergeAll(deps, llmClientLayer)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
||||||
|
|||||||
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
|
|||||||
|
|
||||||
test("classifies V1 overloaded provider codes", () => {
|
test("classifies V1 overloaded provider codes", () => {
|
||||||
expect(
|
expect(
|
||||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
|
||||||
(message) => classifyProviderFailure({ message })._tag,
|
(message) => classifyProviderFailure({ message })._tag,
|
||||||
),
|
),
|
||||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("classifies transient client statuses as provider internal", () => {
|
test("classifies transient client statuses as provider internal", () => {
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
import { LLM } from "../../src/index.js"
|
import { LLM } from "../../src/index.js"
|
||||||
import { OpenAI } from "../../src/providers.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({
|
LLM.request({
|
||||||
model,
|
model: selected,
|
||||||
prompt: "Hello",
|
prompt: "Hello",
|
||||||
// @ts-expect-error OpenAI reasoning effort must be a string.
|
// @ts-expect-error OpenAI reasoning effort must be a string.
|
||||||
providerOptions: { openai: { reasoningEffort: 1 } },
|
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 })
|
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 () => {
|
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
||||||
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
||||||
const selected = OpenAICompatibleResponses.model("custom-model", {
|
const selected = OpenAICompatibleResponses.model("custom-model", {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
|
|||||||
|
|
||||||
expect(error).toBeInstanceOf(AIError)
|
expect(error).toBeInstanceOf(AIError)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
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).toBeInstanceOf(AIError)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||||
expect(error.message).toContain("HTTP 400")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1275,8 +1275,7 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AIError)
|
expect(error).toBeInstanceOf(AIError)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||||
expect(error.message).toContain("HTTP 400")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { describe, expect } from "bun:test"
|
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 { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||||
import {
|
import {
|
||||||
LLM,
|
LLM,
|
||||||
AIError,
|
AIError,
|
||||||
|
HttpOptions,
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
LLMRequest,
|
LLMRequest,
|
||||||
Message,
|
Message,
|
||||||
@@ -11,14 +12,23 @@ import {
|
|||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
ToolResultPart,
|
ToolResultPart,
|
||||||
|
TransportReason,
|
||||||
Usage,
|
Usage,
|
||||||
} from "../../src/index.js"
|
} 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 { compileRequest } from "../../src/route/client.js"
|
||||||
import * as Azure from "../../src/providers/azure.js"
|
import * as Azure from "../../src/providers/azure.js"
|
||||||
import * as OpenAI from "../../src/providers/openai.js"
|
import * as OpenAI from "../../src/providers/openai.js"
|
||||||
import * as XAI from "../../src/providers/xai.js"
|
import * as XAI from "../../src/providers/xai.js"
|
||||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.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 * as ProviderShared from "../../src/protocols/shared.js"
|
||||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||||
import { it } from "../lib/effect.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 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({
|
const request = LLM.request({
|
||||||
id: "req_1",
|
id: "req_1",
|
||||||
model,
|
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* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
model: OpenAIResponses.webSocketRoute
|
model: OpenAIResponses.route
|
||||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||||
.model({ id: "gpt-4.1-mini" }),
|
.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.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 })
|
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", () =>
|
it.effect("streams OpenAI Responses over WebSocket", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const sent: string[] = []
|
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
|
let closed = false
|
||||||
const deps = Layer.mergeAll(
|
const deps = Layer.succeed(
|
||||||
Layer.succeed(
|
RequestExecutor.Service,
|
||||||
RequestExecutor.Service,
|
RequestExecutor.Service.of({
|
||||||
RequestExecutor.Service.of({
|
execute: () => Effect.die("unexpected HTTP request"),
|
||||||
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 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(
|
const response = yield* LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
model: OpenAI.configure({
|
||||||
"gpt-4.1-mini",
|
baseURL: "https://api.openai.test/v1/",
|
||||||
),
|
apiKey: "test",
|
||||||
|
headers: { "openai-beta": "custom-protocol" },
|
||||||
|
}).responses("gpt-4.1-mini"),
|
||||||
prompt: "Say hello.",
|
prompt: "Say hello.",
|
||||||
}),
|
}),
|
||||||
|
{ webSocket },
|
||||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||||
|
|
||||||
expect(response.text).toBe("Hi")
|
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(closed).toBe(true)
|
||||||
expect(sent).toHaveLength(1)
|
expect(sent).toHaveLength(1)
|
||||||
expect(JSON.parse(sent[0])).toEqual({
|
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", () =>
|
it.effect("fails immediately when WebSocket is already closed", () =>
|
||||||
Effect.gen(function* () {
|
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.
|
// 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,
|
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
|
||||||
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||||
).pipe(Effect.flip)
|
).pipe(Effect.flip)
|
||||||
|
|
||||||
expect(error.message).toContain("closed before opening")
|
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(
|
yield* LLMClient.generate(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
model: Azure.configure({
|
model: Azure.configure({
|
||||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
baseURL: "https://opencode-test.openai.azure.com/openai/",
|
||||||
apiKey: "azure-key",
|
apiKey: "azure-key",
|
||||||
headers: { authorization: "Bearer stale" },
|
headers: { authorization: "Bearer stale" },
|
||||||
}).responses("gpt-4.1-mini"),
|
}).responses("gpt-4.1-mini"),
|
||||||
@@ -2037,8 +2610,7 @@ describe("OpenAI Responses route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AIError)
|
expect(error).toBeInstanceOf(AIError)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
||||||
expect(error.message).toContain("HTTP 400")
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,12 +2,11 @@ import { HttpRecorder } from "@opencode-ai/http-recorder"
|
|||||||
import { Layer } from "effect"
|
import { Layer } from "effect"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
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 { ImageClient } from "../src/image-client.js"
|
||||||
import type { Service as ImageClientService } 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 LLMClientService } from "../src/route/client.js"
|
||||||
import type { Service as RequestExecutorService } from "../src/route/executor.js"
|
import type { Service as RequestExecutorService } from "../src/route/executor.js"
|
||||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket.js"
|
|
||||||
import {
|
import {
|
||||||
recordedEffectGroup,
|
recordedEffectGroup,
|
||||||
type RecordedCaseOptions as RunnerCaseOptions,
|
type RecordedCaseOptions as RunnerCaseOptions,
|
||||||
@@ -17,7 +16,7 @@ import {
|
|||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||||
|
|
||||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
||||||
|
|
||||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||||
readonly options?: HttpRecorder.RecorderOptions
|
readonly options?: HttpRecorder.RecorderOptions
|
||||||
@@ -82,11 +81,10 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
deps,
|
requestExecutor,
|
||||||
LLMClient.layer.pipe(Layer.provide(deps)),
|
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
ImageClient.layer.pipe(Layer.provide(deps)),
|
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
LanguageModel,
|
LanguageModel,
|
||||||
ModelID,
|
ModelID,
|
||||||
ProviderID,
|
ProviderID,
|
||||||
|
TransportReason,
|
||||||
Usage,
|
Usage,
|
||||||
} from "../src/schema/index.js"
|
} from "../src/schema/index.js"
|
||||||
import { ProviderShared } from "../src/protocols/shared.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")))),
|
await Effect.runPromise(Effect.fail(error).pipe(Effect.catchTag("AI.Error", () => Effect.succeed("caught")))),
|
||||||
).toBe("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)
|
||||||
|
})
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
}
|
}
|
||||||
if (path === "/api/project/current")
|
if (path === "/api/project/current")
|
||||||
return json(route, { id: (config.project as { id?: string }).id, directory: config.directory })
|
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")
|
if (worktree && route.request().method() === "GET")
|
||||||
return json(route, [
|
return json(route, [
|
||||||
{ directory: config.directory },
|
{ directory: config.directory },
|
||||||
@@ -294,7 +294,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||||||
}
|
}
|
||||||
if (worktree && route.request().method() === "DELETE")
|
if (worktree && route.request().method() === "DELETE")
|
||||||
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
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": "*" } })
|
return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } })
|
||||||
if (path === "/api/permission/request")
|
if (path === "/api/permission/request")
|
||||||
return json(route, {
|
return json(route, {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import type {
|
|||||||
ReferenceListInput,
|
ReferenceListInput,
|
||||||
ReferenceListOutput,
|
ReferenceListOutput,
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
QuestionRequest,
|
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
@@ -112,7 +111,6 @@ type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<Locatio
|
|||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
type McpApi = ServerApi["mcp"]
|
||||||
type PermissionApi = ServerApi["permission"]
|
type PermissionApi = ServerApi["permission"]
|
||||||
type QuestionApi = ServerApi["question"]
|
|
||||||
type VcsApi = ServerApi["vcs"]
|
type VcsApi = ServerApi["vcs"]
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||||
@@ -303,7 +301,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
readonly mcp: McpApi
|
readonly mcp: McpApi
|
||||||
readonly permission: PermissionApi
|
readonly permission: PermissionApi
|
||||||
readonly project: ProjectApi
|
readonly project: ProjectApi
|
||||||
readonly question: QuestionApi
|
|
||||||
readonly reference: ReferenceListApi
|
readonly reference: ReferenceListApi
|
||||||
readonly session: SessionApi
|
readonly session: SessionApi
|
||||||
readonly vcs: VcsApi
|
readonly vcs: VcsApi
|
||||||
@@ -394,40 +391,6 @@ export async function bootstrapDirectory(input: {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() =>
|
|
||||||
retry(() =>
|
|
||||||
input.api.question.request
|
|
||||||
.list({ location: { directory: input.directory } })
|
|
||||||
.then((result) => result.data)
|
|
||||||
.then((questions) => {
|
|
||||||
const ids = questions.map((question) => question.sessionID)
|
|
||||||
const grouped = groupBySession(
|
|
||||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
|
||||||
)
|
|
||||||
const warm = input.session
|
|
||||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
|
||||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
|
||||||
return warm.then(() =>
|
|
||||||
batch(() => {
|
|
||||||
const current = input.session?.data.question ?? input.store.question
|
|
||||||
for (const sessionID of Object.keys(current)) {
|
|
||||||
if (grouped[sessionID]) continue
|
|
||||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
|
||||||
if (input.session) input.session.set("question", sessionID, [])
|
|
||||||
if (!input.session) input.setStore("question", sessionID, [])
|
|
||||||
}
|
|
||||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
|
||||||
const value = reconcile(
|
|
||||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
|
||||||
{ key: "id" },
|
|
||||||
)
|
|
||||||
if (input.session) input.session.set("question", sessionID, value)
|
|
||||||
if (!input.session) input.setStore("question", sessionID, value)
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
input.mcp &&
|
input.mcp &&
|
||||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||||
|
|||||||
@@ -250,7 +250,6 @@ export function createChildStoreManager(input: {
|
|||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
|
||||||
get mcp_ready() {
|
get mcp_ready() {
|
||||||
return !mcpQuery.isLoading
|
return !mcpQuery.isLoading
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, Project } from "@/types"
|
import type { Message, Part, Project } from "@/types"
|
||||||
import type { PermissionRequest, QuestionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
import type { PermissionRequest, SessionInfo } from "@opencode-ai/client/promise"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||||
@@ -45,19 +45,6 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
|
|||||||
save: [],
|
save: [],
|
||||||
}) as PermissionRequest
|
}) as PermissionRequest
|
||||||
|
|
||||||
const questionRequest = (id: string, sessionID: string, title = id) =>
|
|
||||||
({
|
|
||||||
id,
|
|
||||||
sessionID,
|
|
||||||
questions: [
|
|
||||||
{
|
|
||||||
question: title,
|
|
||||||
header: title,
|
|
||||||
options: [{ label: title, description: title }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}) as QuestionRequest
|
|
||||||
|
|
||||||
const baseState = (input: Partial<State> = {}) =>
|
const baseState = (input: Partial<State> = {}) =>
|
||||||
({
|
({
|
||||||
status: "complete",
|
status: "complete",
|
||||||
@@ -75,7 +62,6 @@ const baseState = (input: Partial<State> = {}) =>
|
|||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
|
||||||
mcp: {},
|
mcp: {},
|
||||||
lsp: [],
|
lsp: [],
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
@@ -220,7 +206,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
session_diff: { ses_1: [] },
|
session_diff: { ses_1: [] },
|
||||||
todo: { ses_1: [] },
|
todo: { ses_1: [] },
|
||||||
permission: { ses_1: [] },
|
permission: { ses_1: [] },
|
||||||
question: { ses_1: [] },
|
|
||||||
session_status: { ses_1: { type: "busy" } },
|
session_status: { ses_1: { type: "busy" } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -241,7 +226,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
expect(store.session_diff.ses_1).toBeUndefined()
|
expect(store.session_diff.ses_1).toBeUndefined()
|
||||||
expect(store.todo.ses_1).toBeUndefined()
|
expect(store.todo.ses_1).toBeUndefined()
|
||||||
expect(store.permission.ses_1).toBeUndefined()
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
expect(store.question.ses_1).toBeUndefined()
|
|
||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,7 +266,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
session_diff: { [item.info.id]: [] },
|
session_diff: { [item.info.id]: [] },
|
||||||
todo: { [item.info.id]: [] },
|
todo: { [item.info.id]: [] },
|
||||||
permission: { [item.info.id]: [] },
|
permission: { [item.info.id]: [] },
|
||||||
question: { [item.info.id]: [] },
|
|
||||||
session_status: { [item.info.id]: { type: "busy" } },
|
session_status: { [item.info.id]: { type: "busy" } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -306,7 +289,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||||
expect(store.todo[item.info.id]).toBeUndefined()
|
expect(store.todo[item.info.id]).toBeUndefined()
|
||||||
expect(store.permission[item.info.id]).toBeUndefined()
|
expect(store.permission[item.info.id]).toBeUndefined()
|
||||||
expect(store.question[item.info.id]).toBeUndefined()
|
|
||||||
expect(store.session_status[item.info.id]).toBeUndefined()
|
expect(store.session_status[item.info.id]).toBeUndefined()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -325,7 +307,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
session_diff: { [dropped.id]: [] },
|
session_diff: { [dropped.id]: [] },
|
||||||
todo: { [dropped.id]: [] },
|
todo: { [dropped.id]: [] },
|
||||||
permission: { [dropped.id]: [] },
|
permission: { [dropped.id]: [] },
|
||||||
question: { [dropped.id]: [] },
|
|
||||||
session_status: { [dropped.id]: { type: "busy" } },
|
session_status: { [dropped.id]: { type: "busy" } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -349,7 +330,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||||
expect(store.todo[dropped.id]).toBeUndefined()
|
expect(store.todo[dropped.id]).toBeUndefined()
|
||||||
expect(store.permission[dropped.id]).toBeUndefined()
|
expect(store.permission[dropped.id]).toBeUndefined()
|
||||||
expect(store.question[dropped.id]).toBeUndefined()
|
|
||||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||||
expect(todos).toEqual([dropped.id])
|
expect(todos).toEqual([dropped.id])
|
||||||
})
|
})
|
||||||
@@ -486,12 +466,11 @@ describe("applyDirectoryEvent", () => {
|
|||||||
expect(store.part[messageID]).toBeUndefined()
|
expect(store.part[messageID]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("tracks permission and question request lifecycles", () => {
|
test("tracks permission request lifecycles", () => {
|
||||||
const sessionID = "ses_1"
|
const sessionID = "ses_1"
|
||||||
const [store, setStore] = createStore(
|
const [store, setStore] = createStore(
|
||||||
baseState({
|
baseState({
|
||||||
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
|
permission: { [sessionID]: [permissionRequest("perm_1", sessionID), permissionRequest("perm_3", sessionID)] },
|
||||||
question: { [sessionID]: [questionRequest("q_1", sessionID), questionRequest("q_3", sessionID)] },
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -524,36 +503,6 @@ describe("applyDirectoryEvent", () => {
|
|||||||
loadLsp() {},
|
loadLsp() {},
|
||||||
})
|
})
|
||||||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
|
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
|
||||||
|
|
||||||
applyDirectoryEvent({
|
|
||||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
push() {},
|
|
||||||
directory: "/tmp",
|
|
||||||
loadLsp() {},
|
|
||||||
})
|
|
||||||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
|
|
||||||
|
|
||||||
applyDirectoryEvent({
|
|
||||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
push() {},
|
|
||||||
directory: "/tmp",
|
|
||||||
loadLsp() {},
|
|
||||||
})
|
|
||||||
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
|
|
||||||
|
|
||||||
applyDirectoryEvent({
|
|
||||||
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
|
|
||||||
store,
|
|
||||||
setStore,
|
|
||||||
push() {},
|
|
||||||
directory: "/tmp",
|
|
||||||
loadLsp() {},
|
|
||||||
})
|
|
||||||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("updates vcs branch in store and cache", () => {
|
test("updates vcs branch in store and cache", () => {
|
||||||
|
|||||||
@@ -2,13 +2,7 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
|||||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { Message, Part, Project, Todo } from "@/types"
|
import type { Message, Part, Project, Todo } from "@/types"
|
||||||
import type {
|
import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
FileDiffInfo,
|
|
||||||
PermissionRequest,
|
|
||||||
QuestionRequest,
|
|
||||||
SessionInfo,
|
|
||||||
SessionStatus,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import type { State, VcsCache } from "./types"
|
import type { State, VcsCache } from "./types"
|
||||||
import { trimSessions } from "./session-trim"
|
import { trimSessions } from "./session-trim"
|
||||||
import { dropSessionCaches } from "./session-cache"
|
import { dropSessionCaches } from "./session-cache"
|
||||||
@@ -27,9 +21,6 @@ const SESSION_CONTENT_EVENTS = new Set([
|
|||||||
"message.part.delta",
|
"message.part.delta",
|
||||||
"permission.asked",
|
"permission.asked",
|
||||||
"permission.replied",
|
"permission.replied",
|
||||||
"question.asked",
|
|
||||||
"question.replied",
|
|
||||||
"question.rejected",
|
|
||||||
])
|
])
|
||||||
|
|
||||||
export function applyGlobalEvent(input: {
|
export function applyGlobalEvent(input: {
|
||||||
@@ -86,7 +77,6 @@ export function cleanupDroppedSessionCaches(
|
|||||||
...Object.keys(store.session_diff),
|
...Object.keys(store.session_diff),
|
||||||
...Object.keys(store.todo),
|
...Object.keys(store.todo),
|
||||||
...Object.keys(store.permission),
|
...Object.keys(store.permission),
|
||||||
...Object.keys(store.question),
|
|
||||||
...Object.keys(store.session_status),
|
...Object.keys(store.session_status),
|
||||||
...Object.values(store.part)
|
...Object.values(store.part)
|
||||||
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
||||||
@@ -438,43 +428,6 @@ export function applyDirectoryEvent(input: {
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case "question.asked": {
|
|
||||||
const question = event.properties as QuestionRequest
|
|
||||||
const questions = input.store.question[question.sessionID]
|
|
||||||
if (!questions) {
|
|
||||||
input.setStore("question", question.sessionID, [question])
|
|
||||||
break
|
|
||||||
}
|
|
||||||
const result = Binary.search(questions, question.id, (q) => q.id)
|
|
||||||
if (result.found) {
|
|
||||||
input.setStore("question", question.sessionID, result.index, reconcile(question))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
input.setStore(
|
|
||||||
"question",
|
|
||||||
question.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(result.index, 0, question)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "question.replied":
|
|
||||||
case "question.rejected": {
|
|
||||||
const props = event.properties as { sessionID: string; requestID: string }
|
|
||||||
const questions = input.store.question[props.sessionID]
|
|
||||||
if (!questions) break
|
|
||||||
const result = Binary.search(questions, props.requestID, (q) => q.id)
|
|
||||||
if (!result.found) break
|
|
||||||
input.setStore(
|
|
||||||
"question",
|
|
||||||
props.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
draft.splice(result.index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
case "lsp.updated": {
|
case "lsp.updated": {
|
||||||
input.loadLsp()
|
input.loadLsp()
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||||
|
|
||||||
@@ -33,7 +33,6 @@ describe("app session cache", () => {
|
|||||||
session_message: Record<string, never[] | undefined>
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
|
||||||
form: Record<string, FormInfo[] | undefined>
|
form: Record<string, FormInfo[] | undefined>
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
} = {
|
} = {
|
||||||
@@ -44,7 +43,6 @@ describe("app session cache", () => {
|
|||||||
session_message: {},
|
session_message: {},
|
||||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||||
permission: { ses_1: [] as PermissionRequest[] },
|
permission: { ses_1: [] as PermissionRequest[] },
|
||||||
question: { ses_1: [] as QuestionRequest[] },
|
|
||||||
form: { ses_1: [] as FormInfo[] },
|
form: { ses_1: [] as FormInfo[] },
|
||||||
part_text_accum_delta: { prt_1: "streamed text" },
|
part_text_accum_delta: { prt_1: "streamed text" },
|
||||||
}
|
}
|
||||||
@@ -58,7 +56,6 @@ describe("app session cache", () => {
|
|||||||
expect(store.session_diff.ses_1).toBeUndefined()
|
expect(store.session_diff.ses_1).toBeUndefined()
|
||||||
expect(store.session_status.ses_1).toBeUndefined()
|
expect(store.session_status.ses_1).toBeUndefined()
|
||||||
expect(store.permission.ses_1).toBeUndefined()
|
expect(store.permission.ses_1).toBeUndefined()
|
||||||
expect(store.question.ses_1).toBeUndefined()
|
|
||||||
expect(store.form.ses_1).toBeUndefined()
|
expect(store.form.ses_1).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -72,7 +69,6 @@ describe("app session cache", () => {
|
|||||||
session_message: Record<string, never[] | undefined>
|
session_message: Record<string, never[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
|
||||||
form: Record<string, FormInfo[] | undefined>
|
form: Record<string, FormInfo[] | undefined>
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
} = {
|
} = {
|
||||||
@@ -83,7 +79,6 @@ describe("app session cache", () => {
|
|||||||
session_message: {},
|
session_message: {},
|
||||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||||
permission: {},
|
permission: {},
|
||||||
question: {},
|
|
||||||
form: {},
|
form: {},
|
||||||
part_text_accum_delta: {},
|
part_text_accum_delta: {},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FormInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { FormInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||||
|
|
||||||
@@ -13,7 +13,6 @@ type SessionCache = {
|
|||||||
session_message: Record<string, SessionMessageInfo[] | undefined>
|
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||||
part: Record<string, Part[] | undefined>
|
part: Record<string, Part[] | undefined>
|
||||||
permission: Record<string, PermissionRequest[] | undefined>
|
permission: Record<string, PermissionRequest[] | undefined>
|
||||||
question: Record<string, QuestionRequest[] | undefined>
|
|
||||||
form?: Record<string, FormInfo[] | undefined>
|
form?: Record<string, FormInfo[] | undefined>
|
||||||
part_text_accum_delta: Record<string, string | undefined>
|
part_text_accum_delta: Record<string, string | undefined>
|
||||||
}
|
}
|
||||||
@@ -38,7 +37,6 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable<stri
|
|||||||
delete store.session_diff[sessionID]
|
delete store.session_diff[sessionID]
|
||||||
delete store.session_status[sessionID]
|
delete store.session_status[sessionID]
|
||||||
delete store.permission[sessionID]
|
delete store.permission[sessionID]
|
||||||
delete store.question[sessionID]
|
|
||||||
if (store.form) delete store.form[sessionID]
|
if (store.form) delete store.form[sessionID]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { Agent, Config, LspStatus, Message, Part, Path, Todo, VcsInfo } fro
|
|||||||
import type {
|
import type {
|
||||||
FileDiffInfo,
|
FileDiffInfo,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
QuestionRequest,
|
|
||||||
ReferenceInfo,
|
ReferenceInfo,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
@@ -50,9 +49,6 @@ export type State = {
|
|||||||
permission: {
|
permission: {
|
||||||
[sessionID: string]: PermissionRequest[]
|
[sessionID: string]: PermissionRequest[]
|
||||||
}
|
}
|
||||||
question: {
|
|
||||||
[sessionID: string]: QuestionRequest[]
|
|
||||||
}
|
|
||||||
mcp_ready: boolean
|
mcp_ready: boolean
|
||||||
mcp: {
|
mcp: {
|
||||||
[name: string]: McpServer["status"]
|
[name: string]: McpServer["status"]
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import type {
|
|||||||
SessionMessageInfo,
|
SessionMessageInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import type { Message, Part, Todo } from "@/types"
|
import type { Message, Part, Todo } from "@/types"
|
||||||
import type { FileDiffInfo, PermissionRequest, QuestionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo, PermissionRequest, SessionStatus } from "@opencode-ai/client/promise"
|
||||||
import { batch } from "solid-js"
|
import { batch } from "solid-js"
|
||||||
import { createStore, produce, reconcile } from "solid-js/store"
|
import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { rootSession } from "@/utils/session-route"
|
import { rootSession } from "@/utils/session-route"
|
||||||
@@ -198,7 +198,6 @@ export function createServerSession(
|
|||||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||||
todo: {} as Record<string, Todo[]>,
|
todo: {} as Record<string, Todo[]>,
|
||||||
permission: {} as Record<string, PermissionRequest[]>,
|
permission: {} as Record<string, PermissionRequest[]>,
|
||||||
question: {} as Record<string, QuestionRequest[]>,
|
|
||||||
form: {} as Record<string, FormInfo[]>,
|
form: {} as Record<string, FormInfo[]>,
|
||||||
pending: {} as Record<string, SessionInboxInfo[]>,
|
pending: {} as Record<string, SessionInboxInfo[]>,
|
||||||
input: {} as Record<string, string[]>,
|
input: {} as Record<string, string[]>,
|
||||||
@@ -281,9 +280,6 @@ export function createServerSession(
|
|||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
...Object.entries(data.question)
|
|
||||||
.filter(([, items]) => items.length > 0)
|
|
||||||
.map(([sessionID]) => sessionID),
|
|
||||||
...Object.entries(data.form)
|
...Object.entries(data.form)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -529,9 +525,6 @@ export function createServerSession(
|
|||||||
...Object.entries(data.permission)
|
...Object.entries(data.permission)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
...Object.entries(data.question)
|
|
||||||
.filter(([, items]) => items.length > 0)
|
|
||||||
.map(([sessionID]) => sessionID),
|
|
||||||
...Object.entries(data.form)
|
...Object.entries(data.form)
|
||||||
.filter(([, items]) => items.length > 0)
|
.filter(([, items]) => items.length > 0)
|
||||||
.map(([sessionID]) => sessionID),
|
.map(([sessionID]) => sessionID),
|
||||||
@@ -1339,36 +1332,6 @@ export function createServerSession(
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
case "question.asked": {
|
|
||||||
const question = event.properties as QuestionRequest
|
|
||||||
const questions = data.question[question.sessionID]
|
|
||||||
if (!questions) {
|
|
||||||
setData("question", question.sessionID, [question])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const result = Binary.search(questions, question.id, (item) => item.id)
|
|
||||||
if (result.found) setData("question", question.sessionID, result.index, reconcile(question))
|
|
||||||
if (!result.found)
|
|
||||||
setData(
|
|
||||||
"question",
|
|
||||||
question.sessionID,
|
|
||||||
produce((draft) => void draft.splice(result.index, 0, question)),
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
case "question.replied":
|
|
||||||
case "question.rejected": {
|
|
||||||
const props = event.properties as { sessionID: string; requestID: string }
|
|
||||||
setData(
|
|
||||||
"question",
|
|
||||||
props.sessionID,
|
|
||||||
produce((draft) => {
|
|
||||||
if (!draft) return
|
|
||||||
const result = Binary.search(draft, props.requestID, (item) => item.id)
|
|
||||||
if (result.found) draft.splice(result.index, 1)
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"
|
|||||||
import {
|
import {
|
||||||
normalizeNewSessionWorktree,
|
normalizeNewSessionWorktree,
|
||||||
resolveNewSessionBranch,
|
resolveNewSessionBranch,
|
||||||
|
resolveNewSessionGit,
|
||||||
resolveNewSessionWorktree,
|
resolveNewSessionWorktree,
|
||||||
} from "./new-session-workspace-controller"
|
} from "./new-session-workspace-controller"
|
||||||
|
|
||||||
@@ -47,4 +48,10 @@ describe("new session workspace selection", () => {
|
|||||||
)
|
)
|
||||||
expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev")
|
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
|
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: {
|
export function createNewSessionWorkspaceController(input: {
|
||||||
selected: () => string | undefined
|
selected: () => string | undefined
|
||||||
setSelected: (worktree: string | undefined) => void
|
setSelected: (worktree: string | undefined) => void
|
||||||
@@ -49,7 +53,10 @@ export function createNewSessionWorkspaceController(input: {
|
|||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const settings = useSettings()
|
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 selected = createMemo(() => {
|
||||||
const project = sync().project
|
const project = sync().project
|
||||||
const worktree = input.selected()
|
const worktree = input.selected()
|
||||||
@@ -110,7 +117,7 @@ export function createNewSessionWorkspaceController(input: {
|
|||||||
const project = sync().project
|
const project = sync().project
|
||||||
return project ? workspaceDirectories(project) : []
|
return project ? workspaceDirectories(project) : []
|
||||||
},
|
},
|
||||||
git: () => sync().project?.vcs === "git",
|
git: visible,
|
||||||
openAll: input.onViewAll,
|
openAll: input.onViewAll,
|
||||||
},
|
},
|
||||||
bar: {
|
bar: {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { $ } from "bun"
|
import { $ } from "bun"
|
||||||
import { readdir } from "node:fs/promises"
|
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { brotliCompressSync, constants } from "node:zlib"
|
import { brotliCompressSync, constants } from "node:zlib"
|
||||||
|
import { collectFiles } from "./files"
|
||||||
|
|
||||||
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
||||||
if (options?.skipBuild) return compress({})
|
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 })
|
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||||
const assets = Object.fromEntries(
|
const assets = Object.fromEntries(
|
||||||
await Promise.all(
|
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"))
|
.filter((key) => !key.endsWith(".map"))
|
||||||
|
.toSorted()
|
||||||
.map(async (key) => {
|
.map(async (key) => {
|
||||||
const source = path.join(root, "dist", key)
|
const source = path.join(root, "dist", key)
|
||||||
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
||||||
@@ -31,16 +33,3 @@ function compress(assets: object) {
|
|||||||
function isText(key: string) {
|
function isText(key: string) {
|
||||||
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
|
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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "
|
|||||||
import { mainConfig } from "../vite.node.config"
|
import { mainConfig } from "../vite.node.config"
|
||||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||||
import { buildAppArchive } from "./app-assets"
|
import { buildAppArchive } from "./app-assets"
|
||||||
|
import { verifyArtifact } from "./verify-artifact"
|
||||||
|
|
||||||
const NODE_VERSION = "26.4.0"
|
const NODE_VERSION = "26.4.0"
|
||||||
const dir = path.resolve(import.meta.dirname, "..")
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
@@ -91,6 +92,7 @@ for (const target of targets) {
|
|||||||
await copyNodeAssets(assets)
|
await copyNodeAssets(assets)
|
||||||
await build(mainConfig(input))
|
await build(mainConfig(input))
|
||||||
await assertTextImportsInlined("dist-node/opencode.mjs")
|
await assertTextImportsInlined("dist-node/opencode.mjs")
|
||||||
|
if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs")
|
||||||
|
|
||||||
const host = target.platform === process.platform && target.arch === process.arch
|
const host = target.platform === process.platform && target.arch === process.arch
|
||||||
if (host) {
|
if (host) {
|
||||||
@@ -139,6 +141,7 @@ for (const target of targets) {
|
|||||||
2,
|
2,
|
||||||
)}\n`,
|
)}\n`,
|
||||||
)
|
)
|
||||||
|
await verifyArtifact(path.join(outdir, name))
|
||||||
if (host) await smoke(output)
|
if (host) await smoke(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
|||||||
import type { BunPlugin } from "bun"
|
import type { BunPlugin } from "bun"
|
||||||
import pkg from "../package.json"
|
import pkg from "../package.json"
|
||||||
import { buildAppArchive } from "./app-assets"
|
import { buildAppArchive } from "./app-assets"
|
||||||
|
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||||
|
|
||||||
const dir = path.resolve(import.meta.dirname, "..")
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
const binary = "opencode2"
|
const binary = "opencode2"
|
||||||
@@ -76,6 +77,16 @@ const appAssetsPlugin: BunPlugin = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const item of targets) {
|
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 parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||||
const parcelWatcherPlugin: BunPlugin = {
|
const parcelWatcherPlugin: BunPlugin = {
|
||||||
name: "parcel-watcher-binding",
|
name: "parcel-watcher-binding",
|
||||||
@@ -92,7 +103,7 @@ for (const item of targets) {
|
|||||||
const result = await Bun.build({
|
const result = await Bun.build({
|
||||||
entrypoints: ["./src/index.ts"],
|
entrypoints: ["./src/index.ts"],
|
||||||
tsconfig: "./tsconfig.json",
|
tsconfig: "./tsconfig.json",
|
||||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||||
external: ["node-gyp"],
|
external: ["node-gyp"],
|
||||||
format: "esm",
|
format: "esm",
|
||||||
minify: true,
|
minify: true,
|
||||||
@@ -123,6 +134,7 @@ for (const item of targets) {
|
|||||||
for (const log of result.logs) console.error(log)
|
for (const log of result.logs) console.error(log)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
verifySimulationGraph(simulationInputs)
|
||||||
|
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(outdir, name, "package.json"),
|
path.join(outdir, name, "package.json"),
|
||||||
@@ -139,6 +151,7 @@ for (const item of targets) {
|
|||||||
2,
|
2,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
await verifyArtifact(path.join(outdir, name))
|
||||||
}
|
}
|
||||||
|
|
||||||
function targetName(item: (typeof allTargets)[number]) {
|
function targetName(item: (typeof allTargets)[number]) {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
import { createHash } from "node:crypto"
|
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 path from "node:path"
|
||||||
import { fileURLToPath } from "node:url"
|
import { fileURLToPath } from "node:url"
|
||||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||||
|
import { collectFiles } from "./files"
|
||||||
|
|
||||||
const dir = path.resolve(import.meta.dirname, "..")
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
|
|
||||||
@@ -16,17 +17,6 @@ export type NodeAsset = {
|
|||||||
readonly source: string
|
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) {
|
export async function collectNodeAssets(target: NodeTarget) {
|
||||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||||
@@ -51,7 +41,7 @@ export async function collectNodeAssets(target: NodeTarget) {
|
|||||||
key,
|
key,
|
||||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(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"))
|
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||||
.map((relative) => ({
|
.map((relative) => ({
|
||||||
key: `${target.nodePtyPackage}/${relative}`,
|
key: `${target.nodePtyPackage}/${relative}`,
|
||||||
@@ -85,5 +75,7 @@ export async function copyNodeAssets(assets: readonly NodeAsset[]) {
|
|||||||
|
|
||||||
export async function seaAssetMap() {
|
export async function seaAssetMap() {
|
||||||
const root = path.join(dir, "dist-node", "assets")
|
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)]),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -30,7 +30,6 @@ describe("debug config command", () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ type: "file", path: path.join(project, "opencode.json") },
|
|
||||||
]
|
]
|
||||||
let requested: URL | undefined
|
let requested: URL | undefined
|
||||||
const authorization: Array<string | null> = []
|
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"
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -240,8 +240,6 @@ async function run(input: {
|
|||||||
})()
|
})()
|
||||||
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
||||||
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
||||||
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
|
||||||
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
|
|
||||||
spyOn(sdk.form, "list").mockImplementation(
|
spyOn(sdk.form, "list").mockImplementation(
|
||||||
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
||||||
)
|
)
|
||||||
@@ -435,8 +433,6 @@ describe("runNonInteractivePrompt", () => {
|
|||||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||||
location: { directory: "/work tree", workspace: "wrk_1" },
|
location: { directory: "/work tree", workspace: "wrk_1" },
|
||||||
})
|
})
|
||||||
expect(sdk.question.list).not.toHaveBeenCalled()
|
|
||||||
expect(sdk.question.reject).not.toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("attach mode cancels only session-owned forms", async () => {
|
test("attach mode cancels only session-owned forms", async () => {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createRequire } from "node:module"
|
|||||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||||
import solid from "vite-plugin-solid"
|
import solid from "vite-plugin-solid"
|
||||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
|
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
|
||||||
|
import { verifySimulationGraph } from "./script/verify-artifact"
|
||||||
|
|
||||||
const dir = import.meta.dirname
|
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 {
|
function fffNodePlugin(): Plugin {
|
||||||
return {
|
return {
|
||||||
name: "opencode:fff-node",
|
name: "opencode:fff-node",
|
||||||
@@ -240,6 +250,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
|||||||
rawTextPlugin(),
|
rawTextPlugin(),
|
||||||
runtimeRequirePlugin(),
|
runtimeRequirePlugin(),
|
||||||
fffNodePlugin(),
|
fffNodePlugin(),
|
||||||
|
simulationGraphPlugin(),
|
||||||
solid({
|
solid({
|
||||||
solid: {
|
solid: {
|
||||||
generate: "universal",
|
generate: "universal",
|
||||||
@@ -255,6 +266,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
|||||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||||
|
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
|
||||||
},
|
},
|
||||||
ssr: { noExternal: true },
|
ssr: { noExternal: true },
|
||||||
build: {
|
build: {
|
||||||
@@ -264,7 +276,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
|||||||
emptyOutDir: false,
|
emptyOutDir: false,
|
||||||
minify: true,
|
minify: true,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
external: [/^@opencode-ai\/simulation(?:\/|$)/],
|
|
||||||
output: output("opencode.mjs", nodePrelude(input)),
|
output: output("opencode.mjs", nodePrelude(input)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
|
|||||||
import type { Command } from "@opencode-ai/schema/command"
|
import type { Command } from "@opencode-ai/schema/command"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||||
import type { Pty } from "@opencode-ai/schema/pty"
|
import type { Pty } from "@opencode-ai/schema/pty"
|
||||||
import type { Question } from "@opencode-ai/schema/question"
|
|
||||||
import type { Reference } from "@opencode-ai/schema/reference"
|
import type { Reference } from "@opencode-ai/schema/reference"
|
||||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||||
@@ -1503,69 +1502,38 @@ export interface ShellApi<E = never> {
|
|||||||
export type Endpoint22_0Input = {
|
export type Endpoint22_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||||
export type QuestionRequestListOperation<E = never> = (
|
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
|
||||||
input?: Endpoint22_0Input,
|
|
||||||
) => Effect.Effect<Endpoint22_0Output, E>
|
|
||||||
|
|
||||||
export type Endpoint22_1Input = { readonly sessionID: Session.ID }
|
|
||||||
export type Endpoint22_1Output = ReadonlyArray<Question.Request>
|
|
||||||
export type QuestionListOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
|
|
||||||
|
|
||||||
export type Endpoint22_2Input = {
|
|
||||||
readonly sessionID: Session.ID
|
|
||||||
readonly requestID: Question.ID
|
|
||||||
readonly answers: ReadonlyArray<Question.Answer>
|
|
||||||
}
|
|
||||||
export type Endpoint22_2Output = void
|
|
||||||
export type QuestionReplyOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
|
|
||||||
|
|
||||||
export type Endpoint22_3Input = { readonly sessionID: Session.ID; readonly requestID: Question.ID }
|
|
||||||
export type Endpoint22_3Output = void
|
|
||||||
export type QuestionRejectOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
|
|
||||||
|
|
||||||
export interface QuestionApi<E = never> {
|
|
||||||
readonly request: { readonly list: QuestionRequestListOperation<E> }
|
|
||||||
readonly list: QuestionListOperation<E>
|
|
||||||
readonly reply: QuestionReplyOperation<E>
|
|
||||||
readonly reject: QuestionRejectOperation<E>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Endpoint23_0Input = {
|
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}
|
|
||||||
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
|
||||||
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
|
||||||
|
|
||||||
export interface ReferenceApi<E = never> {
|
export interface ReferenceApi<E = never> {
|
||||||
readonly list: ReferenceListOperation<E>
|
readonly list: ReferenceListOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
export type Endpoint23_0Input = { readonly projectID: Project.ID }
|
||||||
export type Endpoint24_0Output = Worktree.List
|
export type Endpoint23_0Output = Worktree.List
|
||||||
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||||
|
|
||||||
export type Endpoint24_1Input = {
|
export type Endpoint23_1Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly strategy: Worktree.StrategyID
|
readonly strategy: Worktree.StrategyID
|
||||||
readonly from?: AbsolutePath | undefined
|
readonly from?: AbsolutePath | undefined
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly name?: string | undefined
|
readonly name?: string | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint24_1Output = Worktree.Info
|
export type Endpoint23_1Output = Worktree.Info
|
||||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
|
||||||
|
|
||||||
export type Endpoint24_2Input = {
|
export type Endpoint23_2Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
}
|
}
|
||||||
export type Endpoint24_2Output = void
|
export type Endpoint23_2Output = void
|
||||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
|
||||||
|
|
||||||
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
export type Endpoint23_3Input = { readonly projectID: Project.ID }
|
||||||
export type Endpoint24_3Output = void
|
export type Endpoint23_3Output = void
|
||||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
|
||||||
|
|
||||||
export interface WorktreeApi<E = never> {
|
export interface WorktreeApi<E = never> {
|
||||||
readonly list: WorktreeListOperation<E>
|
readonly list: WorktreeListOperation<E>
|
||||||
@@ -1574,25 +1542,25 @@ export interface WorktreeApi<E = never> {
|
|||||||
readonly refresh: WorktreeRefreshOperation<E>
|
readonly refresh: WorktreeRefreshOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint25_0Input = {
|
export type Endpoint24_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||||
|
|
||||||
export type Endpoint25_1Input = {
|
export type Endpoint24_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||||
|
|
||||||
export type Endpoint25_2Input = {
|
export type Endpoint24_2Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
readonly mode: Vcs.Mode
|
readonly mode: Vcs.Mode
|
||||||
readonly context?: number | undefined
|
readonly context?: number | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||||
|
|
||||||
export interface VcsApi<E = never> {
|
export interface VcsApi<E = never> {
|
||||||
readonly get: VcsGetOperation<E>
|
readonly get: VcsGetOperation<E>
|
||||||
@@ -1600,20 +1568,20 @@ export interface VcsApi<E = never> {
|
|||||||
readonly diff: VcsDiffOperation<E>
|
readonly diff: VcsDiffOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
|
||||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
||||||
|
|
||||||
export type Endpoint26_1Input = {
|
export type Endpoint25_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint26_1Output = void
|
export type Endpoint25_1Output = void
|
||||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||||
|
|
||||||
export interface DebugApi<E = never> {
|
export interface DebugApi<E = never> {
|
||||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint27_0Output =
|
export type Endpoint26_0Output =
|
||||||
| { readonly status: "required" | "completed" }
|
| { readonly status: "required" | "completed" }
|
||||||
| {
|
| {
|
||||||
readonly status: "running"
|
readonly status: "running"
|
||||||
@@ -1624,36 +1592,36 @@ export type Endpoint27_0Output =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
| { readonly status: "error"; readonly error: string }
|
| { readonly status: "error"; readonly error: string }
|
||||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||||
|
|
||||||
export interface MigrationApi<E = never> {
|
export interface MigrationApi<E = never> {
|
||||||
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint28_0Input = {
|
export type Endpoint27_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||||
|
|
||||||
export type Endpoint28_1Input = {
|
export type Endpoint27_1Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
readonly query: string
|
readonly query: string
|
||||||
readonly providerID?: WebSearch.ID | undefined
|
readonly providerID?: WebSearch.ID | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
|
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||||
|
|
||||||
export interface WebsearchApi<E = never> {
|
export interface WebsearchApi<E = never> {
|
||||||
readonly providers: WebsearchProvidersOperation<E>
|
readonly providers: WebsearchProvidersOperation<E>
|
||||||
readonly query: WebsearchQueryOperation<E>
|
readonly query: WebsearchQueryOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint29_0Input = {
|
export type Endpoint28_0Input = {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
}
|
}
|
||||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
|
||||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||||
|
|
||||||
export interface ConfigApi<E = never> {
|
export interface ConfigApi<E = never> {
|
||||||
readonly get: ConfigGetOperation<E>
|
readonly get: ConfigGetOperation<E>
|
||||||
@@ -1682,7 +1650,6 @@ export interface AppApi<E = never> {
|
|||||||
readonly event: EventApi<E>
|
readonly event: EventApi<E>
|
||||||
readonly pty: PtyApi<E>
|
readonly pty: PtyApi<E>
|
||||||
readonly shell: ShellApi<E>
|
readonly shell: ShellApi<E>
|
||||||
readonly question: QuestionApi<E>
|
|
||||||
readonly reference: ReferenceApi<E>
|
readonly reference: ReferenceApi<E>
|
||||||
readonly worktree: WorktreeApi<E>
|
readonly worktree: WorktreeApi<E>
|
||||||
readonly vcs: VcsApi<E>
|
readonly vcs: VcsApi<E>
|
||||||
|
|||||||
@@ -200,38 +200,30 @@ import type {
|
|||||||
Endpoint21_5Output,
|
Endpoint21_5Output,
|
||||||
Endpoint22_0Input,
|
Endpoint22_0Input,
|
||||||
Endpoint22_0Output,
|
Endpoint22_0Output,
|
||||||
Endpoint22_1Input,
|
|
||||||
Endpoint22_1Output,
|
|
||||||
Endpoint22_2Input,
|
|
||||||
Endpoint22_2Output,
|
|
||||||
Endpoint22_3Input,
|
|
||||||
Endpoint22_3Output,
|
|
||||||
Endpoint23_0Input,
|
Endpoint23_0Input,
|
||||||
Endpoint23_0Output,
|
Endpoint23_0Output,
|
||||||
|
Endpoint23_1Input,
|
||||||
|
Endpoint23_1Output,
|
||||||
|
Endpoint23_2Input,
|
||||||
|
Endpoint23_2Output,
|
||||||
|
Endpoint23_3Input,
|
||||||
|
Endpoint23_3Output,
|
||||||
Endpoint24_0Input,
|
Endpoint24_0Input,
|
||||||
Endpoint24_0Output,
|
Endpoint24_0Output,
|
||||||
Endpoint24_1Input,
|
Endpoint24_1Input,
|
||||||
Endpoint24_1Output,
|
Endpoint24_1Output,
|
||||||
Endpoint24_2Input,
|
Endpoint24_2Input,
|
||||||
Endpoint24_2Output,
|
Endpoint24_2Output,
|
||||||
Endpoint24_3Input,
|
|
||||||
Endpoint24_3Output,
|
|
||||||
Endpoint25_0Input,
|
|
||||||
Endpoint25_0Output,
|
Endpoint25_0Output,
|
||||||
Endpoint25_1Input,
|
Endpoint25_1Input,
|
||||||
Endpoint25_1Output,
|
Endpoint25_1Output,
|
||||||
Endpoint25_2Input,
|
|
||||||
Endpoint25_2Output,
|
|
||||||
Endpoint26_0Output,
|
Endpoint26_0Output,
|
||||||
Endpoint26_1Input,
|
Endpoint27_0Input,
|
||||||
Endpoint26_1Output,
|
|
||||||
Endpoint27_0Output,
|
Endpoint27_0Output,
|
||||||
|
Endpoint27_1Input,
|
||||||
|
Endpoint27_1Output,
|
||||||
Endpoint28_0Input,
|
Endpoint28_0Input,
|
||||||
Endpoint28_0Output,
|
Endpoint28_0Output,
|
||||||
Endpoint28_1Input,
|
|
||||||
Endpoint28_1Output,
|
|
||||||
Endpoint29_0Input,
|
|
||||||
Endpoint29_0Output,
|
|
||||||
} from "../api/api.js"
|
} from "../api/api.js"
|
||||||
import { ClientError } from "./client-error.js"
|
import { ClientError } from "./client-error.js"
|
||||||
|
|
||||||
@@ -1158,145 +1150,110 @@ const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
|
|||||||
remove: Endpoint21_5(raw),
|
remove: Endpoint21_5(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint22_0 = (raw: RawClient["server.question"]) => (input?: Endpoint22_0Input) =>
|
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
|
||||||
preserveEffect<Endpoint22_0Output>()(
|
preserveEffect<Endpoint22_0Output>()(
|
||||||
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint22_1 = (raw: RawClient["server.question"]) => (input: Endpoint22_1Input) =>
|
|
||||||
preserveEffect<Endpoint22_1Output>()(
|
|
||||||
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
Effect.map((value) => value.data),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint22_2 = (raw: RawClient["server.question"]) => (input: Endpoint22_2Input) =>
|
|
||||||
preserveEffect<Endpoint22_2Output>()(
|
|
||||||
raw["session.question.reply"]({
|
|
||||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
|
||||||
payload: { answers: input["answers"] },
|
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
|
||||||
)
|
|
||||||
|
|
||||||
const Endpoint22_3 = (raw: RawClient["server.question"]) => (input: Endpoint22_3Input) =>
|
|
||||||
preserveEffect<Endpoint22_3Output>()(
|
|
||||||
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
|
||||||
Effect.mapError(mapClientError),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const adaptGroup22 = (raw: RawClient["server.question"]) => ({
|
|
||||||
request: { list: Endpoint22_0(raw) },
|
|
||||||
list: Endpoint22_1(raw),
|
|
||||||
reply: Endpoint22_2(raw),
|
|
||||||
reject: Endpoint22_3(raw),
|
|
||||||
})
|
|
||||||
|
|
||||||
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
|
|
||||||
preserveEffect<Endpoint23_0Output>()(
|
|
||||||
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
|
||||||
|
|
||||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
|
||||||
preserveEffect<Endpoint24_0Output>()(
|
preserveEffect<Endpoint23_0Output>()(
|
||||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
|
||||||
preserveEffect<Endpoint24_1Output>()(
|
preserveEffect<Endpoint23_1Output>()(
|
||||||
raw["worktree.create"]({
|
raw["worktree.create"]({
|
||||||
params: { projectID: input["projectID"] },
|
params: { projectID: input["projectID"] },
|
||||||
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
|
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
|
||||||
preserveEffect<Endpoint24_2Output>()(
|
preserveEffect<Endpoint23_2Output>()(
|
||||||
raw["worktree.remove"]({
|
raw["worktree.remove"]({
|
||||||
params: { projectID: input["projectID"] },
|
params: { projectID: input["projectID"] },
|
||||||
payload: { directory: input["directory"], force: input["force"] },
|
payload: { directory: input["directory"], force: input["force"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
|
||||||
preserveEffect<Endpoint24_3Output>()(
|
preserveEffect<Endpoint23_3Output>()(
|
||||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
|
||||||
list: Endpoint24_0(raw),
|
list: Endpoint23_0(raw),
|
||||||
create: Endpoint24_1(raw),
|
create: Endpoint23_1(raw),
|
||||||
remove: Endpoint24_2(raw),
|
remove: Endpoint23_2(raw),
|
||||||
refresh: Endpoint24_3(raw),
|
refresh: Endpoint23_3(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
|
||||||
preserveEffect<Endpoint25_0Output>()(
|
preserveEffect<Endpoint24_0Output>()(
|
||||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
|
||||||
preserveEffect<Endpoint25_1Output>()(
|
preserveEffect<Endpoint24_1Output>()(
|
||||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
|
||||||
preserveEffect<Endpoint25_2Output>()(
|
preserveEffect<Endpoint24_2Output>()(
|
||||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||||
Effect.mapError(mapClientError),
|
Effect.mapError(mapClientError),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
|
||||||
get: Endpoint25_0(raw),
|
get: Endpoint24_0(raw),
|
||||||
status: Endpoint25_1(raw),
|
status: Endpoint24_1(raw),
|
||||||
diff: Endpoint25_2(raw),
|
diff: Endpoint24_2(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||||
|
|
||||||
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
|
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
|
||||||
preserveEffect<Endpoint26_1Output>()(
|
preserveEffect<Endpoint25_1Output>()(
|
||||||
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
||||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
|
||||||
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||||
|
|
||||||
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
|
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
|
||||||
|
|
||||||
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||||
preserveEffect<Endpoint28_0Output>()(
|
preserveEffect<Endpoint27_0Output>()(
|
||||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||||
preserveEffect<Endpoint28_1Output>()(
|
preserveEffect<Endpoint27_1Output>()(
|
||||||
raw["websearch.query"]({
|
raw["websearch.query"]({
|
||||||
query: { location: input["location"] },
|
query: { location: input["location"] },
|
||||||
payload: { query: input["query"], providerID: input["providerID"] },
|
payload: { query: input["query"], providerID: input["providerID"] },
|
||||||
}).pipe(Effect.mapError(mapClientError)),
|
}).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||||
providers: Endpoint28_0(raw),
|
providers: Endpoint27_0(raw),
|
||||||
query: Endpoint28_1(raw),
|
query: Endpoint27_1(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
|
||||||
preserveEffect<Endpoint29_0Output>()(
|
preserveEffect<Endpoint28_0Output>()(
|
||||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({
|
const adaptClient = (raw: RawClient) => ({
|
||||||
health: adaptGroup0(raw["server.health"]),
|
health: adaptGroup0(raw["server.health"]),
|
||||||
@@ -1321,14 +1278,13 @@ const adaptClient = (raw: RawClient) => ({
|
|||||||
event: adaptGroup19(raw["server.event"]),
|
event: adaptGroup19(raw["server.event"]),
|
||||||
pty: adaptGroup20(raw["server.pty"]),
|
pty: adaptGroup20(raw["server.pty"]),
|
||||||
shell: adaptGroup21(raw["server.shell"]),
|
shell: adaptGroup21(raw["server.shell"]),
|
||||||
question: adaptGroup22(raw["server.question"]),
|
reference: adaptGroup22(raw["server.reference"]),
|
||||||
reference: adaptGroup23(raw["server.reference"]),
|
worktree: adaptGroup23(raw["server.worktree"]),
|
||||||
worktree: adaptGroup24(raw["server.worktree"]),
|
vcs: adaptGroup24(raw["server.vcs"]),
|
||||||
vcs: adaptGroup25(raw["server.vcs"]),
|
debug: adaptGroup25(raw["server.debug"]),
|
||||||
debug: adaptGroup26(raw["server.debug"]),
|
migration: adaptGroup26(raw["server.migration"]),
|
||||||
migration: adaptGroup27(raw["server.migration"]),
|
websearch: adaptGroup27(raw["server.websearch"]),
|
||||||
websearch: adaptGroup28(raw["server.websearch"]),
|
config: adaptGroup28(raw["server.config"]),
|
||||||
config: adaptGroup29(raw["server.config"]),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
|
|||||||
@@ -194,14 +194,6 @@ import type {
|
|||||||
ShellOutputOutput,
|
ShellOutputOutput,
|
||||||
ShellRemoveInput,
|
ShellRemoveInput,
|
||||||
ShellRemoveOutput,
|
ShellRemoveOutput,
|
||||||
QuestionRequestListInput,
|
|
||||||
QuestionRequestListOutput,
|
|
||||||
QuestionListInput,
|
|
||||||
QuestionListOutput,
|
|
||||||
QuestionReplyInput,
|
|
||||||
QuestionReplyOutput,
|
|
||||||
QuestionRejectInput,
|
|
||||||
QuestionRejectOutput,
|
|
||||||
ReferenceListInput,
|
ReferenceListInput,
|
||||||
ReferenceListOutput,
|
ReferenceListOutput,
|
||||||
WorktreeListInput,
|
WorktreeListInput,
|
||||||
@@ -1660,56 +1652,6 @@ export function make(options: ClientOptions) {
|
|||||||
requestOptions,
|
requestOptions,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
question: {
|
|
||||||
request: {
|
|
||||||
list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<QuestionRequestListOutput>(
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: `/api/question/request`,
|
|
||||||
query: { location: input?.["location"] },
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [401, 400],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<{ readonly data: QuestionListOutput }>(
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
|
|
||||||
successStatus: 200,
|
|
||||||
declaredStatuses: [404, 400, 401],
|
|
||||||
empty: false,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
).then((value) => value.data),
|
|
||||||
reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<QuestionReplyOutput>(
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
|
|
||||||
body: { answers: input["answers"] },
|
|
||||||
successStatus: 204,
|
|
||||||
declaredStatuses: [404, 400, 401],
|
|
||||||
empty: true,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
),
|
|
||||||
reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
|
|
||||||
request<QuestionRejectOutput>(
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
|
|
||||||
successStatus: 204,
|
|
||||||
declaredStatuses: [404, 400, 401],
|
|
||||||
empty: true,
|
|
||||||
},
|
|
||||||
requestOptions,
|
|
||||||
),
|
|
||||||
},
|
|
||||||
reference: {
|
reference: {
|
||||||
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
|
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
|
||||||
request<ReferenceListOutput>(
|
request<ReferenceListOutput>(
|
||||||
@@ -1729,7 +1671,7 @@ export function make(options: ClientOptions) {
|
|||||||
request<WorktreeListOutput>(
|
request<WorktreeListOutput>(
|
||||||
{
|
{
|
||||||
method: "GET",
|
method: "GET",
|
||||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||||
successStatus: 200,
|
successStatus: 200,
|
||||||
declaredStatuses: [401, 400],
|
declaredStatuses: [401, 400],
|
||||||
empty: false,
|
empty: false,
|
||||||
@@ -1740,7 +1682,7 @@ export function make(options: ClientOptions) {
|
|||||||
request<WorktreeCreateOutput>(
|
request<WorktreeCreateOutput>(
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||||
body: {
|
body: {
|
||||||
strategy: input["strategy"],
|
strategy: input["strategy"],
|
||||||
from: input["from"],
|
from: input["from"],
|
||||||
@@ -1757,7 +1699,7 @@ export function make(options: ClientOptions) {
|
|||||||
request<WorktreeRemoveOutput>(
|
request<WorktreeRemoveOutput>(
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree`,
|
path: `/api/worktree/${encodeURIComponent(input.projectID)}`,
|
||||||
body: { directory: input["directory"], force: input["force"] },
|
body: { directory: input["directory"], force: input["force"] },
|
||||||
successStatus: 204,
|
successStatus: 204,
|
||||||
declaredStatuses: [400, 401],
|
declaredStatuses: [400, 401],
|
||||||
@@ -1769,7 +1711,7 @@ export function make(options: ClientOptions) {
|
|||||||
request<WorktreeRefreshOutput>(
|
request<WorktreeRefreshOutput>(
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
path: `/api/experimental/project/${encodeURIComponent(input.projectID)}/worktree/refresh`,
|
path: `/api/worktree/${encodeURIComponent(input.projectID)}/refresh`,
|
||||||
successStatus: 204,
|
successStatus: 204,
|
||||||
declaredStatuses: [400, 401],
|
declaredStatuses: [400, 401],
|
||||||
empty: true,
|
empty: true,
|
||||||
|
|||||||
@@ -324,12 +324,6 @@ export type Pty = {
|
|||||||
exitCode?: number
|
exitCode?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionOption = { label: string; description: string }
|
|
||||||
|
|
||||||
export type QuestionTool = { messageID: string; id: string }
|
|
||||||
|
|
||||||
export type QuestionAnswer = Array<string>
|
|
||||||
|
|
||||||
export type FormMetadata1 = { [x: string]: any }
|
export type FormMetadata1 = { [x: string]: any }
|
||||||
|
|
||||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||||
@@ -910,15 +904,6 @@ export type ShellDeleted = {
|
|||||||
data: { id: string }
|
data: { id: string }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionRejected = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "question.rejected"
|
|
||||||
location?: LocationRef
|
|
||||||
data: { sessionID: string; requestID: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FormCancelled = {
|
export type FormCancelled = {
|
||||||
id: string
|
id: string
|
||||||
created: number
|
created: number
|
||||||
@@ -1425,23 +1410,6 @@ export type PtyUpdated = {
|
|||||||
data: { info: Pty }
|
data: { info: Pty }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionInfo = {
|
|
||||||
question: string
|
|
||||||
header: string
|
|
||||||
options: Array<QuestionOption>
|
|
||||||
multiple?: boolean
|
|
||||||
custom?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionReplied = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "question.replied"
|
|
||||||
location?: LocationRef
|
|
||||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FormStringField1 = {
|
export type FormStringField1 = {
|
||||||
key: string
|
key: string
|
||||||
title?: string
|
title?: string
|
||||||
@@ -1682,17 +1650,6 @@ export type FormReplied = {
|
|||||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QuestionAsked = {
|
|
||||||
id: string
|
|
||||||
created: number
|
|
||||||
metadata?: { [x: string]: any }
|
|
||||||
type: "question.asked"
|
|
||||||
location?: LocationRef
|
|
||||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
|
||||||
|
|
||||||
export type FormField1 =
|
export type FormField1 =
|
||||||
| FormStringField1
|
| FormStringField1
|
||||||
| FormNumberField1
|
| FormNumberField1
|
||||||
@@ -1830,7 +1787,7 @@ export type ConfigEntry =
|
|||||||
| { repository: string; branch?: string; description?: string; hidden?: boolean }
|
| { repository: string; branch?: string; description?: string; hidden?: boolean }
|
||||||
| { path: 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 } }>
|
plugins?: Array<string | { package: string; options?: { [x: string]: JsonValue } }>
|
||||||
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
warming?: boolean | { prompt?: string; interval?: string; duration?: string }
|
||||||
providers?: {
|
providers?: {
|
||||||
@@ -1878,13 +1835,13 @@ export type ConfigEntry =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
experimental?: {
|
experimental?: {
|
||||||
|
portable_shell_scanner?: boolean
|
||||||
subagent_depth?: number
|
subagent_depth?: number
|
||||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
| { type: "directory"; path: string }
|
| { type: "directory"; path: string }
|
||||||
| { type: "file"; path: string }
|
|
||||||
| { type: "agents"; path: string }
|
| { type: "agents"; path: string }
|
||||||
| { type: "claude"; path: string }
|
| { type: "claude"; path: string }
|
||||||
|
|
||||||
@@ -2114,9 +2071,6 @@ export type V2Event =
|
|||||||
| ShellCreated
|
| ShellCreated
|
||||||
| ShellExited
|
| ShellExited
|
||||||
| ShellDeleted
|
| ShellDeleted
|
||||||
| QuestionAsked
|
|
||||||
| QuestionReplied
|
|
||||||
| QuestionRejected
|
|
||||||
| FormCreated
|
| FormCreated
|
||||||
| FormReplied
|
| FormReplied
|
||||||
| FormCancelled
|
| FormCancelled
|
||||||
@@ -2296,14 +2250,6 @@ export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly
|
|||||||
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
|
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
|
||||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
|
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
|
||||||
|
|
||||||
export type QuestionNotFoundError = {
|
|
||||||
readonly _tag: "QuestionNotFoundError"
|
|
||||||
readonly requestID: string
|
|
||||||
readonly message: string
|
|
||||||
}
|
|
||||||
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
|
|
||||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
|
|
||||||
|
|
||||||
export type WorktreeError = {
|
export type WorktreeError = {
|
||||||
readonly name: "WorktreeError"
|
readonly name: "WorktreeError"
|
||||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||||
@@ -5592,36 +5538,6 @@ export type ShellRemoveInput = {
|
|||||||
|
|
||||||
export type ShellRemoveOutput = void
|
export type ShellRemoveOutput = void
|
||||||
|
|
||||||
export type QuestionRequestListInput = {
|
|
||||||
readonly location?: {
|
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
|
||||||
}["location"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionRequestListOutput = {
|
|
||||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
|
||||||
data: Array<QuestionRequest>
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
|
||||||
|
|
||||||
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
|
|
||||||
|
|
||||||
export type QuestionReplyInput = {
|
|
||||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
|
||||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
|
||||||
readonly answers: { readonly answers: ReadonlyArray<ReadonlyArray<string>> }["answers"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionReplyOutput = void
|
|
||||||
|
|
||||||
export type QuestionRejectInput = {
|
|
||||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
|
||||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionRejectOutput = void
|
|
||||||
|
|
||||||
export type ReferenceListInput = {
|
export type ReferenceListInput = {
|
||||||
readonly location?: {
|
readonly location?: {
|
||||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||||
|
|||||||
@@ -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({
|
const client = OpenCode.make({
|
||||||
baseUrl: "http://localhost:3000",
|
baseUrl: "http://localhost:3000",
|
||||||
@@ -283,10 +282,10 @@ test("worktree methods use the global project contract", async () => {
|
|||||||
await client.worktree.refresh({ projectID: "proj_test" })
|
await client.worktree.refresh({ projectID: "proj_test" })
|
||||||
|
|
||||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||||
["GET", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
["GET", "http://localhost:3000/api/worktree/proj_test"],
|
||||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
["POST", "http://localhost:3000/api/worktree/proj_test"],
|
||||||
["DELETE", "http://localhost:3000/api/experimental/project/proj_test/worktree"],
|
["DELETE", "http://localhost:3000/api/worktree/proj_test"],
|
||||||
["POST", "http://localhost:3000/api/experimental/project/proj_test/worktree/refresh"],
|
["POST", "http://localhost:3000/api/worktree/proj_test/refresh"],
|
||||||
])
|
])
|
||||||
expect(await requests[1]?.json()).toEqual({
|
expect(await requests[1]?.json()).toEqual({
|
||||||
strategy: "git",
|
strategy: "git",
|
||||||
|
|||||||
@@ -80,6 +80,7 @@
|
|||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@types/which": "3.0.4",
|
"@types/which": "3.0.4",
|
||||||
|
"@opencode-ai/shell-scan": "workspace:*",
|
||||||
"@parcel/watcher-darwin-arm64": "2.5.1",
|
"@parcel/watcher-darwin-arm64": "2.5.1",
|
||||||
"@parcel/watcher-darwin-x64": "2.5.1",
|
"@parcel/watcher-darwin-x64": "2.5.1",
|
||||||
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
"@parcel/watcher-linux-arm64-glibc": "2.5.1",
|
||||||
|
|||||||
@@ -20,6 +20,16 @@ const result = await Bun.build({
|
|||||||
format: "esm",
|
format: "esm",
|
||||||
packages: "external",
|
packages: "external",
|
||||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
|
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,
|
splitting: true,
|
||||||
loader: {
|
loader: {
|
||||||
".txt": "text",
|
".txt": "text",
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
|||||||
transport: {
|
transport: {
|
||||||
id: "ai-sdk",
|
id: "ai-sdk",
|
||||||
prepare: (input) => Effect.succeed(input.body),
|
prepare: (input) => Effect.succeed(input.body),
|
||||||
frames: () => Stream.empty,
|
execute: () => Effect.succeed({ frames: Stream.empty }),
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
headers: info.headers,
|
headers: info.headers,
|
||||||
|
|||||||
+102
-17
@@ -4,13 +4,14 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|||||||
import path from "path"
|
import path from "path"
|
||||||
import { isDeepStrictEqual } from "node:util"
|
import { isDeepStrictEqual } from "node:util"
|
||||||
import { type ParseError, parse } from "jsonc-parser"
|
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 { Context, Effect, Layer, Option, PubSub, Ref, Schema, Semaphore, Stream } from "effect"
|
||||||
|
import { produce, type Draft } from "immer"
|
||||||
import {
|
import {
|
||||||
AgentsDirectory,
|
AgentsDirectory,
|
||||||
ClaudeDirectory,
|
ClaudeDirectory,
|
||||||
Directory,
|
Directory,
|
||||||
Document,
|
Document,
|
||||||
File,
|
|
||||||
Info,
|
Info,
|
||||||
type Entry,
|
type Entry,
|
||||||
Event,
|
Event,
|
||||||
@@ -36,6 +37,8 @@ export function latest<K extends keyof Info>(entries: readonly Entry[], key: K):
|
|||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
/** Returns location config documents and discovery sources from lowest to highest priority. */
|
||||||
readonly entries: () => Effect.Effect<Entry[]>
|
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
|
* Streams raw filesystem updates under config roots. Config owns root
|
||||||
* topology and watch reconciliation; domain owners filter this feed for the
|
* topology and watch reconciliation; domain owners filter this feed for the
|
||||||
@@ -44,6 +47,11 @@ export interface Interface {
|
|||||||
readonly changes: () => Stream.Stream<Watcher.Update>
|
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({
|
export const Options = Schema.Struct({
|
||||||
project: Schema.optional(Schema.Boolean),
|
project: Schema.optional(Schema.Boolean),
|
||||||
file: Schema.optional(Schema.String),
|
file: Schema.optional(Schema.String),
|
||||||
@@ -70,6 +78,19 @@ export const testLayer = (initial: Entry[] = []) =>
|
|||||||
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
const updates = yield* PubSub.unbounded<Watcher.Update>()
|
||||||
const service = Test.of({
|
const service = Test.of({
|
||||||
entries: () => Ref.get(entries),
|
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),
|
changes: () => Stream.fromPubSub(updates),
|
||||||
setEntries: (next) => Ref.set(entries, next),
|
setEntries: (next) => Ref.set(entries, next),
|
||||||
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
|
emitChange: (update) => PubSub.publish(updates, update).pipe(Effect.asVoid),
|
||||||
@@ -91,6 +112,7 @@ export const layer = (options?: Options) =>
|
|||||||
const wellknown = yield* WellKnown.Service
|
const wellknown = yield* WellKnown.Service
|
||||||
const names = ["opencode.json", "opencode.jsonc"]
|
const names = ["opencode.json", "opencode.jsonc"]
|
||||||
const reloadLock = Semaphore.makeUnsafe(1)
|
const reloadLock = Semaphore.makeUnsafe(1)
|
||||||
|
const fileTargets = new Set<AbsolutePath>()
|
||||||
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
const decodeOptions = { errors: "all", onExcessProperty: "ignore", propertyOrder: "original" } as const
|
||||||
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
const decodeInfo = Schema.decodeUnknownOption(Info, decodeOptions)
|
||||||
const parseInfo = Effect.fn("Config.parseInfo")(function* (text: string, source: string) {
|
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 substituted = yield* ConfigVariable.substitute({ type: "path", path: filepath, text })
|
||||||
const info = yield* parseInfo(substituted, filepath)
|
const info = yield* parseInfo(substituted, filepath)
|
||||||
if (!info) return
|
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* () {
|
const loadWellknown = Effect.fn("Config.loadWellknown")(function* () {
|
||||||
@@ -224,25 +246,18 @@ export const layer = (options?: Options) =>
|
|||||||
const directPaths = discovered
|
const directPaths = discovered
|
||||||
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
.filter((item) => ![".agents", ".claude", ".opencode"].includes(path.basename(item)))
|
||||||
.toReversed()
|
.toReversed()
|
||||||
const direct = yield* Effect.forEach(directPaths, (filepath) =>
|
fileTargets.clear()
|
||||||
loadFile(filepath).pipe(
|
directPaths.forEach((filepath) => fileTargets.add(AbsolutePath.make(filepath)))
|
||||||
Effect.map((config) => [
|
const direct = yield* Effect.forEach(directPaths, (filepath) => loadFile(filepath)).pipe(
|
||||||
...(config ? [config] : []),
|
|
||||||
new File({ type: "file", path: AbsolutePath.make(filepath) }),
|
|
||||||
]),
|
|
||||||
),
|
|
||||||
).pipe(
|
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
Effect.map((entries) => entries.flat()),
|
Effect.map((entries) => entries.filter((entry): entry is Document => entry !== undefined)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const file = options?.file
|
const file = options?.file
|
||||||
|
if (file) fileTargets.add(AbsolutePath.make(path.resolve(file)))
|
||||||
const explicit = file
|
const explicit = file
|
||||||
? yield* loadFile(path.resolve(file)).pipe(
|
? yield* loadFile(path.resolve(file)).pipe(
|
||||||
Effect.map((config) => [
|
Effect.map((config) => (config ? [config] : [])),
|
||||||
...(config ? [config] : []),
|
|
||||||
new File({ type: "file", path: AbsolutePath.make(path.resolve(file)) }),
|
|
||||||
]),
|
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
@@ -285,7 +300,10 @@ export const layer = (options?: Options) =>
|
|||||||
const watched = new Set<string>()
|
const watched = new Set<string>()
|
||||||
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
const reconcile = Effect.fn("Config.reconcileWatches")(function* (entries: readonly Entry[]) {
|
||||||
const directories = entries.flatMap((entry) => (entry.type === "directory" ? [entry.path] : []))
|
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 = [
|
const targets = [
|
||||||
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
|
...directories.map((path) => ({ path, type: "directory" as const, ignore })),
|
||||||
...files
|
...files
|
||||||
@@ -308,9 +326,9 @@ export const layer = (options?: Options) =>
|
|||||||
reloadLock.withPermit(
|
reloadLock.withPermit(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const next = yield* discover()
|
const next = yield* discover()
|
||||||
|
yield* reconcile(next)
|
||||||
if (isDeepStrictEqual(configs, next)) return
|
if (isDeepStrictEqual(configs, next)) return
|
||||||
configs = next
|
configs = next
|
||||||
yield* reconcile(next)
|
|
||||||
yield* bus.publish(Event.Updated, {})
|
yield* bus.publish(Event.Updated, {})
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -364,10 +382,54 @@ export const layer = (options?: Options) =>
|
|||||||
)
|
)
|
||||||
yield* reconcile(initial)
|
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({
|
return Service.of({
|
||||||
entries: Effect.fn("Config.entries")(function* () {
|
entries: Effect.fn("Config.entries")(function* () {
|
||||||
return configs
|
return configs
|
||||||
}),
|
}),
|
||||||
|
update,
|
||||||
changes: () => Stream.fromPubSub(updates),
|
changes: () => Stream.fromPubSub(updates),
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -382,3 +444,26 @@ export function configured(options?: Options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const node = configured()
|
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 }]
|
||||||
|
}
|
||||||
|
|||||||
@@ -401,6 +401,15 @@ function normalizeExperimental(
|
|||||||
unsupportedExperimental.forEach((key) =>
|
unsupportedExperimental.forEach((key) =>
|
||||||
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
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")) {
|
if (own(experimental, "subagent_depth")) {
|
||||||
const value = decodeEncoded(
|
const value = decodeEncoded(
|
||||||
ConfigExperimental.Info.fields.subagent_depth,
|
ConfigExperimental.Info.fields.subagent_depth,
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { Permission } from "../../permission.js"
|
|||||||
import type { LocationMutation } from "../../location-mutation.js"
|
import type { LocationMutation } from "../../location-mutation.js"
|
||||||
import type { ReadTool } from "../../tool/plugin/read.js"
|
import type { ReadTool } from "../../tool/plugin/read.js"
|
||||||
import type { EditTool } from "../../tool/plugin/edit.js"
|
import type { EditTool } from "../../tool/plugin/edit.js"
|
||||||
|
import { AbsolutePath } from "../../schema.js"
|
||||||
|
|
||||||
const legacySources = [
|
const legacySources = [
|
||||||
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
{ pattern: "{agent,agents}/**/*.md", primary: false },
|
||||||
@@ -210,5 +211,5 @@ function decode(file: { directory: string; filepath: string; primary: boolean },
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (!info) return
|
if (!info) return
|
||||||
return new Document({ type: "document", path: file.filepath, info })
|
return new Document({ type: "document", path: AbsolutePath.make(file.filepath), info })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ export const Plugin = define({
|
|||||||
const config = yield* Config.Service
|
const config = yield* Config.Service
|
||||||
const loaded = { entries: yield* config.entries() }
|
const loaded = { entries: yield* config.entries() }
|
||||||
yield* ctx.websearch.transform((websearch) => {
|
yield* ctx.websearch.transform((websearch) => {
|
||||||
const providerID = Config.latest(loaded.entries, "websearch")?.provider
|
const selection = Config.latest(loaded.entries, "websearch")
|
||||||
if (providerID) websearch.default.set(providerID)
|
if (selection === false) websearch.default.set(false)
|
||||||
|
if (selection) websearch.default.set(selection.provider)
|
||||||
})
|
})
|
||||||
yield* ctx.event.subscribe().pipe(
|
yield* ctx.event.subscribe().pipe(
|
||||||
Stream.filter((event) => event.type === "config.updated"),
|
Stream.filter((event) => event.type === "config.updated"),
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
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 { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
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 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"
|
export * as LayerNodePlatform from "./app-node-platform.js"
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ import { Plugin } from "./plugin.js"
|
|||||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||||
import { Worktree } from "./worktree.js"
|
import { Worktree } from "./worktree.js"
|
||||||
import { Pty } from "./pty.js"
|
import { Pty } from "./pty.js"
|
||||||
import { Question } from "./question.js"
|
|
||||||
import { Shell } from "./shell.js"
|
import { Shell } from "./shell.js"
|
||||||
import { Reference } from "./reference.js"
|
import { Reference } from "./reference.js"
|
||||||
import { WebSearch } from "./websearch.js"
|
import { WebSearch } from "./websearch.js"
|
||||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||||
|
import { SessionModelTransport } from "./session/model-transport.js"
|
||||||
import { SessionCompaction } from "./session/compaction.js"
|
import { SessionCompaction } from "./session/compaction.js"
|
||||||
import { SessionTitle } from "./session/title.js"
|
import { SessionTitle } from "./session/title.js"
|
||||||
import { Skill } from "./skill.js"
|
import { Skill } from "./skill.js"
|
||||||
@@ -86,13 +86,13 @@ const locationServiceNodes = [
|
|||||||
ReferenceInstructions.node,
|
ReferenceInstructions.node,
|
||||||
InstructionEntry.node,
|
InstructionEntry.node,
|
||||||
Form.node,
|
Form.node,
|
||||||
Question.node,
|
|
||||||
Generate.node,
|
Generate.node,
|
||||||
SessionGenerateNode.node,
|
SessionGenerateNode.node,
|
||||||
ReadToolFileSystem.node,
|
ReadToolFileSystem.node,
|
||||||
McpTool.node,
|
McpTool.node,
|
||||||
SessionInstructions.node,
|
SessionInstructions.node,
|
||||||
SessionRunnerModel.node,
|
SessionRunnerModel.node,
|
||||||
|
SessionModelTransport.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.node,
|
SessionTitle.node,
|
||||||
Snapshot.node,
|
Snapshot.node,
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ interface Failures extends Record<keyof Domains, unknown> {
|
|||||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||||
|
|
||||||
export interface Interface {
|
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]>(
|
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||||
domain: Domain,
|
domain: Domain,
|
||||||
name: Name,
|
name: Name,
|
||||||
@@ -74,7 +78,9 @@ const layer = Layer.effect(
|
|||||||
return event
|
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 })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -332,7 +332,10 @@ export const make = Effect.fn("PluginHost.make")(function* (plugin: import("../p
|
|||||||
}),
|
}),
|
||||||
default: {
|
default: {
|
||||||
get: draft.default.get,
|
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),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { App } from "../../app.js"
|
|||||||
import { Credential } from "../../credential.js"
|
import { Credential } from "../../credential.js"
|
||||||
import { Bus } from "../../bus.js"
|
import { Bus } from "../../bus.js"
|
||||||
import { Integration } from "../../integration.js"
|
import { Integration } from "../../integration.js"
|
||||||
import { Model } from "../../model.js"
|
|
||||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||||
import { Provider } from "../../provider.js"
|
import { Provider } from "../../provider.js"
|
||||||
import type { PluginInternal } from "../internal.js"
|
import type { PluginInternal } from "../internal.js"
|
||||||
@@ -203,10 +202,10 @@ export const OpenAIPlugin = define({
|
|||||||
if (!item) return
|
if (!item) return
|
||||||
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
||||||
const account = chatgpt.metadata?.accountID
|
const account = chatgpt.metadata?.accountID
|
||||||
item.provider.headers = Provider.mergeHeaders(
|
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
|
||||||
item.provider.headers,
|
originator: "opencode",
|
||||||
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
|
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
|
||||||
)
|
})
|
||||||
for (const model of item.models.values()) {
|
for (const model of item.models.values()) {
|
||||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||||
// subscription covers usage, so hide the rest and zero the cost.
|
// 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())))
|
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
import { WebSearchExa } from "./exa.js"
|
import { WebSearchExa } from "./exa.js"
|
||||||
import { WebSearchFirecrawl } from "./firecrawl.js"
|
import { WebSearchFirecrawl } from "./firecrawl.js"
|
||||||
import { WebSearchParallel } from "./parallel.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: {},
|
||||||
|
}))
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
export * as Question from "./question.js"
|
|
||||||
|
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
|
||||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
|
||||||
import { Question } from "@opencode-ai/schema/question"
|
|
||||||
import { Bus } from "./bus.js"
|
|
||||||
import { SessionSchema } from "./session/schema.js"
|
|
||||||
|
|
||||||
export const ID = Question.ID
|
|
||||||
export type ID = typeof ID.Type
|
|
||||||
|
|
||||||
export const Option = Question.Option
|
|
||||||
export type Option = typeof Option.Type
|
|
||||||
|
|
||||||
export const Info = Question.Info
|
|
||||||
export type Info = typeof Info.Type
|
|
||||||
|
|
||||||
export const Prompt = Question.Prompt
|
|
||||||
export type Prompt = typeof Prompt.Type
|
|
||||||
|
|
||||||
export const Tool = Question.Tool
|
|
||||||
export type Tool = typeof Tool.Type
|
|
||||||
|
|
||||||
export const Request = Question.Request
|
|
||||||
export type Request = typeof Request.Type
|
|
||||||
|
|
||||||
export const Answer = Question.Answer
|
|
||||||
export type Answer = typeof Answer.Type
|
|
||||||
|
|
||||||
export const Reply = Question.Reply
|
|
||||||
export type Reply = typeof Reply.Type
|
|
||||||
|
|
||||||
export { Event } from "@opencode-ai/schema/question"
|
|
||||||
|
|
||||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Question.RejectedError", {}) {
|
|
||||||
override get message() {
|
|
||||||
return "The user dismissed this question"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
|
|
||||||
requestID: ID,
|
|
||||||
}) {}
|
|
||||||
|
|
||||||
export interface AskInput {
|
|
||||||
readonly sessionID: SessionSchema.ID
|
|
||||||
readonly questions: ReadonlyArray<Info>
|
|
||||||
readonly tool?: Tool
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplyInput {
|
|
||||||
readonly requestID: ID
|
|
||||||
readonly answers: ReadonlyArray<Answer>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Interface {
|
|
||||||
readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
|
|
||||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
|
||||||
readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
|
|
||||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
|
|
||||||
|
|
||||||
interface Pending {
|
|
||||||
readonly request: Request
|
|
||||||
readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Location-owned pending prompts. The Location layer map must materialize this
|
|
||||||
* layer once per embedded Location so replies cannot settle another Location's
|
|
||||||
* deferred request.
|
|
||||||
*/
|
|
||||||
const layer = Layer.effect(
|
|
||||||
Service,
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const bus = yield* Bus.Service
|
|
||||||
const pending = new Map<ID, Pending>()
|
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() =>
|
|
||||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
|
|
||||||
discard: true,
|
|
||||||
}).pipe(
|
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.clear()
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const ask = Effect.fn("Question.ask")((input: AskInput) =>
|
|
||||||
Effect.uninterruptibleMask((restore) =>
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const id = ID.ascending()
|
|
||||||
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
|
|
||||||
const request: Request = { id, ...input }
|
|
||||||
pending.set(id, { request, deferred })
|
|
||||||
return yield* bus.publish(Question.Event.Asked, request).pipe(
|
|
||||||
Effect.andThen(restore(Deferred.await(deferred))),
|
|
||||||
Effect.ensuring(
|
|
||||||
Effect.sync(() => {
|
|
||||||
pending.delete(id)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const reply = Effect.fn("Question.reply")((input: ReplyInput) =>
|
|
||||||
Effect.uninterruptible(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const existing = pending.get(input.requestID)
|
|
||||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
|
||||||
yield* bus.publish(Question.Event.Replied, {
|
|
||||||
sessionID: existing.request.sessionID,
|
|
||||||
requestID: existing.request.id,
|
|
||||||
answers: input.answers.map((answer) => [...answer]),
|
|
||||||
})
|
|
||||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
|
||||||
pending.delete(input.requestID)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const reject = Effect.fn("Question.reject")((requestID: ID) =>
|
|
||||||
Effect.uninterruptible(
|
|
||||||
Effect.gen(function* () {
|
|
||||||
const existing = pending.get(requestID)
|
|
||||||
if (!existing) return yield* new NotFoundError({ requestID })
|
|
||||||
yield* bus.publish(Question.Event.Rejected, {
|
|
||||||
sessionID: existing.request.sessionID,
|
|
||||||
requestID: existing.request.id,
|
|
||||||
})
|
|
||||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
|
||||||
pending.delete(requestID)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
const list = Effect.fn("Question.list")(function* () {
|
|
||||||
return Array.from(pending.values(), (item) => item.request)
|
|
||||||
})
|
|
||||||
|
|
||||||
return Service.of({ ask, reply, reject, list })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
export * as Session from "./session.js"
|
export * as Session from "./session.js"
|
||||||
export * from "./session/schema.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 { ListAnchor } from "@opencode-ai/schema/session"
|
||||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||||
import { Project } from "./project.js"
|
import { Project } from "./project.js"
|
||||||
@@ -27,6 +27,7 @@ import { fromRow } from "./session/info.js"
|
|||||||
import { SessionRunner } from "./session/runner/index.js"
|
import { SessionRunner } from "./session/runner/index.js"
|
||||||
import { SessionStore } from "./session/store.js"
|
import { SessionStore } from "./session/store.js"
|
||||||
import { SessionExecution } from "./session/execution.js"
|
import { SessionExecution } from "./session/execution.js"
|
||||||
|
import { SessionModelTransport } from "./session/model-transport.js"
|
||||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
|
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { LocationServiceMap } from "./location-service-map.js"
|
import { LocationServiceMap } from "./location-service-map.js"
|
||||||
@@ -306,6 +307,16 @@ const layer = Layer.effect(
|
|||||||
const scope = yield* Scope.Scope
|
const scope = yield* Scope.Scope
|
||||||
const activeShells = new Set<SessionSchema.ID>()
|
const activeShells = new Set<SessionSchema.ID>()
|
||||||
const shellLocks = KeyedMutex.makeUnsafe<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 decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||||
@@ -437,9 +448,10 @@ const layer = Layer.effect(
|
|||||||
return session
|
return session
|
||||||
}),
|
}),
|
||||||
remove: Effect.fn("Session.remove")(function* (sessionID) {
|
remove: Effect.fn("Session.remove")(function* (sessionID) {
|
||||||
yield* result.get(sessionID)
|
const session = yield* result.get(sessionID)
|
||||||
yield* execution.interrupt(sessionID)
|
yield* execution.interrupt(sessionID)
|
||||||
yield* execution.awaitIdle(sessionID)
|
yield* execution.awaitIdle(sessionID)
|
||||||
|
yield* closeTransport(session)
|
||||||
const children = yield* result.list({ parentID: sessionID })
|
const children = yield* result.list({ parentID: sessionID })
|
||||||
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
|
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
|
||||||
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
||||||
@@ -777,7 +789,10 @@ const layer = Layer.effect(
|
|||||||
return false
|
return false
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (recovered) return
|
if (recovered) {
|
||||||
|
yield* execution.wakeActive(input.sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
yield* execution.wake(input.sessionID)
|
yield* execution.wake(input.sessionID)
|
||||||
}),
|
}),
|
||||||
compact: Effect.fn("Session.compact")(function* (input) {
|
compact: Effect.fn("Session.compact")(function* (input) {
|
||||||
@@ -863,8 +878,7 @@ const layer = Layer.effect(
|
|||||||
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
interrupt: Effect.fn("Session.interrupt")((sessionID, options) =>
|
||||||
Effect.uninterruptible(
|
Effect.uninterruptible(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* execution.interrupt(sessionID)
|
yield* execution.interrupt(sessionID, options)
|
||||||
if (options?.continue && (yield* SessionInbox.has(db, sessionID, "any"))) yield* execution.wake(sessionID)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
export * as SessionExecution from "./execution.js"
|
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 { Bus } from "../bus.js"
|
||||||
|
import { Database } from "../database/database.js"
|
||||||
import { LocationServiceMap } from "../location-service-map.js"
|
import { LocationServiceMap } from "../location-service-map.js"
|
||||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { SessionEvent } from "./event.js"
|
import { SessionEvent } from "./event.js"
|
||||||
@@ -11,6 +12,7 @@ import { SessionSchema } from "./schema.js"
|
|||||||
import { SessionStore } from "./store.js"
|
import { SessionStore } from "./store.js"
|
||||||
import { toSessionError } from "./to-session-error.js"
|
import { toSessionError } from "./to-session-error.js"
|
||||||
import { UserInterruptedError } from "./error.js"
|
import { UserInterruptedError } from "./error.js"
|
||||||
|
import { SessionInbox } from "./inbox.js"
|
||||||
|
|
||||||
export interface Interface {
|
export interface Interface {
|
||||||
/** Snapshots active execution owned by this process. */
|
/** Snapshots active execution owned by this process. */
|
||||||
@@ -19,8 +21,10 @@ export interface Interface {
|
|||||||
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
readonly resume: (sessionID: SessionSchema.ID) => Effect.Effect<void, SessionRunner.RunError>
|
||||||
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
/** Registers newly recorded work. Repeated wakeups may coalesce. */
|
||||||
readonly wake: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
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. */
|
/** 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. */
|
/** 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>
|
readonly awaitIdle: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||||
}
|
}
|
||||||
@@ -45,6 +49,7 @@ export const layer = Layer.effect(
|
|||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const locations = yield* LocationServiceMap.Service
|
const locations = yield* LocationServiceMap.Service
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
|
const db = (yield* Database.Service).db
|
||||||
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
const reportLifecycle = <A>(sessionID: SessionSchema.ID, effect: Effect.Effect<A>) =>
|
||||||
effect.pipe(
|
effect.pipe(
|
||||||
Effect.tapCause((cause) =>
|
Effect.tapCause((cause) =>
|
||||||
@@ -71,12 +76,13 @@ export const layer = Layer.effect(
|
|||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
continuation?: SessionRunner.Continuation,
|
continuation?: SessionRunner.Continuation,
|
||||||
|
promotable: SessionInbox.Promotable = "input",
|
||||||
): Effect.Effect<void, SessionRunner.RunError> {
|
): Effect.Effect<void, SessionRunner.RunError> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const session = yield* store.get(sessionID)
|
const session = yield* store.get(sessionID)
|
||||||
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
if (!session) return yield* Effect.die(new Error(`Session not found: ${sessionID}`))
|
||||||
const result = yield* SessionRunner.Service.use((runner) =>
|
const result = yield* SessionRunner.Service.use((runner) =>
|
||||||
runner.drain({ sessionID, force, continuation }),
|
runner.drain({ sessionID, force, continuation, promotable }),
|
||||||
).pipe(
|
).pipe(
|
||||||
Effect.provide(locations.get(session.location)),
|
Effect.provide(locations.get(session.location)),
|
||||||
Effect.tapCause((cause) =>
|
Effect.tapCause((cause) =>
|
||||||
@@ -86,7 +92,7 @@ export const layer = Layer.effect(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if (result.type === "complete") return
|
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>({
|
const coordinator = yield* SessionRunCoordinator.make<SessionSchema.ID, SessionRunner.RunError, InterruptReason>({
|
||||||
@@ -95,7 +101,7 @@ export const layer = Layer.effect(
|
|||||||
sessionID,
|
sessionID,
|
||||||
bus.publish(SessionEvent.Execution.Started, { sessionID }, claimOnCommit(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.
|
// One terminal observation per busy period, covering every coalesced drain.
|
||||||
settled: (sessionID, exit, reason) =>
|
settled: (sessionID, exit, reason) =>
|
||||||
reportLifecycle(
|
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({
|
return Service.of({
|
||||||
active: coordinator.active,
|
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,
|
resume: coordinator.run,
|
||||||
wake: coordinator.wake,
|
wake: coordinator.wake,
|
||||||
|
wakeActive: coordinator.wakeActive,
|
||||||
awaitIdle: coordinator.awaitIdle,
|
awaitIdle: coordinator.awaitIdle,
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
@@ -145,7 +155,7 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeGlobalNode({
|
export const node = makeGlobalNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
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. */
|
/** 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()),
|
active: Effect.succeed(new Set()),
|
||||||
resume: () => Effect.void,
|
resume: () => Effect.void,
|
||||||
wake: () => Effect.void,
|
wake: () => Effect.void,
|
||||||
|
wakeActive: () => Effect.void,
|
||||||
interrupt: () => Effect.void,
|
interrupt: () => Effect.void,
|
||||||
awaitIdle: () => Effect.void,
|
awaitIdle: () => Effect.void,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { SessionHistory } from "./history.js"
|
|||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelHeaders } from "./model-headers.js"
|
||||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||||
import { SessionRunnerModel } from "./runner/model.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"
|
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
@@ -39,7 +39,12 @@ export const layer = Layer.effect(
|
|||||||
sessionID: selection.session.id,
|
sessionID: selection.session.id,
|
||||||
agent: selection.agent.id,
|
agent: selection.agent.id,
|
||||||
model: model.ref,
|
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)
|
.filter((part) => part.length > 0)
|
||||||
.map(SystemPart.make),
|
.map(SystemPart.make),
|
||||||
messages: [
|
messages: [
|
||||||
|
|||||||
@@ -349,6 +349,14 @@ export const nextSteer = Effect.fn("SessionInbox.nextSteer")(function* (
|
|||||||
return row ? fromRow(row) : undefined
|
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
|
* Which pending rows count: "any" counts every row, while "input" means any
|
||||||
* item in either delivery mode.
|
* item in either delivery mode.
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ export * as SessionModelRequest from "./model-request.js"
|
|||||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||||
import type { Content } from "@opencode-ai/schema/tool"
|
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 { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||||
import { App } from "../app.js"
|
import { App } from "../app.js"
|
||||||
import { Model } from "../model.js"
|
import { Model } from "../model.js"
|
||||||
|
import { Provider } from "../provider.js"
|
||||||
import { Permission } from "../permission.js"
|
import { Permission } from "../permission.js"
|
||||||
import { PluginHooks } from "../plugin/hooks.js"
|
import { PluginHooks } from "../plugin/hooks.js"
|
||||||
import { QuestionTool } from "../tool/plugin/question.js"
|
import { QuestionTool } from "../tool/plugin/question.js"
|
||||||
@@ -15,10 +15,11 @@ import { Tool } from "../tool.js"
|
|||||||
import { SessionContext } from "./context.js"
|
import { SessionContext } from "./context.js"
|
||||||
import { SessionModelHeaders } from "./model-headers.js"
|
import { SessionModelHeaders } from "./model-headers.js"
|
||||||
import { SessionModelHttp } from "./model-http.js"
|
import { SessionModelHttp } from "./model-http.js"
|
||||||
|
import { SessionModelTransport } from "./model-transport.js"
|
||||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
||||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.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"
|
import { toLLMMessages } from "./runner/to-llm-message.js"
|
||||||
|
|
||||||
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiB
|
||||||
@@ -46,6 +47,8 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
|||||||
interface Prepared {
|
interface Prepared {
|
||||||
readonly request: LLMRequest
|
readonly request: LLMRequest
|
||||||
readonly options: StreamOptions
|
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
|
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||||
* step-limit-violating calls fail individually through the same seam.
|
* step-limit-violating calls fail individually through the same seam.
|
||||||
@@ -165,7 +168,12 @@ export const layer = Layer.effect(
|
|||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const hooks = yield* PluginHooks.Service
|
const hooks = yield* PluginHooks.Service
|
||||||
|
const transport = yield* SessionModelTransport.Service
|
||||||
const app = yield* App.Metadata
|
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(
|
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||||
Config.withDefault(false),
|
Config.withDefault(false),
|
||||||
Effect.orDie,
|
Effect.orDie,
|
||||||
@@ -182,7 +190,10 @@ export const layer = Layer.effect(
|
|||||||
// The final Step keeps definitions available to protocols with native "none",
|
// The final Step keeps definitions available to protocols with native "none",
|
||||||
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
// preserving their prompt cache prefix. Calls are still rejected at execution.
|
||||||
const tools = input.context.tools
|
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)
|
.filter((part) => part.length > 0)
|
||||||
.map(SystemPart.make)
|
.map(SystemPart.make)
|
||||||
const history = toLLMMessages(input.context.messages, resolved.ref, providerMetadataKey)
|
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 })),
|
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||||
toolChoice: stepLimitReached ? "none" : undefined,
|
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 = {
|
const options: StreamOptions = {
|
||||||
http: SessionModelHttp.middleware(hooks, {
|
...(http ? { http } : {}),
|
||||||
sessionID: session.id,
|
...(webSocket &&
|
||||||
agent: agent.id,
|
webSocketEligible &&
|
||||||
model: resolved.ref,
|
resolved.ref.providerID === Provider.ID.openai &&
|
||||||
}),
|
model.route.id === "openai-responses"
|
||||||
|
? { webSocket: transport.bind(session.id) }
|
||||||
|
: {}),
|
||||||
}
|
}
|
||||||
if (promptCacheSnapshots) {
|
if (promptCacheSnapshots) {
|
||||||
const current = PromptCacheDiagnostics.snapshot(request)
|
const current = PromptCacheDiagnostics.snapshot(request)
|
||||||
@@ -263,6 +285,7 @@ export const layer = Layer.effect(
|
|||||||
return {
|
return {
|
||||||
request,
|
request,
|
||||||
options,
|
options,
|
||||||
|
webSocketEligible,
|
||||||
executeTool,
|
executeTool,
|
||||||
stepLimitReached,
|
stepLimitReached,
|
||||||
}
|
}
|
||||||
@@ -275,5 +298,5 @@ export const layer = Layer.effect(
|
|||||||
export const node = makeLocationNode({
|
export const node = makeLocationNode({
|
||||||
service: Service,
|
service: Service,
|
||||||
layer,
|
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] })
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
export * as SessionRunCoordinator from "./run-coordinator.js"
|
export * as SessionRunCoordinator from "./run-coordinator.js"
|
||||||
|
|
||||||
import { Deferred, Effect, Exit, Fiber, FiberSet, Scope } from "effect"
|
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. */
|
/** Serializes execution for each key while allowing different keys to run concurrently. */
|
||||||
export interface Coordinator<Key, E, Reason = never> {
|
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. */
|
/** Starts an execution while idle, or joins the active execution and returns its exit. */
|
||||||
readonly run: (key: Key) => Effect.Effect<void, E>
|
readonly run: (key: Key) => Effect.Effect<void, E>
|
||||||
/** Rings the doorbell: an idle key starts an execution; an active one drains again before settling. */
|
/** 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. */
|
/** 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. */
|
/** 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>
|
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
|
* 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
|
* 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
|
* execution rings it with its eligibility request, and the execution loop drains again
|
||||||
* closes the gap between a drain's last eligibility check and the idle transition, since
|
* instead of ending. The doorbell closes the gap between a drain's last eligibility check
|
||||||
* those cannot be one atomic step. `done` resolves joiners with this execution's exit.
|
* and the idle transition, since those cannot be one atomic step. `done` resolves joiners
|
||||||
|
* with this execution's exit.
|
||||||
*/
|
*/
|
||||||
type Execution<E, Reason> = {
|
type Execution<E, Reason> = {
|
||||||
readonly done: Deferred.Deferred<void, E>
|
readonly done: Deferred.Deferred<void, E>
|
||||||
owner?: Fiber.Fiber<void>
|
owner?: Fiber.Fiber<void>
|
||||||
pendingWake: boolean
|
request: Request
|
||||||
|
pendingWake?: Request
|
||||||
stopping: boolean
|
stopping: boolean
|
||||||
interruptionReason?: Reason
|
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: {
|
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. */
|
/** Runs once when a process-local busy period begins, before its first drain. */
|
||||||
readonly started?: (key: Key) => Effect.Effect<void>
|
readonly started?: (key: Key) => Effect.Effect<void>
|
||||||
/**
|
/**
|
||||||
@@ -55,23 +71,26 @@ export const make = <Key, E, Reason = never>(options: {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const executions = new Map<Key, Execution<E, Reason>>()
|
const executions = new Map<Key, Execution<E, Reason>>()
|
||||||
const fork = yield* FiberSet.makeRuntime<never, void, never>()
|
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> =>
|
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.flatMap(() =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
if (execution.stopping || !execution.pendingWake) return Effect.void
|
if (execution.stopping || execution.pendingWake === undefined) return Effect.void
|
||||||
execution.pendingWake = false
|
execution.request = execution.pendingWake
|
||||||
|
execution.pendingWake = undefined
|
||||||
// Trampoline so drains that complete synchronously cannot grow the stack.
|
// Trampoline so drains that complete synchronously cannot grow the stack.
|
||||||
return Effect.yieldNow.pipe(Effect.andThen(loop(key, execution, false)))
|
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> = {
|
const execution: Execution<E, Reason> = {
|
||||||
done: Deferred.makeUnsafe<void, E>(),
|
done: Deferred.makeUnsafe<void, E>(),
|
||||||
pendingWake: false,
|
request,
|
||||||
stopping: false,
|
stopping: false,
|
||||||
}
|
}
|
||||||
executions.set(key, execution)
|
executions.set(key, execution)
|
||||||
@@ -87,7 +106,7 @@ export const make = <Key, E, Reason = never>(options: {
|
|||||||
execution.owner = undefined
|
execution.owner = undefined
|
||||||
}).pipe(Effect.andThen(options.settled?.(key, exit, execution.interruptionReason) ?? Effect.void)),
|
}).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.exit,
|
||||||
Effect.asVoid,
|
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
|
// 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.
|
// 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>) => {
|
const settle = (key: Key, execution: Execution<E, Reason>, exit: Exit.Exit<void, E>, continuation: boolean) => {
|
||||||
if (execution.pendingWake) start(key, false)
|
if (continuation && execution.continuation) start(key, false, execution.continuation.request)
|
||||||
|
else if (execution.pendingWake) start(key, false, execution.pendingWake)
|
||||||
else executions.delete(key)
|
else executions.delete(key)
|
||||||
Deferred.doneUnsafe(execution.done, exit)
|
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> =>
|
const run = (key: Key): Effect.Effect<void, E> =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const execution = executions.get(key)
|
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)))
|
if (execution.stopping) return Deferred.await(execution.done).pipe(Effect.andThen(run(key)))
|
||||||
return Deferred.await(execution.done)
|
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(() => {
|
Effect.sync(() => {
|
||||||
const execution = executions.get(key)
|
const execution = executions.get(key)
|
||||||
if (execution !== undefined) {
|
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
|
return
|
||||||
}
|
}
|
||||||
start(key, false)
|
start(key, false, request)
|
||||||
})
|
})
|
||||||
|
|
||||||
const interrupt = (key: Key, reason?: Reason): Effect.Effect<void> =>
|
const wakeActive = (key: Key) =>
|
||||||
Effect.suspend(() => {
|
Effect.suspend(() => {
|
||||||
const execution = executions.get(key)
|
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.stopping = true
|
||||||
execution.pendingWake = false
|
execution.pendingWake = undefined
|
||||||
execution.interruptionReason = reason
|
execution.interruptionReason = reason
|
||||||
|
if (options?.continue) execution.continuation = { ...options.continue, signaled: false }
|
||||||
return Fiber.interrupt(execution.owner)
|
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 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 type { AIError } from "@opencode-ai/ai"
|
||||||
import { Context, Effect } from "effect"
|
import { Context, Effect } from "effect"
|
||||||
import { SessionSchema } from "../schema.js"
|
import { SessionSchema } from "../schema.js"
|
||||||
|
import type { Promotable } from "../inbox.js"
|
||||||
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
|
||||||
import { SessionRunnerModel } from "./model.js"
|
import { SessionRunnerModel } from "./model.js"
|
||||||
import type { Instructions } from "../../instructions/index.js"
|
import type { Instructions } from "../../instructions/index.js"
|
||||||
@@ -29,6 +30,8 @@ export interface Interface {
|
|||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
readonly continuation?: Continuation
|
readonly continuation?: Continuation
|
||||||
|
/** "steer" settles the active intent without promoting queued next-turn work. */
|
||||||
|
readonly promotable?: Promotable
|
||||||
}) => Effect.Effect<DrainResult, RunError>
|
}) => Effect.Effect<DrainResult, RunError>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { SessionContext } from "../context.js"
|
|||||||
import { SessionEvent } from "../event.js"
|
import { SessionEvent } from "../event.js"
|
||||||
import { SessionInbox } from "../inbox.js"
|
import { SessionInbox } from "../inbox.js"
|
||||||
import { SessionModelRequest } from "../model-request.js"
|
import { SessionModelRequest } from "../model-request.js"
|
||||||
|
import { SessionModelTransport } from "../model-transport.js"
|
||||||
import { SessionMessage } from "../message.js"
|
import { SessionMessage } from "../message.js"
|
||||||
import { SessionSchema } from "../schema.js"
|
import { SessionSchema } from "../schema.js"
|
||||||
import { SessionStore } from "../store.js"
|
import { SessionStore } from "../store.js"
|
||||||
@@ -34,7 +35,7 @@ import { SessionRunnerRetry } from "./retry.js"
|
|||||||
import { SessionUsage } from "../usage.js"
|
import { SessionUsage } from "../usage.js"
|
||||||
import { ToolOutput } from "../../tool-output.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<{
|
type CallOutcome = Data.TaggedEnum<{
|
||||||
Completed: { readonly needsContinuation: boolean; readonly step: number }
|
Completed: { readonly needsContinuation: boolean; readonly step: number }
|
||||||
Retry: { readonly step: number }
|
Retry: { readonly step: number }
|
||||||
@@ -43,6 +44,7 @@ type CallOutcome = Data.TaggedEnum<{
|
|||||||
readonly error: SessionRunnerRetry.RetryableFailure["error"]
|
readonly error: SessionRunnerRetry.RetryableFailure["error"]
|
||||||
readonly step: number
|
readonly step: number
|
||||||
}
|
}
|
||||||
|
RecoverFull: { readonly step: number }
|
||||||
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
|
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
|
||||||
}>
|
}>
|
||||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||||
@@ -108,6 +110,7 @@ const layer = Layer.effect(
|
|||||||
const store = yield* SessionStore.Service
|
const store = yield* SessionStore.Service
|
||||||
const context = yield* SessionContext.Service
|
const context = yield* SessionContext.Service
|
||||||
const modelRequests = yield* SessionModelRequest.Service
|
const modelRequests = yield* SessionModelRequest.Service
|
||||||
|
const modelTransport = yield* SessionModelTransport.Service
|
||||||
const snapshots = yield* Snapshot.Service
|
const snapshots = yield* Snapshot.Service
|
||||||
const db = (yield* Database.Service).db
|
const db = (yield* Database.Service).db
|
||||||
const compaction = yield* SessionCompaction.Service
|
const compaction = yield* SessionCompaction.Service
|
||||||
@@ -125,22 +128,25 @@ const layer = Layer.effect(
|
|||||||
readonly sessionID: SessionSchema.ID
|
readonly sessionID: SessionSchema.ID
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
readonly continuation?: Continuation
|
readonly continuation?: Continuation
|
||||||
|
readonly promotable?: SessionInbox.Promotable
|
||||||
}) {
|
}) {
|
||||||
let force = input.force
|
let force = input.force
|
||||||
let continuation = input.continuation
|
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 }
|
return { type: "complete" as const }
|
||||||
yield* settleStaleToolCalls(input.sessionID)
|
yield* settleStaleToolCalls(input.sessionID)
|
||||||
while (true) {
|
while (true) {
|
||||||
if (yield* runPendingCompaction(input.sessionID)) {
|
if (yield* runPendingCompaction(input.sessionID, promotable)) {
|
||||||
force = false
|
force = false
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if (yield* runPendingMove(input.sessionID, "input")) return { type: "moved" as const }
|
if (yield* runPendingMove(input.sessionID, promotable)) return { type: "moved" as const }
|
||||||
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, "input")))
|
if (!force && !continuation && !(yield* SessionInbox.has(db, input.sessionID, promotable)))
|
||||||
return { type: "complete" as const }
|
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 (result.type === "moved") return result
|
||||||
|
if (promotable === "steer") return { type: "complete" as const }
|
||||||
force = false
|
force = false
|
||||||
continuation = undefined
|
continuation = undefined
|
||||||
}
|
}
|
||||||
@@ -153,13 +159,14 @@ const layer = Layer.effect(
|
|||||||
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
const runSteps = Effect.fn("SessionRunner.runSteps")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
continuation?: Continuation,
|
continuation?: Continuation,
|
||||||
|
initialPromotable: SessionInbox.Promotable = continuation ? "steer" : "input",
|
||||||
) {
|
) {
|
||||||
// Fresh work may promote queued input; later steps absorb steers only.
|
// 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 step = continuation?.step ?? 1
|
||||||
let next = continuation
|
let next = continuation
|
||||||
while (true) {
|
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 }
|
if (yield* runPendingMove(sessionID, "steer")) return { type: "moved" as const, continuation: next }
|
||||||
const result = yield* runStep(sessionID, promotable, step)
|
const result = yield* runStep(sessionID, promotable, step)
|
||||||
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
next = result.needsContinuation ? { step: result.step + 1 } : undefined
|
||||||
@@ -205,12 +212,15 @@ const layer = Layer.effect(
|
|||||||
let currentStep = step
|
let currentStep = step
|
||||||
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
||||||
let recoverOverflow = true
|
let recoverOverflow = true
|
||||||
|
// Continuation rejection permits one immediate full-context Physical Attempt without generic backoff.
|
||||||
|
let recoverContinuation = true
|
||||||
while (true) {
|
while (true) {
|
||||||
const outcome = yield* callModel(
|
const outcome = yield* callModel(
|
||||||
sessionID,
|
sessionID,
|
||||||
currentPromotable,
|
currentPromotable,
|
||||||
currentStep,
|
currentStep,
|
||||||
recoverOverflow,
|
recoverOverflow,
|
||||||
|
recoverContinuation,
|
||||||
assistantMessageID,
|
assistantMessageID,
|
||||||
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
||||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
||||||
@@ -232,6 +242,7 @@ const layer = Layer.effect(
|
|||||||
if (outcome.recoveredOverflow) recoverOverflow = false
|
if (outcome.recoveredOverflow) recoverOverflow = false
|
||||||
assistantMessageID = SessionMessage.ID.create()
|
assistantMessageID = SessionMessage.ID.create()
|
||||||
}
|
}
|
||||||
|
if (outcome._tag === "RecoverFull") recoverContinuation = false
|
||||||
// Neither a retry nor a compaction restart re-promotes input.
|
// Neither a retry nor a compaction restart re-promotes input.
|
||||||
currentPromotable = undefined
|
currentPromotable = undefined
|
||||||
currentStep = outcome.step
|
currentStep = outcome.step
|
||||||
@@ -247,6 +258,7 @@ const layer = Layer.effect(
|
|||||||
promotable: SessionInbox.Promotable | undefined,
|
promotable: SessionInbox.Promotable | undefined,
|
||||||
step: number,
|
step: number,
|
||||||
recoverOverflow: boolean,
|
recoverOverflow: boolean,
|
||||||
|
recoverContinuation: boolean,
|
||||||
assistantMessageID: SessionMessage.ID,
|
assistantMessageID: SessionMessage.ID,
|
||||||
) {
|
) {
|
||||||
const selected = yield* context.select(sessionID)
|
const selected = yield* context.select(sessionID)
|
||||||
@@ -412,6 +424,13 @@ const layer = Layer.effect(
|
|||||||
// escapes as a scheduled retry or fails the assistant durably.
|
// escapes as a scheduled retry or fails the assistant durably.
|
||||||
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
|
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
|
||||||
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : 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 (
|
if (
|
||||||
llmFailure &&
|
llmFailure &&
|
||||||
llmError &&
|
llmError &&
|
||||||
@@ -500,14 +519,14 @@ const layer = Layer.effect(
|
|||||||
/** Executes a previously admitted manual compaction request, if one is pending. */
|
/** Executes a previously admitted manual compaction request, if one is pending. */
|
||||||
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
const runPendingCompaction = Effect.fn("SessionRunner.runPendingCompaction")(function* (
|
||||||
sessionID: SessionSchema.ID,
|
sessionID: SessionSchema.ID,
|
||||||
|
promotable: SessionInbox.Promotable,
|
||||||
) {
|
) {
|
||||||
return yield* Effect.uninterruptibleMask((restore) =>
|
return yield* Effect.uninterruptibleMask((restore) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pending = yield* SessionInbox.serialized(
|
const pending = yield* SessionInbox.serialized(
|
||||||
sessionID,
|
sessionID,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const selected =
|
const selected = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||||
(yield* SessionInbox.nextSteer(db, sessionID)) ?? (yield* SessionInbox.nextQueued(db, sessionID))
|
|
||||||
if (selected?.type !== "compaction") return
|
if (selected?.type !== "compaction") return
|
||||||
yield* bus.publishAll([
|
yield* bus.publishAll([
|
||||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
[SessionEvent.InboxDelivered, { sessionID, inboxID: selected.id }],
|
||||||
@@ -549,10 +568,9 @@ const layer = Layer.effect(
|
|||||||
return yield* SessionInbox.serialized(
|
return yield* SessionInbox.serialized(
|
||||||
sessionID,
|
sessionID,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const pending =
|
const pending = yield* SessionInbox.nextPromotable(db, sessionID, promotable)
|
||||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
|
||||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
|
||||||
if (pending?.type !== "move") return false
|
if (pending?.type !== "move") return false
|
||||||
|
yield* modelTransport.close(sessionID)
|
||||||
yield* bus.publishAll([
|
yield* bus.publishAll([
|
||||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
|
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
|
||||||
[
|
[
|
||||||
@@ -623,6 +641,7 @@ export const node = makeLocationNode({
|
|||||||
llmClient,
|
llmClient,
|
||||||
SessionContext.node,
|
SessionContext.node,
|
||||||
SessionModelRequest.node,
|
SessionModelRequest.node,
|
||||||
|
SessionModelTransport.node,
|
||||||
SessionStore.node,
|
SessionStore.node,
|
||||||
SessionCompaction.node,
|
SessionCompaction.node,
|
||||||
SessionTitle.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.
|
||||||
@@ -18,8 +18,9 @@ export function isRetryable(error: AIError) {
|
|||||||
switch (error.reason._tag) {
|
switch (error.reason._tag) {
|
||||||
case "RateLimit":
|
case "RateLimit":
|
||||||
case "ProviderInternal":
|
case "ProviderInternal":
|
||||||
case "Transport":
|
|
||||||
return true
|
return true
|
||||||
|
case "Transport":
|
||||||
|
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
|
||||||
case "InvalidProviderOutput":
|
case "InvalidProviderOutput":
|
||||||
return error.reason.classification === "incomplete-stream"
|
return error.reason.classification === "incomplete-stream"
|
||||||
case "Authentication":
|
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"))
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import { AIError, ToolFailure } from "@opencode-ai/ai"
|
|||||||
import { Tool } from "@opencode-ai/schema/tool"
|
import { Tool } from "@opencode-ai/schema/tool"
|
||||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||||
import { Permission } from "../permission.js"
|
import { Permission } from "../permission.js"
|
||||||
import { Question } from "../question.js"
|
|
||||||
import { Integration } from "../integration.js"
|
import { Integration } from "../integration.js"
|
||||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
|
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
|
||||||
import { SessionRunnerModel } from "./runner/model.js"
|
import { SessionRunnerModel } from "./runner/model.js"
|
||||||
@@ -37,7 +36,6 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
|
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||||
if (cause instanceof Question.RejectedError) return { type: "aborted", message: cause.message }
|
|
||||||
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
|
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
|
||||||
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
||||||
// The canonical error is the sole model-visible representation, so a cause
|
// The canonical error is the sole model-visible representation, so a cause
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { shellParserWasm } from "#shell-parser-wasm"
|
|||||||
import { ShellSelect } from "./select.js"
|
import { ShellSelect } from "./select.js"
|
||||||
|
|
||||||
type Part = { type: string; text: string }
|
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 CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||||
|
|
||||||
@@ -152,7 +153,17 @@ const ARITY: Record<string, number> = {
|
|||||||
"yarn run": 3,
|
"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 parsers = yield* Effect.promise(load)
|
||||||
const powershell = ShellSelect.ps(shell)
|
const powershell = ShellSelect.ps(shell)
|
||||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
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) {
|
function parts(node: Node) {
|
||||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||||
const child = node.child(index)
|
const child = node.child(index)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
|||||||
import { Effect, Schema } from "effect"
|
import { Effect, Schema } from "effect"
|
||||||
import { Form } from "../../form.js"
|
import { Form } from "../../form.js"
|
||||||
import { Permission } from "../../permission.js"
|
import { Permission } from "../../permission.js"
|
||||||
import { Question } from "../../question.js"
|
import { Question } from "@opencode-ai/schema/question"
|
||||||
|
|
||||||
export const name = "question"
|
export const name = "question"
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,11 @@ export const Plugin = {
|
|||||||
invocation.cwd = target.absolute
|
invocation.cwd = target.absolute
|
||||||
finalTimeout = invocation.timeout
|
finalTimeout = invocation.timeout
|
||||||
if (!unrestricted) {
|
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) =>
|
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user