mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fbbb043d4a | |||
| a9c3d484ba |
@@ -1,8 +0,0 @@
|
|||||||
---
|
|
||||||
"@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.
|
|
||||||
@@ -5,5 +5,4 @@
|
|||||||
"@opencode-ai/client": minor
|
"@opencode-ai/client": minor
|
||||||
---
|
---
|
||||||
|
|
||||||
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
|
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
|
||||||
external directories, while the default tree-sitter path remains unchanged.
|
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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,10 +72,20 @@ jobs:
|
|||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
run: GITHUB_ACTIONS=false bun turbo test
|
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
||||||
env:
|
env:
|
||||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||||
|
|
||||||
|
- name: Verify PowerShell 7 scanner conformance
|
||||||
|
if: always() && runner.os == 'Windows'
|
||||||
|
working-directory: packages/shell-scan
|
||||||
|
run: PWSH=pwsh bun run research:powershell
|
||||||
|
|
||||||
|
- name: Verify Windows PowerShell scanner conformance
|
||||||
|
if: always() && runner.os == 'Windows'
|
||||||
|
working-directory: packages/shell-scan
|
||||||
|
run: PWSH=powershell.exe bun run research:powershell
|
||||||
|
|
||||||
- name: Verify compiled service lifecycle
|
- name: Verify compiled service lifecycle
|
||||||
if: always()
|
if: always()
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
|
|||||||
@@ -0,0 +1,253 @@
|
|||||||
|
---
|
||||||
|
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,20 +12,6 @@
|
|||||||
- 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/`.
|
||||||
|
|||||||
+5
-7
@@ -33,6 +33,7 @@
|
|||||||
"packages": [
|
"packages": [
|
||||||
"packages/*",
|
"packages/*",
|
||||||
"packages/console/*",
|
"packages/console/*",
|
||||||
|
"packages/lab/*",
|
||||||
"packages/stats/*",
|
"packages/stats/*",
|
||||||
"packages/slack"
|
"packages/slack"
|
||||||
],
|
],
|
||||||
@@ -46,9 +47,9 @@
|
|||||||
"@octokit/rest": "22.0.0",
|
"@octokit/rest": "22.0.0",
|
||||||
"@hono/standard-validator": "0.2.0",
|
"@hono/standard-validator": "0.2.0",
|
||||||
"@hono/zod-validator": "0.4.2",
|
"@hono/zod-validator": "0.4.2",
|
||||||
"@opentui/core": "0.5.3",
|
"@opentui/core": "0.5.2",
|
||||||
"@opentui/keymap": "0.5.3",
|
"@opentui/keymap": "0.5.2",
|
||||||
"@opentui/solid": "0.5.3",
|
"@opentui/solid": "0.5.2",
|
||||||
"@tanstack/solid-virtual": "3.13.32",
|
"@tanstack/solid-virtual": "3.13.32",
|
||||||
"@shikijs/stream": "4.2.0",
|
"@shikijs/stream": "4.2.0",
|
||||||
"@standard-schema/spec": "1.1.0",
|
"@standard-schema/spec": "1.1.0",
|
||||||
@@ -144,10 +145,6 @@
|
|||||||
"esbuild",
|
"esbuild",
|
||||||
"node-pty",
|
"node-pty",
|
||||||
"protobufjs",
|
"protobufjs",
|
||||||
"tree-sitter",
|
|
||||||
"tree-sitter-bash",
|
|
||||||
"tree-sitter-powershell",
|
|
||||||
"web-tree-sitter",
|
|
||||||
"electron"
|
"electron"
|
||||||
],
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -173,6 +170,7 @@
|
|||||||
"@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 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.
|
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.
|
||||||
|
|
||||||
### 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` and `chat`.
|
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses`, `responsesWebSocket`, 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,10 +124,11 @@ 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`. 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.
|
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`.
|
||||||
|
|
||||||
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(...)`.
|
||||||
|
|
||||||
@@ -153,16 +154,14 @@ 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 execution types + HttpTransport / WebSocketTransport namespaces
|
index.ts Transport type + 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 direct one-request channel executor + raw socket adapter
|
websocket.ts WebSocketTransport.json + WebSocketExecutor service
|
||||||
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
|
||||||
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
|
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
|
||||||
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,6 +315,7 @@ 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 },
|
||||||
})
|
})
|
||||||
@@ -331,7 +332,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`
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# LLM Provider Parity Status
|
# LLM Provider Parity Status
|
||||||
|
|
||||||
Last reviewed: 2026-08-07
|
Last reviewed: 2026-07-24
|
||||||
|
|
||||||
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,7 +16,8 @@ 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 | `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 HTTP | `src/protocols/open-responses.ts`, `src/protocols/openai-responses.ts`, `src/providers/openai.ts` | Usable. Extends the Open Responses baseline with hosted-tool event surfacing, reasoning replay metadata, GPT-5 defaults, and cache usage. | No explicit `previous_response_id` path. Typed options cover only a subset of Responses fields. Structured output is still mostly synthetic-tool based. |
|
||||||
|
| OpenAI Responses WebSocket | `src/protocols/openai-responses.ts`, `src/route/transport/websocket.ts` | Present as `OpenAI.responsesWebSocket(...)`. | Runner/catalog support explicitly must not downgrade WebSocket routes; broader runtime selection is not complete. |
|
||||||
| OpenAI-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,8 +49,8 @@ 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` | 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` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
|
||||||
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
|
| `@ai-sdk/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. |
|
||||||
@@ -78,9 +79,9 @@ 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 default and optional per-call WebSocket execution. |
|
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
| 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`. |
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ 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")
|
||||||
@@ -249,6 +250,11 @@ 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),
|
||||||
@@ -260,11 +266,13 @@ 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,
|
||||||
@@ -334,19 +342,22 @@ const response =
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
For direct provider-facade calls, Responses has one semantic model and route:
|
For direct provider-facade calls, HTTP versus WebSocket is represented as named
|
||||||
|
route selectors, not as model or request overrides. Same protocol, different
|
||||||
|
transport, different route:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
OpenAI.responses("gpt-4o")
|
OpenAI.responses("gpt-4o")
|
||||||
|
OpenAI.responsesWebSocket("gpt-4o")
|
||||||
```
|
```
|
||||||
|
|
||||||
The package-like OpenAI Responses entrypoint has the same transport-neutral
|
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
|
||||||
`model(...)` contract:
|
Responses settings while preserving the same `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 })
|
model("gpt-4o", { apiKey, transport: "websocket" })
|
||||||
```
|
```
|
||||||
|
|
||||||
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
|
Vertex keeps Gemini, Chat, Responses, and Messages as separate package-like entrypoints,
|
||||||
@@ -376,9 +387,11 @@ 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 does not require a different public layer for WebSocket execution.
|
The client should not require a different public layer just because a selected
|
||||||
Responses routes use HTTP by default, and callers may pass a channel executor per
|
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
|
||||||
call. Routes without channel support simply ignore that execution capability.
|
capabilities available; routes that do not need WebSocket simply never touch it.
|
||||||
|
If a WebSocket route is selected in an environment without WebSocket support,
|
||||||
|
fail with a typed transport configuration error.
|
||||||
|
|
||||||
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
|
||||||
@@ -484,13 +497,18 @@ generic dynamic resolver:
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const model =
|
const model =
|
||||||
providerID === "azure" ? Azure.configure(resolvedAzureConfig).responses(apiModelID) : OpenAI.responses(apiModelID)
|
providerID === "azure"
|
||||||
|
? 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. Transport selection remains execution policy: a Session
|
provider APIs directly. A direct provider-facade boundary maps metadata like
|
||||||
or other caller may pass a WebSocket channel executor per call without changing
|
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
|
||||||
the model constructed by this boundary.
|
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
|
||||||
|
The client runtime only executes the route carried by the resulting model.
|
||||||
|
|
||||||
## Competitive Shape
|
## Competitive Shape
|
||||||
|
|
||||||
@@ -526,8 +544,9 @@ 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 setting on a provider or executable model. OpenAI Responses uses
|
- No transport override on an executable model or request. Direct provider
|
||||||
HTTP by default and accepts an optional per-call channel executor as execution policy.
|
facades use `responses` versus `responsesWebSocket`; the package-like Responses
|
||||||
|
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
|
||||||
@@ -561,10 +580,12 @@ 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` accepts
|
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
|
||||||
optional per-call channel execution without changing route identity.
|
exposes available transport capabilities and selected routes fail with typed
|
||||||
|
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)` and `.chat(id)`.
|
`OpenAI.configure(config).responses(id)`, `.chat(id)`, and
|
||||||
|
`.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
|
||||||
@@ -578,8 +599,10 @@ 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] Keep executable model construction transport-neutral at the Session boundary;
|
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
|
||||||
Session-scoped execution policy supplies channel capability separately.
|
executable models at the session boundary with explicit provider facade
|
||||||
|
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 } from "@opencode-ai/ai/route"
|
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor, WebSocketExecutor } from "@opencode-ai/ai/route"
|
||||||
import { OpenAI } from "@opencode-ai/ai/providers"
|
import { OpenAI } from "@opencode-ai/ai/providers"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -213,7 +213,8 @@ 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 llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
const llmDeps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||||
|
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(llmDeps))
|
||||||
|
|
||||||
const program = Effect.gen(function* () {
|
const program = Effect.gen(function* () {
|
||||||
// yield* generateOnce
|
// yield* generateOnce
|
||||||
@@ -221,6 +222,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(requestExecutorLayer, llmClientLayer)))
|
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
|
||||||
|
|
||||||
Effect.runPromise(program)
|
Effect.runPromise(program)
|
||||||
|
|||||||
@@ -7,4 +7,3 @@ 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"
|
|
||||||
|
|||||||
@@ -1,191 +0,0 @@
|
|||||||
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,43 +211,11 @@ 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,
|
||||||
@@ -272,9 +240,6 @@ 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)],
|
||||||
)
|
)
|
||||||
@@ -667,9 +632,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` and `error` are hard failures. All four end
|
// `finish` event; `response.failed` is a hard failure. All three end the stream,
|
||||||
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
|
// so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||||
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
|
const TERMINAL_TYPES = new Set(["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 => {
|
||||||
@@ -1001,24 +966,16 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
|||||||
return message || code || fallback
|
return message || code || fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
const providerError = (state: ParserState, 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: id,
|
module: state.id,
|
||||||
method: "stream",
|
method: "stream",
|
||||||
reason: classifyProviderFailure({ message, code, status }),
|
reason: classifyProviderFailure({ message, code }),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
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`)
|
||||||
@@ -1058,11 +1015,7 @@ 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")
|
if (event.type === "error") return providerError(state, event, `${state.name} stream 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])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
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,23 +1,18 @@
|
|||||||
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 } from "../route/transport/index.js"
|
import { HttpTransport, WebSocketTransport } 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
|
||||||
|
|
||||||
@@ -62,6 +57,16 @@ 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,
|
||||||
@@ -244,13 +249,6 @@ 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,
|
||||||
@@ -259,7 +257,36 @@ export const route = Route.make({
|
|||||||
protocol,
|
protocol,
|
||||||
endpoint,
|
endpoint,
|
||||||
auth,
|
auth,
|
||||||
transport,
|
transport: httpTransport,
|
||||||
|
defaults: { providerOptions: { openai: { store: false } } },
|
||||||
|
})
|
||||||
|
|
||||||
|
const decodeWebSocketMessage = ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenAIResponsesWebSocketMessage))
|
||||||
|
|
||||||
|
const webSocketMessage = (body: OpenAIResponsesBody | Record<string, unknown>) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
if (!ProviderShared.isRecord(body))
|
||||||
|
return yield* ProviderShared.invalidRequest("OpenAI Responses WebSocket body must be a JSON object")
|
||||||
|
const { stream: _stream, ...message } = body
|
||||||
|
return yield* decodeWebSocketMessage({ ...message, type: "response.create" })
|
||||||
|
})
|
||||||
|
|
||||||
|
export const webSocketTransport = WebSocketTransport.jsonTransport.with<
|
||||||
|
OpenAIResponsesBody,
|
||||||
|
OpenAIResponsesWebSocketMessage
|
||||||
|
>({
|
||||||
|
toMessage: webSocketMessage,
|
||||||
|
encodeMessage: encodeWebSocketMessage,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const webSocketRoute = Route.make({
|
||||||
|
id: `${ADAPTER}-websocket`,
|
||||||
|
provider: "openai",
|
||||||
|
providerMetadataKey: "openai",
|
||||||
|
protocol,
|
||||||
|
endpoint,
|
||||||
|
auth,
|
||||||
|
transport: webSocketTransport,
|
||||||
defaults: { providerOptions: { openai: { store: false } } },
|
defaults: { providerOptions: { openai: { store: false } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ 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, OpenAIChat.route]
|
export const routes = [OpenAIResponses.route, OpenAIResponses.webSocketRoute, 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,6 +63,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +82,17 @@ 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) =>
|
||||||
@@ -105,6 +111,7 @@ export const configure = (input: Config = {}) => {
|
|||||||
id,
|
id,
|
||||||
model: responses,
|
model: responses,
|
||||||
responses,
|
responses,
|
||||||
|
responsesWebSocket,
|
||||||
chat,
|
chat,
|
||||||
image,
|
image,
|
||||||
configure,
|
configure,
|
||||||
@@ -131,7 +138,10 @@ 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) => {
|
||||||
return configure(config(settings)).responses(modelID)
|
const configured = configure(config(settings))
|
||||||
|
if (settings.transport === undefined || settings.transport === "http") return configured.responses(modelID)
|
||||||
|
if (settings.transport === "websocket") return configured.responsesWebSocket(modelID)
|
||||||
|
throw new Error(`Unsupported OpenAI Responses transport: ${String(settings.transport)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptionsInput>["model"] = (
|
||||||
@@ -139,5 +149,6 @@ 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,10 +1,12 @@
|
|||||||
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, WebSocketChannelExecutor } from "./transport/index.js"
|
import type { HttpMiddleware, Transport, TransportRuntime } 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"
|
||||||
@@ -56,7 +58,6 @@ 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>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +157,6 @@ 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,18 +314,16 @@ 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, options?: StreamOptions) => {
|
streamPrepared: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => {
|
||||||
const route = `${request.model.provider}/${request.model.route.id}`
|
const route = `${request.model.provider}/${request.model.route.id}`
|
||||||
return Stream.unwrap(
|
const events = routeInput.transport
|
||||||
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
.frames(prepared, request, runtime)
|
||||||
Effect.map((execution) => {
|
.pipe(
|
||||||
const events = execution.frames.pipe(
|
|
||||||
Stream.mapEffect(decodeEvent(route)),
|
Stream.mapEffect(decodeEvent(route)),
|
||||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||||
)
|
)
|
||||||
const stream = events.pipe(
|
return events.pipe(
|
||||||
Stream.mapAccumEffect(
|
Stream.mapAccumEffect(
|
||||||
() => protocol.stream.initial(request),
|
() => protocol.stream.initial(request),
|
||||||
protocol.stream.step,
|
protocol.stream.step,
|
||||||
@@ -334,10 +332,6 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
|||||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||||
requireTerminalEvent(route),
|
requireTerminalEvent(route),
|
||||||
)
|
)
|
||||||
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
} satisfies Route<Body, Prepared>
|
} satisfies Route<Body, Prepared>
|
||||||
return route
|
return route
|
||||||
@@ -419,7 +413,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, options)
|
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -457,6 +451,7 @@ 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,8 +34,44 @@ 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 headerDetails = (headers: Headers.Headers) =>
|
const BODY_LIMIT = 16_384
|
||||||
Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)]))
|
const REDACTED = "<redacted>"
|
||||||
|
|
||||||
|
// One source of truth for what counts as a sensitive name across headers,
|
||||||
|
// URL query keys, and field names embedded inside request/response bodies.
|
||||||
|
//
|
||||||
|
// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header
|
||||||
|
// names like `Authorization` / `X-API-Key`) and as the body-field alternation
|
||||||
|
// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…`
|
||||||
|
// that are too generic to redact substring-style without false positives.
|
||||||
|
const SENSITIVE_NAME_SOURCE =
|
||||||
|
"authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
|
||||||
|
const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
|
||||||
|
const SHORT_QUERY_NAME = /^(key|sig)$/i
|
||||||
|
const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
|
||||||
|
const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
|
||||||
|
const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")
|
||||||
|
|
||||||
|
const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
|
||||||
|
|
||||||
|
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
|
||||||
|
|
||||||
|
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
|
||||||
|
name,
|
||||||
|
String(value),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const redactUrl = (value: string) => {
|
||||||
|
if (!URL.canParse(value)) return REDACTED
|
||||||
|
const url = new URL(value)
|
||||||
|
url.searchParams.forEach((_, key) => {
|
||||||
|
if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED)
|
||||||
|
})
|
||||||
|
return url.toString()
|
||||||
|
}
|
||||||
|
|
||||||
const 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]))
|
||||||
@@ -108,22 +144,58 @@ const rateLimitDetails = (headers: Record<string, string>, retryAfter: number |
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const requestDetails = (request: HttpClientRequest.HttpClientRequest) =>
|
const requestDetails = (request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
|
||||||
new HttpRequestDetails({
|
new HttpRequestDetails({
|
||||||
method: request.method,
|
method: request.method,
|
||||||
url: request.url,
|
url: redactUrl(request.url),
|
||||||
headers: headerDetails(request.headers),
|
headers: redactHeaders(request.headers, redactedNames),
|
||||||
})
|
})
|
||||||
|
|
||||||
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
|
const responseDetails = (
|
||||||
|
response: HttpClientResponse.HttpClientResponse,
|
||||||
|
redactedNames: ReadonlyArray<string | RegExp>,
|
||||||
|
) =>
|
||||||
new HttpResponseDetails({
|
new HttpResponseDetails({
|
||||||
status: response.status,
|
status: response.status,
|
||||||
headers: headerDetails(response.headers),
|
headers: redactHeaders(response.headers, redactedNames),
|
||||||
})
|
})
|
||||||
|
|
||||||
const responseBody = (body: string | void) => {
|
const secretValues = (request: HttpClientRequest.HttpClientRequest) => {
|
||||||
|
const values = new Set<string>()
|
||||||
|
const add = (value: string) => {
|
||||||
|
if (value.length < 4) return
|
||||||
|
values.add(value)
|
||||||
|
values.add(encodeURIComponent(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
Object.entries(request.headers).forEach(([name, value]) => {
|
||||||
|
if (!isSensitiveHeaderName(name)) return
|
||||||
|
add(value)
|
||||||
|
const bearer = /^Bearer\s+(.+)$/i.exec(value)?.[1]
|
||||||
|
if (bearer) add(bearer)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!URL.canParse(request.url)) return values
|
||||||
|
new URL(request.url).searchParams.forEach((value, key) => {
|
||||||
|
if (isSensitiveQueryName(key)) add(value)
|
||||||
|
})
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two passes: structural (redact `"name": "value"` and `name=value` patterns
|
||||||
|
// for any field name that looks sensitive) plus literal (replace any actual
|
||||||
|
// secret values we sent in the request, in case the response echoes one back).
|
||||||
|
const redactBody = (body: string, secrets: ReadonlySet<string>) =>
|
||||||
|
Array.from(secrets).reduce(
|
||||||
|
(text, secret) => text.split(secret).join(REDACTED),
|
||||||
|
body.replace(REDACT_JSON_FIELD, `$1"${REDACTED}"`).replace(REDACT_QUERY_FIELD, `$1${REDACTED}`),
|
||||||
|
)
|
||||||
|
|
||||||
|
const responseBody = (body: string | void, secrets: ReadonlySet<string>) => {
|
||||||
if (body === undefined) return {}
|
if (body === undefined) return {}
|
||||||
return { body }
|
const redacted = redactBody(body, secrets)
|
||||||
|
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(
|
||||||
@@ -135,49 +207,52 @@ const decodeProviderBody = Schema.decodeUnknownOption(
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const providerMessage = (status: number, body: string | void) => {
|
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
||||||
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
|
if (body.body && body.body.length <= 500) {
|
||||||
return (
|
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
||||||
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
|
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
||||||
`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),
|
request: requestDetails(input.request, input.redactedNames),
|
||||||
response: responseDetails(input.response),
|
response: responseDetails(input.response, input.redactedNames),
|
||||||
...input.body,
|
...input.body,
|
||||||
requestId: input.requestId,
|
requestId: input.requestId,
|
||||||
rateLimit: input.rateLimit,
|
rateLimit: input.rateLimit,
|
||||||
})
|
})
|
||||||
|
|
||||||
const statusError =
|
const statusError =
|
||||||
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
|
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
|
||||||
|
(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)
|
const details = responseBody(body, secretValues(request))
|
||||||
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, body),
|
message: providerMessage(response.status, details),
|
||||||
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,
|
||||||
@@ -187,10 +262,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 HttpContext that
|
// AI SDK's own fetch) onto the same reason types and redacted 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.
|
// request headers are empty, and only structural body redaction applies.
|
||||||
export const classifyHttpFailure = (input: {
|
export const classifyHttpFailure = (input: {
|
||||||
readonly message: string
|
readonly message: string
|
||||||
readonly url: string
|
readonly url: string
|
||||||
@@ -202,7 +277,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)
|
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
||||||
return classifyProviderFailure({
|
return classifyProviderFailure({
|
||||||
message: input.message,
|
message: input.message,
|
||||||
status: input.status,
|
status: input.status,
|
||||||
@@ -210,11 +285,11 @@ export const classifyHttpFailure = (input: {
|
|||||||
retryAfterMs: retryAfter,
|
retryAfterMs: retryAfter,
|
||||||
rateLimit,
|
rateLimit,
|
||||||
http: new HttpContext({
|
http: new HttpContext({
|
||||||
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
|
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
||||||
response:
|
response:
|
||||||
input.status === undefined
|
input.status === undefined
|
||||||
? undefined
|
? undefined
|
||||||
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
||||||
...details,
|
...details,
|
||||||
requestId: requestId(headers),
|
requestId: requestId(headers),
|
||||||
rateLimit,
|
rateLimit,
|
||||||
@@ -244,6 +319,7 @@ 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 }) =>
|
||||||
@@ -255,8 +331,8 @@ const httpError = (input: {
|
|||||||
transport: "http",
|
transport: "http",
|
||||||
operation: input.operation,
|
operation: input.operation,
|
||||||
code: failure.code,
|
code: failure.code,
|
||||||
url: request.url,
|
url: redactUrl(request.url),
|
||||||
http: new HttpContext({ request: requestDetails(request) }),
|
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -267,7 +343,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
|
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
|
||||||
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))
|
||||||
@@ -293,9 +369,10 @@ 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" })),
|
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read", redactedNames })),
|
||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -306,18 +383,19 @@ 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" })),
|
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
||||||
Effect.flatMap(statusError(request)),
|
Effect.flatMap(statusError(request, redactedNames)),
|
||||||
)
|
)
|
||||||
|
|
||||||
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" })))
|
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
||||||
return yield* statusError(response.request)(response)
|
return yield* statusError(response.request, redactedNames)(response)
|
||||||
})
|
})
|
||||||
return Service.of({
|
return Service.of({
|
||||||
execute: executeOnce,
|
execute: executeOnce,
|
||||||
|
|||||||
@@ -16,28 +16,11 @@ 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, WebSocketTransport } from "./transport/index.js"
|
export { HttpTransport, WebSocketExecutor, 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 {
|
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport/index.js"
|
||||||
ChannelCheckpoint,
|
|
||||||
ChannelCreate,
|
|
||||||
ChannelObservation,
|
|
||||||
HttpHandler,
|
|
||||||
HttpMiddleware,
|
|
||||||
Transport as TransportDef,
|
|
||||||
TransportExecuteOptions,
|
|
||||||
TransportExecution,
|
|
||||||
TransportRuntime,
|
|
||||||
WebSocketConnection,
|
|
||||||
WebSocketChannelDriver,
|
|
||||||
WebSocketChannelExchange,
|
|
||||||
WebSocketChannelExecution,
|
|
||||||
WebSocketChannelExecutor,
|
|
||||||
WebSocketConnector,
|
|
||||||
WebSocketRequest,
|
|
||||||
} from "./transport/index.js"
|
|
||||||
|
|||||||
@@ -87,10 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
|||||||
middleware: prepareInput.middleware,
|
middleware: prepareInput.middleware,
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
execute: (prepared, _request, runtime) =>
|
frames: (prepared, _request, runtime) =>
|
||||||
Effect.succeed({
|
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||||
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const sseJson = {
|
export const sseJson = {
|
||||||
|
|||||||
@@ -1,33 +1,19 @@
|
|||||||
import type { Effect, Scope, Stream } from "effect"
|
import type { Effect, 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 { WebSocketChannelExecutor } from "./websocket-channel.js"
|
import type { Interface as WebSocketExecutorInterface } from "./websocket.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 execute: (
|
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
|
||||||
prepared: Prepared,
|
|
||||||
request: LLMRequest,
|
|
||||||
runtime: TransportRuntime,
|
|
||||||
options?: TransportExecuteOptions,
|
|
||||||
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TransportPrepareInput<Body> {
|
export interface TransportPrepareInput<Body> {
|
||||||
@@ -38,19 +24,8 @@ 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 type {
|
export { WebSocketExecutor, WebSocketTransport } from "./websocket.js"
|
||||||
ChannelCheckpoint,
|
|
||||||
ChannelCreate,
|
|
||||||
ChannelObservation,
|
|
||||||
WebSocketChannelDriver,
|
|
||||||
WebSocketChannelExchange,
|
|
||||||
WebSocketChannelExecution,
|
|
||||||
WebSocketChannelExecutor,
|
|
||||||
} from "./websocket-channel.js"
|
|
||||||
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket.js"
|
|
||||||
export { WebSocketTransport } from "./websocket.js"
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
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,15 +1,8 @@
|
|||||||
import { Cause, Effect, Queue, Stream } from "effect"
|
import { Cause, Context, Effect, Layer, 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
|
||||||
@@ -22,29 +15,24 @@ export interface WebSocketConnection {
|
|||||||
readonly close: Effect.Effect<void, never>
|
readonly close: Effect.Effect<void, never>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WebSocketConnector {
|
export interface Interface {
|
||||||
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
||||||
}
|
}
|
||||||
|
|
||||||
type WebSocketConstructorWithHeaders = (
|
type WebSocketConstructorWithHeaders = new (
|
||||||
url: string,
|
url: string,
|
||||||
options?: { readonly headers?: Headers.Headers },
|
options?: { readonly headers?: Headers.Headers },
|
||||||
) => globalThis.WebSocket
|
) => globalThis.WebSocket
|
||||||
|
|
||||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
||||||
|
|
||||||
const transportError = (
|
const transportError = (
|
||||||
method: string,
|
method: string,
|
||||||
message: string,
|
message: string,
|
||||||
input: {
|
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
|
||||||
readonly operation: TransportOperation
|
|
||||||
readonly url?: string
|
|
||||||
readonly code?: string
|
|
||||||
readonly phase?: TransportReason["phase"]
|
|
||||||
readonly delivery?: TransportReason["delivery"]
|
|
||||||
},
|
|
||||||
) =>
|
) =>
|
||||||
new AIError({
|
new AIError({
|
||||||
module: "WebSocketConnector",
|
module: "WebSocketExecutor",
|
||||||
method,
|
method,
|
||||||
reason: new TransportReason({
|
reason: new TransportReason({
|
||||||
message,
|
message,
|
||||||
@@ -52,33 +40,9 @@ 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
|
||||||
@@ -99,8 +63,6 @@ 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",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -127,8 +89,6 @@ 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",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -141,8 +101,6 @@ 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",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -154,7 +112,7 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export const toWebSocketUrl = (value: string) =>
|
const webSocketUrl = (value: string) =>
|
||||||
Effect.try({
|
Effect.try({
|
||||||
try: () => {
|
try: () => {
|
||||||
const url = new URL(value)
|
const url = new URL(value)
|
||||||
@@ -173,31 +131,21 @@ export const toWebSocketUrl = (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.gen(function* () {
|
Effect.try({
|
||||||
const constructor = yield* Socket.WebSocketConstructor
|
|
||||||
const ws = yield* Effect.try({
|
|
||||||
try: () =>
|
try: () =>
|
||||||
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
||||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
|
||||||
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
|
||||||
headers: input.headers,
|
|
||||||
}),
|
|
||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "request",
|
operation: "request",
|
||||||
phase: "connect",
|
|
||||||
delivery: "not-sent",
|
|
||||||
}),
|
}),
|
||||||
})
|
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||||
return yield* fromWebSocket(ws, input)
|
|
||||||
})
|
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
||||||
|
|
||||||
export const fromWebSocket = (
|
export const fromWebSocket = (
|
||||||
ws: globalThis.WebSocket,
|
ws: globalThis.WebSocket,
|
||||||
@@ -207,52 +155,16 @@ 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 offer(event.data)
|
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
|
||||||
const binary = binaryMessage(event.data)
|
const binary = binaryMessage(event.data)
|
||||||
if (binary) return offer(binary)
|
if (binary) return Queue.offerUnsafe(messages, 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",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -264,13 +176,12 @@ 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(
|
||||||
@@ -278,7 +189,6 @@ export const fromWebSocket = (
|
|||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "read",
|
operation: "read",
|
||||||
code: String(event.code),
|
code: String(event.code),
|
||||||
phase: "close",
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -295,26 +205,13 @@ export const fromWebSocket = (
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
sendText: (message) =>
|
sendText: (message) =>
|
||||||
Effect.suspend(() => {
|
Effect.try({
|
||||||
if (ws.readyState !== globalThis.WebSocket.OPEN)
|
|
||||||
return Effect.fail(
|
|
||||||
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
|
|
||||||
url: input.url,
|
|
||||||
operation: "write",
|
|
||||||
phase: "send",
|
|
||||||
delivery: "not-sent",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
return Effect.try({
|
|
||||||
try: () => ws.send(message),
|
try: () => ws.send(message),
|
||||||
catch: (error) =>
|
catch: (error) =>
|
||||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||||
url: input.url,
|
url: input.url,
|
||||||
operation: "write",
|
operation: "write",
|
||||||
phase: "send",
|
|
||||||
delivery: "not-sent",
|
|
||||||
}),
|
}),
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromQueue(messages),
|
messages: Stream.fromQueue(messages),
|
||||||
close: cleanup.pipe(
|
close: cleanup.pipe(
|
||||||
@@ -331,57 +228,6 @@ 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
|
||||||
@@ -408,44 +254,33 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
|||||||
...prepareInput,
|
...prepareInput,
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
url: yield* toWebSocketUrl(parts.url),
|
url: yield* webSocketUrl(parts.url),
|
||||||
headers: parts.headers,
|
headers: parts.headers,
|
||||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
execute: (prepared, request, _runtime, options) => {
|
frames: (prepared, _request, runtime) => {
|
||||||
const webSocket = options?.webSocket
|
const webSocket = runtime.webSocket
|
||||||
if (!webSocket) {
|
if (!webSocket) {
|
||||||
return Effect.fail(
|
return Stream.fail(
|
||||||
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||||
url: prepared.url,
|
url: prepared.url,
|
||||||
operation: "request",
|
operation: "request",
|
||||||
code: "unavailable",
|
code: "unavailable",
|
||||||
phase: "prepare",
|
|
||||||
delivery: "not-sent",
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const driver: WebSocketChannelDriver = {
|
const decoder = new TextDecoder()
|
||||||
create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
|
return Stream.unwrap(
|
||||||
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
Effect.gen(function* () {
|
||||||
}
|
const connection = yield* Effect.acquireRelease(
|
||||||
const exchange: WebSocketChannelExchange = {
|
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
||||||
id: request.id ?? "request",
|
(connection) => connection.close,
|
||||||
connect: { url: prepared.url, headers: prepared.headers },
|
)
|
||||||
fallback: () =>
|
yield* connection.sendText(prepared.message)
|
||||||
Stream.fail(
|
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -454,13 +289,15 @@ export const jsonTransport = {
|
|||||||
with: json,
|
with: json,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const WebSocketTransport = {
|
export const WebSocketExecutor = {
|
||||||
json,
|
Service,
|
||||||
jsonTransport,
|
layer,
|
||||||
direct,
|
|
||||||
makeDirect,
|
|
||||||
open,
|
open,
|
||||||
fromWebSocket,
|
fromWebSocket,
|
||||||
messageText,
|
messageText,
|
||||||
toWebSocketUrl,
|
} as const
|
||||||
|
|
||||||
|
export const WebSocketTransport = {
|
||||||
|
json,
|
||||||
|
jsonTransport,
|
||||||
} as const
|
} as const
|
||||||
|
|||||||
@@ -106,13 +106,6 @@ 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,13 +1,12 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
import { Effect, 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, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||||
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
import * as OpenAIChat from "../src/protocols/openai-chat.js"
|
||||||
import * as OpenAI from "../src/providers/openai.js"
|
import { dynamicResponse, systemError } from "./lib/http.js"
|
||||||
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
|
|
||||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
import { 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(
|
||||||
@@ -66,7 +65,6 @@ 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", () =>
|
||||||
@@ -77,11 +75,11 @@ describe("RequestExecutor", () => {
|
|||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(error.reason).toMatchObject({
|
expect(error.reason).toMatchObject({
|
||||||
_tag: "Transport",
|
_tag: "Transport",
|
||||||
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
|
message: "ECONNRESET: disconnected <redacted> <redacted>",
|
||||||
transport: "http",
|
transport: "http",
|
||||||
operation: "read",
|
operation: "read",
|
||||||
code: "ECONNRESET",
|
code: "ECONNRESET",
|
||||||
url: "https://provider.test/v1/chat?api_key=query-secret-123&debug=1",
|
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&debug=1",
|
||||||
})
|
})
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
@@ -154,12 +152,12 @@ describe("RequestExecutor", () => {
|
|||||||
expectAIError(error)
|
expectAIError(error)
|
||||||
expect(error.reason).toMatchObject({
|
expect(error.reason).toMatchObject({
|
||||||
_tag: "Transport",
|
_tag: "Transport",
|
||||||
message: "ECONNRESET: proxy disconnected proxy-secret",
|
message: "ECONNRESET: proxy disconnected <redacted>",
|
||||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||||
http: {
|
http: {
|
||||||
request: {
|
request: {
|
||||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||||
headers: { authorization: "Bearer proxy-secret" },
|
headers: { authorization: "<redacted>" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -219,47 +217,9 @@ 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) =>
|
||||||
@@ -293,7 +253,7 @@ describe("RequestExecutor", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("returns complete diagnostics for rate limits", () =>
|
it.effect("returns redacted 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)
|
||||||
@@ -308,15 +268,15 @@ describe("RequestExecutor", () => {
|
|||||||
requestId: "req_123",
|
requestId: "req_123",
|
||||||
request: {
|
request: {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||||
headers: { authorization: "Bearer secret", "x-safe": "visible" },
|
headers: { authorization: "<redacted>", "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": "secret",
|
"x-api-key": "<redacted>",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -335,14 +295,14 @@ describe("RequestExecutor", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves configured header names in diagnostics", () =>
|
it.effect("honors current redacted 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("visible")
|
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
|
||||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
|
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
|
||||||
}).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"]),
|
||||||
@@ -461,15 +421,15 @@ describe("RequestExecutor", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves large authentication error bodies", () =>
|
it.effect("truncates 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).toBeUndefined()
|
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
||||||
expect(errorHttp(error)?.body).toHaveLength(20_000)
|
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
responsesLayer([
|
||||||
@@ -480,15 +440,16 @@ describe("RequestExecutor", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves response body fields", () =>
|
it.effect("redacts common secret fields in response 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(errorHttp(error)?.body).toBe(
|
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
|
||||||
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
|
expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
|
||||||
)
|
expect(errorHttp(error)?.body).not.toContain("body-secret")
|
||||||
|
expect(errorHttp(error)?.body).not.toContain("query-secret")
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
responsesLayer([
|
||||||
@@ -500,13 +461,16 @@ describe("RequestExecutor", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("preserves echoed request values in response bodies", () =>
|
it.effect("redacts echoed request secret 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).toBe("provider echoed query-secret-123 and authorization header-secret-456")
|
expect(errorHttp(error)?.body).toContain("provider echoed <redacted>")
|
||||||
|
expect(errorHttp(error)?.body).toContain("authorization <redacted>")
|
||||||
|
expect(errorHttp(error)?.body).not.toContain("query-secret-123")
|
||||||
|
expect(errorHttp(error)?.body).not.toContain("header-secret-456")
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.provide(
|
Effect.provide(
|
||||||
responsesLayer([
|
responsesLayer([
|
||||||
@@ -547,146 +511,3 @@ 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, WebSocketTransport } from "@opencode-ai/ai/route"
|
import { Route, Protocol } 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,7 +16,6 @@ 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"
|
||||||
@@ -37,7 +36,6 @@ 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 () => {
|
||||||
@@ -45,6 +43,7 @@ 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(
|
||||||
@@ -66,10 +65,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,8 +1,9 @@
|
|||||||
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 } from "../../src/route.js"
|
import { LLMClient, RequestExecutor, WebSocketExecutor } 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
|
||||||
@@ -33,7 +34,7 @@ const handlerLayer = (handler: Handler): Layer.Layer<HttpClient.HttpClient> =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
export type RuntimeEnv = RequestExecutorService | LLMClientService
|
export type RuntimeEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService
|
||||||
|
|
||||||
export interface SystemError extends Error {
|
export interface SystemError extends Error {
|
||||||
readonly code: string
|
readonly code: string
|
||||||
@@ -43,8 +44,9 @@ 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 llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||||
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
|
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
|
||||||
|
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"}', '{"code":"slow_down"}'].map(
|
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
||||||
(message) => classifyProviderFailure({ message })._tag,
|
(message) => classifyProviderFailure({ message })._tag,
|
||||||
),
|
),
|
||||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("classifies transient client statuses as provider internal", () => {
|
test("classifies transient client statuses as provider internal", () => {
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
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 selected = OpenAI.responses("gpt-5")
|
const model = OpenAI.responses("gpt-5")
|
||||||
|
|
||||||
LLM.request({ model: selected, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
LLM.request({ model, prompt: "Hello", providerOptions: { openai: { reasoningEffort: "high" } } })
|
||||||
|
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: selected,
|
model,
|
||||||
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,6 +80,11 @@ 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.reason.message).toContain("`tool_use` ids were found without `tool_result` blocks")
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1098,7 +1098,8 @@ describe("Anthropic Messages route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AIError)
|
expect(error).toBeInstanceOf(AIError)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||||
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1275,7 +1275,8 @@ describe("OpenAI Chat route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AIError)
|
expect(error).toBeInstanceOf(AIError)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||||
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
import { ConfigProvider, Effect, Layer, 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,
|
||||||
@@ -12,23 +11,14 @@ import {
|
|||||||
ToolCallPart,
|
ToolCallPart,
|
||||||
ToolDefinition,
|
ToolDefinition,
|
||||||
ToolResultPart,
|
ToolResultPart,
|
||||||
TransportReason,
|
|
||||||
Usage,
|
Usage,
|
||||||
} from "../../src/index.js"
|
} from "../../src/index.js"
|
||||||
import {
|
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
|
||||||
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"
|
||||||
@@ -41,47 +31,6 @@ 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,
|
||||||
@@ -267,19 +216,19 @@ describe("OpenAI Responses route", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("prepares one OpenAI Responses route for either transport", () =>
|
it.effect("prepares OpenAI Responses WebSocket target", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* compileRequest(
|
const prepared = yield* compileRequest(
|
||||||
LLMRequest.update(request, {
|
LLMRequest.update(request, {
|
||||||
model: OpenAIResponses.route
|
model: OpenAIResponses.webSocketRoute
|
||||||
.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")
|
expect(prepared.route).toBe("openai-responses-websocket")
|
||||||
expect(prepared.protocol).toBe("openai-responses")
|
expect(prepared.protocol).toBe("openai-responses")
|
||||||
expect(prepared.metadata).toEqual({ transport: "http-json" })
|
expect(prepared.metadata).toEqual({ transport: "websocket-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 })
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -287,32 +236,26 @@ 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<{
|
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||||
readonly url: string
|
|
||||||
readonly authorization: string | undefined
|
|
||||||
readonly protocol: string | undefined
|
|
||||||
}> = []
|
|
||||||
let closed = false
|
let closed = false
|
||||||
const deps = Layer.succeed(
|
const deps = Layer.mergeAll(
|
||||||
|
Layer.succeed(
|
||||||
RequestExecutor.Service,
|
RequestExecutor.Service,
|
||||||
RequestExecutor.Service.of({
|
RequestExecutor.Service.of({
|
||||||
execute: () => Effect.die("unexpected HTTP request"),
|
execute: () => Effect.die("unexpected HTTP request"),
|
||||||
}),
|
}),
|
||||||
)
|
),
|
||||||
const webSocket = WebSocketTransport.makeDirect({
|
Layer.succeed(
|
||||||
|
WebSocketExecutor.Service,
|
||||||
|
WebSocketExecutor.Service.of({
|
||||||
open: (input) =>
|
open: (input) =>
|
||||||
Effect.succeed({
|
Effect.succeed({
|
||||||
sendText: (message) =>
|
sendText: (message) =>
|
||||||
Effect.sync(() => {
|
Effect.sync(() => {
|
||||||
opened.push({
|
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||||
url: input.url,
|
|
||||||
authorization: input.headers.authorization,
|
|
||||||
protocol: input.headers["openai-beta"],
|
|
||||||
})
|
|
||||||
sent.push(message)
|
sent.push(message)
|
||||||
}),
|
}),
|
||||||
messages: Stream.fromArray([
|
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.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||||
]),
|
]),
|
||||||
@@ -320,27 +263,20 @@ describe("OpenAI Responses route", () => {
|
|||||||
closed = true
|
closed = true
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
})
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
const response = yield* LLMClient.generate(
|
const response = yield* LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
model: OpenAI.configure({
|
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||||
baseURL: "https://api.openai.test/v1/",
|
"gpt-4.1-mini",
|
||||||
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([
|
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
||||||
{
|
|
||||||
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({
|
||||||
@@ -352,524 +288,15 @@ 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* WebSocketTransport.fromWebSocket(
|
const error = yield* WebSocketExecutor.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" })
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -902,7 +329,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/",
|
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||||
apiKey: "azure-key",
|
apiKey: "azure-key",
|
||||||
headers: { authorization: "Bearer stale" },
|
headers: { authorization: "Bearer stale" },
|
||||||
}).responses("gpt-4.1-mini"),
|
}).responses("gpt-4.1-mini"),
|
||||||
@@ -2610,7 +2037,8 @@ describe("OpenAI Responses route", () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
expect(error).toBeInstanceOf(AIError)
|
expect(error).toBeInstanceOf(AIError)
|
||||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: "Bad request" })
|
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||||
|
expect(error.message).toContain("HTTP 400")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,11 +2,12 @@ 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 } from "../src/route.js"
|
import { LLMClient, RequestExecutor, WebSocketExecutor } 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,
|
||||||
@@ -16,7 +17,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 | LLMClientService | ImageClientService
|
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
||||||
|
|
||||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||||
readonly options?: HttpRecorder.RecorderOptions
|
readonly options?: HttpRecorder.RecorderOptions
|
||||||
@@ -81,10 +82,11 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
||||||
return Layer.mergeAll(
|
return Layer.mergeAll(
|
||||||
requestExecutor,
|
deps,
|
||||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
LLMClient.layer.pipe(Layer.provide(deps)),
|
||||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
ImageClient.layer.pipe(Layer.provide(deps)),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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"
|
||||||
@@ -109,25 +108,3 @@ 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)
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
|||||||
permission: "bash",
|
permission: "bash",
|
||||||
patterns: ["git status", "git diff"],
|
patterns: ["git status", "git diff"],
|
||||||
metadata: {},
|
metadata: {},
|
||||||
always: [],
|
always: ["git *"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import { extractPromptFromParts } from "@/utils/prompt"
|
|||||||
import type { TextPart as SDKTextPart } from "@/types"
|
import type { TextPart as SDKTextPart } from "@/types"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { sessionHref } from "@/utils/session-route"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
interface ForkableMessage {
|
interface ForkableMessage {
|
||||||
id: string
|
id: string
|
||||||
@@ -27,6 +30,7 @@ export const DialogFork: Component = () => {
|
|||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -73,7 +77,7 @@ export const DialogFork: Component = () => {
|
|||||||
.then((forked) => {
|
.then((forked) => {
|
||||||
dialog.close()
|
dialog.close()
|
||||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||||
navigate(`/${dir}/session/${forked.id}`)
|
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
const message = err instanceof Error ? err.message : String(err)
|
const message = err instanceof Error ? err.message : String(err)
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, For, createMemo, createResource } from "solid-js"
|
import { Component, For, createEffect, createMemo } from "solid-js"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { ExternalLink } from "../external-link"
|
import { ExternalLink } from "../external-link"
|
||||||
import { InlineServerSelect } from "./parts/server-select"
|
import { InlineServerSelect } from "./parts/server-select"
|
||||||
import "./settings-v2.css"
|
import "./settings-v2.css"
|
||||||
@@ -22,6 +23,7 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSdk = useServerSDK()
|
const serverSdk = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
const mcps = createMemo<McpRowItem[]>(() => {
|
const mcps = createMemo<McpRowItem[]>(() => {
|
||||||
const configMcp = serverSync.data.config.mcp ?? {}
|
const configMcp = serverSync.data.config.mcp ?? {}
|
||||||
return Object.entries(configMcp).map(([name, config]) => ({
|
return Object.entries(configMcp).map(([name, config]) => ({
|
||||||
@@ -47,9 +49,11 @@ export const SettingsExtensionsV2: Component = () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
createEffect(() => {
|
||||||
initialValue: [],
|
if (serverSdk.connection.status() !== "connected") return
|
||||||
|
void data.location.skill.sync().catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
const skills = () => data.location.skill.list() ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
import type { CatalogApi } from "@opencode-ai/client/promise"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import {
|
import { loadPathQuery, loadProjectsQuery, loadProvidersQuery } from "./bootstrap"
|
||||||
loadAgentsQuery,
|
|
||||||
loadCommands,
|
|
||||||
loadPathQuery,
|
|
||||||
loadProjectsQuery,
|
|
||||||
loadProvidersQuery,
|
|
||||||
loadReferencesQuery,
|
|
||||||
} from "./bootstrap"
|
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
@@ -73,39 +66,6 @@ describe("query keys", () => {
|
|||||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads agents from the current location-scoped endpoint", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const api = {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(input)
|
|
||||||
return { location: {}, data: [] }
|
|
||||||
},
|
|
||||||
} as unknown as AgentApi
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
|
||||||
expect(result).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads commands from the current location-scoped endpoint", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const api = {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(input)
|
|
||||||
return {
|
|
||||||
location: {},
|
|
||||||
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as unknown as CommandApi
|
|
||||||
|
|
||||||
const result = await loadCommands("/repo", api)
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
|
||||||
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads projects from the current endpoint", async () => {
|
test("loads projects from the current endpoint", async () => {
|
||||||
const calls: string[] = []
|
const calls: string[] = []
|
||||||
const projects = {
|
const projects = {
|
||||||
@@ -161,18 +121,4 @@ describe("query keys", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads references from the current location-scoped endpoint", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const api = {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(input)
|
|
||||||
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
|
||||||
},
|
|
||||||
} as unknown as ReferenceApi
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,22 +1,13 @@
|
|||||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||||
import type {
|
import type {
|
||||||
AgentListInput,
|
|
||||||
AgentListOutput,
|
|
||||||
CatalogApi,
|
CatalogApi,
|
||||||
CommandInfo,
|
|
||||||
CommandListInput,
|
|
||||||
CommandListOutput,
|
|
||||||
IntegrationListInput,
|
|
||||||
IntegrationListOutput,
|
|
||||||
LocationGetInput,
|
LocationGetInput,
|
||||||
LocationGetOutput,
|
LocationGetOutput,
|
||||||
PermissionRequest,
|
PermissionRequest,
|
||||||
ProjectCurrentInput,
|
ProjectCurrentInput,
|
||||||
ProjectCurrentOutput,
|
ProjectCurrentOutput,
|
||||||
ProjectListOutput,
|
ProjectListOutput,
|
||||||
ReferenceListInput,
|
QuestionRequest,
|
||||||
ReferenceListOutput,
|
|
||||||
ReferenceInfo,
|
|
||||||
SessionApi,
|
SessionApi,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
@@ -27,10 +18,9 @@ import { batch } from "solid-js"
|
|||||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import type { ServerSession } from "../server-session"
|
import type { ServerSession } from "../server-session"
|
||||||
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
import { cmp, directoryKey, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||||
import { formatServerError } from "@/utils/server-errors"
|
import { formatServerError } from "@/utils/server-errors"
|
||||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
@@ -109,9 +99,8 @@ type ProjectApi = {
|
|||||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
|
||||||
type PermissionApi = ServerApi["permission"]
|
type PermissionApi = ServerApi["permission"]
|
||||||
type VcsApi = ServerApi["vcs"]
|
type QuestionApi = ServerApi["question"]
|
||||||
|
|
||||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
@@ -239,38 +228,6 @@ export const loadProvidersQuery = (scope: ServerScope, directory: string | null,
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
type AgentListApi = {
|
|
||||||
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type CommandListApi = {
|
|
||||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type IntegrationListApi = {
|
|
||||||
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReferenceListApi = {
|
|
||||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [scope, directory, "agents"],
|
|
||||||
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [scope, directory, "integrations"] as const,
|
|
||||||
queryFn: () =>
|
|
||||||
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
|
||||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
|
||||||
|
|
||||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
||||||
queryOptions<Path>({
|
queryOptions<Path>({
|
||||||
queryKey: [scope, directory, "path"],
|
queryKey: [scope, directory, "path"],
|
||||||
@@ -284,27 +241,15 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) =>
|
|
||||||
queryOptions<ReferenceInfo[]>({
|
|
||||||
queryKey: [scope, directory, "references"] as const,
|
|
||||||
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
|
||||||
placeholderData: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
export async function bootstrapDirectory(input: {
|
export async function bootstrapDirectory(input: {
|
||||||
directory: string
|
directory: string
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
mcp: boolean
|
mcp: boolean
|
||||||
api: CatalogApi & {
|
api: CatalogApi & {
|
||||||
readonly agent: AgentListApi
|
|
||||||
readonly command: CommandListApi
|
|
||||||
readonly mcp: McpApi
|
|
||||||
readonly permission: PermissionApi
|
readonly permission: PermissionApi
|
||||||
readonly project: ProjectApi
|
readonly project: ProjectApi
|
||||||
readonly reference: ReferenceListApi
|
readonly question: QuestionApi
|
||||||
readonly session: SessionApi
|
readonly session: SessionApi
|
||||||
readonly vcs: VcsApi
|
|
||||||
readonly location: LocationApi
|
|
||||||
}
|
}
|
||||||
store: Store<State>
|
store: Store<State>
|
||||||
setStore: SetStoreFunction<State>
|
setStore: SetStoreFunction<State>
|
||||||
@@ -321,9 +266,7 @@ export async function bootstrapDirectory(input: {
|
|||||||
}) {
|
}) {
|
||||||
const loading = input.store.status !== "complete"
|
const loading = input.store.status !== "complete"
|
||||||
const seededProject = projectID(input.directory, input.global.project)
|
const seededProject = projectID(input.directory, input.global.project)
|
||||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
|
||||||
if (seededProject) input.setStore("project", seededProject)
|
if (seededProject) input.setStore("project", seededProject)
|
||||||
if (seededPath) input.setStore("path", seededPath)
|
|
||||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||||
}
|
}
|
||||||
@@ -334,29 +277,11 @@ export async function bootstrapDirectory(input: {
|
|||||||
providerRev.set(revKey, rev)
|
providerRev.set(revKey, rev)
|
||||||
const slow = [
|
const slow = [
|
||||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||||
() =>
|
|
||||||
input.queryClient
|
|
||||||
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
|
|
||||||
.then((data) => input.setStore("agent", data)),
|
|
||||||
!seededProject &&
|
!seededProject &&
|
||||||
(() =>
|
(() =>
|
||||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||||
input.setStore("project", project.id),
|
input.setStore("project", project.id),
|
||||||
)),
|
)),
|
||||||
!seededPath &&
|
|
||||||
(() =>
|
|
||||||
input.queryClient
|
|
||||||
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
|
||||||
.then((data) => {
|
|
||||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
|
||||||
if (next) input.setStore("project", next)
|
|
||||||
})),
|
|
||||||
input.mcp &&
|
|
||||||
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
|
||||||
() =>
|
|
||||||
input.queryClient.fetchQuery(
|
|
||||||
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
|
||||||
),
|
|
||||||
() =>
|
() =>
|
||||||
retry(() =>
|
retry(() =>
|
||||||
input.api.permission.request
|
input.api.permission.request
|
||||||
@@ -391,12 +316,41 @@ 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.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
|
||||||
input.mcp &&
|
|
||||||
(() =>
|
|
||||||
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/con
|
|||||||
import type { State } from "./types"
|
import type { State } from "./types"
|
||||||
import type { QueryOptionsApi } from "../server-sync"
|
import type { QueryOptionsApi } from "../server-sync"
|
||||||
import { ServerScope } from "@/utils/server-scope"
|
import { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
|
|
||||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||||
@@ -18,6 +19,20 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
|
|||||||
|
|
||||||
const child = () => createStore({} as State)
|
const child = () => createStore({} as State)
|
||||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||||
|
const path = { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||||
|
const data = {
|
||||||
|
location: {
|
||||||
|
info: () => undefined,
|
||||||
|
agent: { list: () => undefined },
|
||||||
|
command: { list: () => undefined },
|
||||||
|
reference: { list: () => undefined },
|
||||||
|
mcp: {
|
||||||
|
server: { list: () => undefined },
|
||||||
|
resource: { list: () => undefined },
|
||||||
|
},
|
||||||
|
vcs: { info: () => undefined },
|
||||||
|
},
|
||||||
|
} as unknown as Data
|
||||||
|
|
||||||
const queryOptionsApi = {
|
const queryOptionsApi = {
|
||||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||||
@@ -100,7 +115,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
|
|
||||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||||
@@ -134,7 +150,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,7 +184,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -198,7 +216,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,7 +233,7 @@ describe("createChildStoreManager", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("enables MCP only when requested for the directory", () => {
|
test("syncs MCP only when requested for the directory", () => {
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
const offset = querySingles.length
|
const offset = querySingles.length
|
||||||
const mcpLoads: string[] = []
|
const mcpLoads: string[] = []
|
||||||
@@ -234,30 +253,21 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!manager) throw new Error("manager required")
|
if (!manager) throw new Error("manager required")
|
||||||
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
const [, setStore] = manager.child("/project", { bootstrap: false })
|
||||||
expect(querySingles.length - offset).toBe(6)
|
expect(querySingles.length - offset).toBe(2)
|
||||||
const query = querySingles[offset + 1]
|
|
||||||
const resourceQuery = querySingles[offset + 2]
|
|
||||||
if (!query) throw new Error("query required")
|
|
||||||
if (!resourceQuery) throw new Error("resource query required")
|
|
||||||
expect(query().enabled).toBe(false)
|
|
||||||
expect(resourceQuery().enabled).toBe(false)
|
|
||||||
|
|
||||||
setStore("status", "complete")
|
setStore("status", "complete")
|
||||||
manager.child("/project", { bootstrap: false, mcp: true })
|
manager.child("/project", { bootstrap: false, mcp: true })
|
||||||
expect(query().enabled).toBe(true)
|
|
||||||
expect(resourceQuery().enabled).toBe(true)
|
|
||||||
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
|
||||||
expect(mcpLoads).toEqual(["/project"])
|
expect(mcpLoads).toEqual(["/project"])
|
||||||
|
|
||||||
manager.disableMcp("/project")
|
manager.disableMcp("/project")
|
||||||
expect(query().enabled).toBe(false)
|
|
||||||
expect(manager.mcp("/project")).toBe(false)
|
expect(manager.mcp("/project")).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
@@ -284,7 +294,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -293,11 +304,9 @@ describe("createChildStoreManager", () => {
|
|||||||
const [store] = manager.child("/project", { bootstrap: false })
|
const [store] = manager.child("/project", { bootstrap: false })
|
||||||
const queries = querySingles.slice(offset)
|
const queries = querySingles.slice(offset)
|
||||||
|
|
||||||
expect(queries).toHaveLength(6)
|
expect(queries).toHaveLength(2)
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
expect(queries[3]?.().enabled).toBe(false)
|
expect(queries[1]?.().enabled).toBe(false)
|
||||||
expect(queries[4]?.().enabled).toBe(false)
|
|
||||||
expect(queries[5]?.().enabled).toBe(false)
|
|
||||||
expect(store.path.directory).toBe("/project")
|
expect(store.path.directory).toBe("/project")
|
||||||
expect(store.provider_ready).toBe(false)
|
expect(store.provider_ready).toBe(false)
|
||||||
expect(store.lsp_ready).toBe(false)
|
expect(store.lsp_ready).toBe(false)
|
||||||
@@ -305,9 +314,7 @@ describe("createChildStoreManager", () => {
|
|||||||
|
|
||||||
manager.child("/project")
|
manager.child("/project")
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
expect(queries[3]?.().enabled).toBe(true)
|
expect(queries[1]?.().enabled).toBe(true)
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
|
||||||
expect(queries[5]?.().enabled).toBe(true)
|
|
||||||
expect(bootstraps).toEqual(["/project"])
|
expect(bootstraps).toEqual(["/project"])
|
||||||
|
|
||||||
manager.child("/project", { bootstrap: false })
|
manager.child("/project", { bootstrap: false })
|
||||||
@@ -334,7 +341,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -365,7 +373,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path, provider },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,11 +383,11 @@ describe("createChildStoreManager", () => {
|
|||||||
manager.child("/handshake")
|
manager.child("/handshake")
|
||||||
const queries = querySingles.slice(offset)
|
const queries = querySingles.slice(offset)
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
expect(queries[4]?.().enabled).toBe(false)
|
expect(queries[1]?.().enabled).toBe(false)
|
||||||
|
|
||||||
connected = true
|
connected = true
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
expect(queries[4]?.().enabled).toBe(true)
|
expect(queries[1]?.().enabled).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { VcsInfo } from "@/types"
|
import type { Path, VcsInfo } from "@/types"
|
||||||
import {
|
import {
|
||||||
DIR_IDLE_TTL_MS,
|
DIR_IDLE_TTL_MS,
|
||||||
MAX_DIR_STORES,
|
MAX_DIR_STORES,
|
||||||
@@ -19,6 +19,8 @@ import { QueryOptionsApi } from "../server-sync"
|
|||||||
import { directoryKey, type DirectoryKey } from "./utils"
|
import { directoryKey, type DirectoryKey } from "./utils"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
|
import { normalizeAgentList } from "./utils"
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
@@ -32,7 +34,9 @@ export function createChildStoreManager(input: {
|
|||||||
onDispose: (directory: string) => void
|
onDispose: (directory: string) => void
|
||||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||||
queryOptions: QueryOptionsApi
|
queryOptions: QueryOptionsApi
|
||||||
|
data: Data
|
||||||
global: {
|
global: {
|
||||||
|
path: Path
|
||||||
provider: NormalizedProviderListResponse
|
provider: NormalizedProviderListResponse
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
@@ -45,7 +49,6 @@ export function createChildStoreManager(input: {
|
|||||||
const ownerPins = new WeakMap<object, Set<string>>()
|
const ownerPins = new WeakMap<object, Set<string>>()
|
||||||
const disposers = new Map<string, () => void>()
|
const disposers = new Map<string, () => void>()
|
||||||
const mcpDirectories = new Set<string>()
|
const mcpDirectories = new Set<string>()
|
||||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
|
||||||
const activeDirectories = new Set<string>()
|
const activeDirectories = new Set<string>()
|
||||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||||
|
|
||||||
@@ -120,7 +123,6 @@ export function createChildStoreManager(input: {
|
|||||||
iconCache.delete(key)
|
iconCache.delete(key)
|
||||||
lifecycle.delete(key)
|
lifecycle.delete(key)
|
||||||
mcpDirectories.delete(key)
|
mcpDirectories.delete(key)
|
||||||
mcpToggles.delete(key)
|
|
||||||
activeDirectories.delete(key)
|
activeDirectories.delete(key)
|
||||||
activationToggles.delete(key)
|
activationToggles.delete(key)
|
||||||
const dispose = disposers.get(key)
|
const dispose = disposers.get(key)
|
||||||
@@ -186,21 +188,7 @@ export function createChildStoreManager(input: {
|
|||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
const initialMeta = meta[0].value
|
const initialMeta = meta[0].value
|
||||||
const initialIcon = icon[0].value
|
const initialIcon = icon[0].value
|
||||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
|
||||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||||
|
|
||||||
const pathQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.path(key),
|
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
|
||||||
}))
|
|
||||||
const mcpQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcp(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const mcpResourceQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.mcpResources(key),
|
|
||||||
enabled: input.connected() && mcpEnabled(),
|
|
||||||
}))
|
|
||||||
const lspQuery = useQuery(() => ({
|
const lspQuery = useQuery(() => ({
|
||||||
...input.queryOptions.lsp(key),
|
...input.queryOptions.lsp(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
@@ -209,11 +197,6 @@ export function createChildStoreManager(input: {
|
|||||||
...input.queryOptions.providers(key),
|
...input.queryOptions.providers(key),
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
enabled: input.connected() && instanceQueriesEnabled(),
|
||||||
}))
|
}))
|
||||||
const referenceQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.references(key),
|
|
||||||
enabled: input.connected() && instanceQueriesEnabled(),
|
|
||||||
}))
|
|
||||||
|
|
||||||
const child = createStore<State>({
|
const child = createStore<State>({
|
||||||
project: "",
|
project: "",
|
||||||
projectMeta: initialMeta,
|
projectMeta: initialMeta,
|
||||||
@@ -230,15 +213,24 @@ export function createChildStoreManager(input: {
|
|||||||
},
|
},
|
||||||
config: {},
|
config: {},
|
||||||
get path() {
|
get path() {
|
||||||
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
|
const location = input.data.location.info({ directory })
|
||||||
if (pathQuery.isLoading) return EMPTY
|
return {
|
||||||
return pathQuery.data ?? EMPTY
|
state: "",
|
||||||
|
config: "",
|
||||||
|
worktree: location?.project.directory ?? "",
|
||||||
|
directory: location?.directory ?? directory,
|
||||||
|
home: input.global.path.home,
|
||||||
|
}
|
||||||
},
|
},
|
||||||
status: "loading" as const,
|
status: "loading" as const,
|
||||||
agent: [],
|
get agent() {
|
||||||
command: [],
|
return normalizeAgentList(input.data.location.agent.list({ directory }) ?? [])
|
||||||
|
},
|
||||||
|
get command() {
|
||||||
|
return input.data.location.command.list({ directory }) ?? []
|
||||||
|
},
|
||||||
get reference() {
|
get reference() {
|
||||||
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
|
return input.data.location.reference.list({ directory }) ?? []
|
||||||
},
|
},
|
||||||
session: [],
|
session: [],
|
||||||
sessionTotal: 0,
|
sessionTotal: 0,
|
||||||
@@ -250,14 +242,22 @@ export function createChildStoreManager(input: {
|
|||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
|
question: {},
|
||||||
get mcp_ready() {
|
get mcp_ready() {
|
||||||
return !mcpQuery.isLoading
|
return input.data.location.mcp.server.list({ directory }) !== undefined
|
||||||
},
|
},
|
||||||
get mcp() {
|
get mcp() {
|
||||||
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
return Object.fromEntries(
|
||||||
|
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
get mcp_resource() {
|
get mcp_resource() {
|
||||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
return Object.fromEntries(
|
||||||
|
(input.data.location.mcp.resource.list({ directory }) ?? []).map((resource) => [
|
||||||
|
`${resource.server}:${resource.uri}`,
|
||||||
|
resource,
|
||||||
|
]),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
get lsp_ready() {
|
get lsp_ready() {
|
||||||
return instanceQueriesEnabled() && !lspQuery.isLoading
|
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||||
@@ -265,7 +265,11 @@ export function createChildStoreManager(input: {
|
|||||||
get lsp() {
|
get lsp() {
|
||||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||||
},
|
},
|
||||||
vcs: vcsStore.value,
|
get vcs() {
|
||||||
|
const vcs = input.data.location.vcs.info({ directory })
|
||||||
|
if (!vcs) return vcsStore.value
|
||||||
|
return { branch: vcs.branch.current, default_branch: vcs.branch.default }
|
||||||
|
},
|
||||||
limit: 5,
|
limit: 5,
|
||||||
message: {},
|
message: {},
|
||||||
session_message: {},
|
session_message: {},
|
||||||
@@ -274,7 +278,6 @@ export function createChildStoreManager(input: {
|
|||||||
})
|
})
|
||||||
children[key] = child
|
children[key] = child
|
||||||
disposers.set(key, dispose)
|
disposers.set(key, dispose)
|
||||||
mcpToggles.set(key, setMcpEnabled)
|
|
||||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||||
|
|
||||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||||
@@ -338,7 +341,6 @@ export function createChildStoreManager(input: {
|
|||||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||||
if (mcpDirectories.has(key)) return
|
if (mcpDirectories.has(key)) return
|
||||||
mcpDirectories.add(key)
|
mcpDirectories.add(key)
|
||||||
mcpToggles.get(key)?.(true)
|
|
||||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,7 +357,6 @@ export function createChildStoreManager(input: {
|
|||||||
function disableMcp(directory: string) {
|
function disableMcp(directory: string) {
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
if (!mcpDirectories.delete(key)) return
|
if (!mcpDirectories.delete(key)) return
|
||||||
mcpToggles.get(key)?.(false)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||||
|
|||||||
@@ -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, SessionInfo } from "@opencode-ai/client/promise"
|
import type { PermissionRequest, QuestionRequest, 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,6 +45,19 @@ 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",
|
||||||
@@ -62,6 +75,7 @@ const baseState = (input: Partial<State> = {}) =>
|
|||||||
session_diff: {},
|
session_diff: {},
|
||||||
todo: {},
|
todo: {},
|
||||||
permission: {},
|
permission: {},
|
||||||
|
question: {},
|
||||||
mcp: {},
|
mcp: {},
|
||||||
lsp: [],
|
lsp: [],
|
||||||
vcs: undefined,
|
vcs: undefined,
|
||||||
@@ -206,6 +220,7 @@ 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" } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -226,6 +241,7 @@ 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -266,6 +282,7 @@ 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" } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -289,6 +306,7 @@ 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()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -307,6 +325,7 @@ 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" } },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -330,6 +349,7 @@ 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])
|
||||||
})
|
})
|
||||||
@@ -466,11 +486,12 @@ describe("applyDirectoryEvent", () => {
|
|||||||
expect(store.part[messageID]).toBeUndefined()
|
expect(store.part[messageID]).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
test("tracks permission request lifecycles", () => {
|
test("tracks permission and question 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)] },
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -503,6 +524,36 @@ 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,7 +2,13 @@ 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 { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
import type {
|
||||||
|
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"
|
||||||
@@ -21,6 +27,9 @@ 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: {
|
||||||
@@ -77,6 +86,7 @@ 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)
|
||||||
@@ -428,6 +438,43 @@ 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, SessionStatus } from "@opencode-ai/client/promise"
|
import type { FormInfo, PermissionRequest, QuestionRequest, 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,6 +33,7 @@ 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>
|
||||||
} = {
|
} = {
|
||||||
@@ -43,6 +44,7 @@ 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" },
|
||||||
}
|
}
|
||||||
@@ -56,6 +58,7 @@ 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()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -69,6 +72,7 @@ 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>
|
||||||
} = {
|
} = {
|
||||||
@@ -79,6 +83,7 @@ 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, SessionStatus } from "@opencode-ai/client/promise"
|
import type { FormInfo, PermissionRequest, QuestionRequest, 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,6 +13,7 @@ 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>
|
||||||
}
|
}
|
||||||
@@ -37,6 +38,7 @@ 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,6 +2,7 @@ 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,
|
||||||
@@ -49,6 +50,9 @@ 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"]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
|
|||||||
import { createServerSdkContext } from "./server-sdk"
|
import { createServerSdkContext } from "./server-sdk"
|
||||||
import { createServerSyncContext } from "./server-sync"
|
import { createServerSyncContext } from "./server-sync"
|
||||||
import { getOwner } from "solid-js/web"
|
import { getOwner } from "solid-js/web"
|
||||||
|
import { createServerData } from "@opencode-ai/client/solid"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createServerPermissionState } from "./permission"
|
import { createServerPermissionState } from "./permission"
|
||||||
import { createServerNotificationState } from "./notification"
|
import { createServerNotificationState } from "./notification"
|
||||||
@@ -99,7 +100,13 @@ function createServerController(
|
|||||||
) {
|
) {
|
||||||
const connKey = ServerConnection.key(conn)
|
const connKey = ServerConnection.key(conn)
|
||||||
const sdk = createServerSdkContext(conn, scope)
|
const sdk = createServerSdkContext(conn, scope)
|
||||||
const sync = createServerSyncContext(sdk)
|
const data = createServerData({
|
||||||
|
api: () => sdk.api,
|
||||||
|
event: sdk.event,
|
||||||
|
connection: sdk.connection,
|
||||||
|
directory: "",
|
||||||
|
})
|
||||||
|
const sync = createServerSyncContext(sdk, data)
|
||||||
const permission = createServerPermissionState({ sdk, sync })
|
const permission = createServerPermissionState({ sdk, sync })
|
||||||
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
||||||
|
|
||||||
@@ -134,6 +141,7 @@ function createServerController(
|
|||||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
data,
|
||||||
sdk,
|
sdk,
|
||||||
sync,
|
sync,
|
||||||
isLocal,
|
isLocal,
|
||||||
|
|||||||
@@ -278,7 +278,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = input.sdk.event.listen((e) => {
|
const unsub = input.sdk.eventByDir.listen((e) => {
|
||||||
const event = e.details
|
const event = e.details
|
||||||
if (
|
if (
|
||||||
event.type !== "session.execution.succeeded" &&
|
event.type !== "session.execution.succeeded" &&
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function hasPermissionPromptRules(permission: unknown) {
|
|||||||
return Object.values(config).some(isNonAllowRule)
|
return Object.values(config).some(isNonAllowRule)
|
||||||
}
|
}
|
||||||
|
|
||||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
||||||
|
|
||||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
@@ -197,7 +197,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
void respondPending(event.properties, e.name)
|
void respondPending(event.properties, e.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsubscribe = input.sdk.event.listen((event) => {
|
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
||||||
if (ready()) {
|
if (ready()) {
|
||||||
handlePermission(event)
|
handlePermission(event)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
import { adaptServerEvent } from "./server-sdk"
|
||||||
|
|
||||||
describe("resumeStreamAfterPageShow", () => {
|
|
||||||
test("restarts a stream only after a back-forward cache restore", () => {
|
|
||||||
let starts = 0
|
|
||||||
const start = () => starts++
|
|
||||||
|
|
||||||
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
|
|
||||||
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
|
|
||||||
|
|
||||||
expect(starts).toBe(1)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("adaptServerEvent", () => {
|
describe("adaptServerEvent", () => {
|
||||||
test("preserves current permission requests", () => {
|
test("preserves current permission requests", () => {
|
||||||
@@ -43,57 +31,3 @@ describe("adaptServerEvent", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("current event buffering", () => {
|
|
||||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
|
||||||
directory: "/repo",
|
|
||||||
payload: adaptServerEvent({
|
|
||||||
id,
|
|
||||||
created: 1,
|
|
||||||
type: "session.text.delta",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
|
||||||
} as OpenCodeEvent),
|
|
||||||
})
|
|
||||||
|
|
||||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
|
||||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1)
|
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
|
||||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("coalesces current tool input deltas by tool ID", () => {
|
|
||||||
const current = (eventID: string, id: string, delta: string) =>
|
|
||||||
adaptServerEvent({
|
|
||||||
id: eventID,
|
|
||||||
created: 1,
|
|
||||||
type: "session.tool.input.delta",
|
|
||||||
location: { directory: "/repo" },
|
|
||||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
|
||||||
} as OpenCodeEvent)
|
|
||||||
const result = coalesceServerEvents([
|
|
||||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
|
||||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
|
||||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2)
|
|
||||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
|
||||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves boundaries between distinct delta streams", () => {
|
|
||||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
|
||||||
|
|
||||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves current event order when enqueuing", () => {
|
|
||||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
|
||||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
|
||||||
|
|
||||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||||
|
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||||
import type { Event } from "@/types"
|
import type { Event } from "@/types"
|
||||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { type Accessor, onCleanup } from "solid-js"
|
||||||
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
|
||||||
import { createStore } from "solid-js/store"
|
|
||||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { ServerConnection } from "./servers"
|
import { ServerConnection } from "./servers"
|
||||||
@@ -12,85 +11,15 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
|
||||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
|
||||||
type CurrentDelta = Extract<
|
|
||||||
OpenCodeEvent,
|
|
||||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
|
||||||
>
|
|
||||||
|
|
||||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||||
}
|
}
|
||||||
|
|
||||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
|
||||||
queue.push(event)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
|
||||||
const output: QueuedServerEvent[] = []
|
|
||||||
events.forEach((event) => {
|
|
||||||
const current = currentDelta(event.payload.current)
|
|
||||||
if (current) {
|
|
||||||
const previous = output[output.length - 1]
|
|
||||||
const prior = currentDelta(previous?.payload.current)
|
|
||||||
if (
|
|
||||||
previous &&
|
|
||||||
prior &&
|
|
||||||
previous.directory === event.directory &&
|
|
||||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
|
||||||
) {
|
|
||||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
|
||||||
const data =
|
|
||||||
current.type === "session.compaction.delta"
|
|
||||||
? { ...current.data, text: fragment }
|
|
||||||
: { ...current.data, delta: fragment }
|
|
||||||
output[output.length - 1] = {
|
|
||||||
directory: event.directory,
|
|
||||||
payload: {
|
|
||||||
...event.payload,
|
|
||||||
properties: data,
|
|
||||||
current: { ...current, data } as CurrentDelta,
|
|
||||||
} as ServerEvent,
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output.push(event)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
output.push(event)
|
|
||||||
})
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
|
||||||
if (
|
|
||||||
event?.type === "session.text.delta" ||
|
|
||||||
event?.type === "session.reasoning.delta" ||
|
|
||||||
event?.type === "session.tool.input.delta" ||
|
|
||||||
event?.type === "session.compaction.delta"
|
|
||||||
)
|
|
||||||
return event
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDeltaKey(event: CurrentDelta) {
|
|
||||||
if (event.type === "session.tool.input.delta")
|
|
||||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
|
||||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
|
||||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function currentDeltaFragment(event: CurrentDelta) {
|
|
||||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
|
||||||
if (!event.persisted) return
|
|
||||||
start()
|
|
||||||
}
|
|
||||||
|
|
||||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||||
|
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
||||||
|
export type ServerConnectionStatus = ClientConnectionStatus
|
||||||
type ServerSDKBase = {
|
type ServerSDKBase = {
|
||||||
server: ServerConnection.Any
|
server: ServerConnection.Any
|
||||||
scope: ServerScope
|
scope: ServerScope
|
||||||
@@ -101,231 +30,51 @@ type ServerSDKBase = {
|
|||||||
attempt: Accessor<number>
|
attempt: Accessor<number>
|
||||||
error: Accessor<string | undefined>
|
error: Accessor<string | undefined>
|
||||||
}
|
}
|
||||||
event: {
|
eventByDir: {
|
||||||
on: ServerEventEmitter["on"]
|
on: ServerEventEmitter["on"]
|
||||||
listen: ServerEventEmitter["listen"]
|
listen: ServerEventEmitter["listen"]
|
||||||
}
|
}
|
||||||
|
event: {
|
||||||
|
on: CurrentEventEmitter["on"]
|
||||||
|
listen: CurrentEventEmitter["listen"]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const abort = new AbortController()
|
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||||
|
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
||||||
|
const emitter = createGlobalEmitter<CurrentEventMap>()
|
||||||
|
|
||||||
const eventFetch = (() => {
|
const connection = createClientConnection(api, {
|
||||||
if (!platform.fetch || !server) return
|
flushInterval: 16,
|
||||||
try {
|
pageLifecycle: true,
|
||||||
const url = new URL(server.http.url)
|
onEvent(event) {
|
||||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
emitter.emit(event.type, event)
|
||||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
||||||
} catch {
|
},
|
||||||
return
|
log: {
|
||||||
}
|
info(message, data) {
|
||||||
})()
|
if (message !== "event stream disconnected") return
|
||||||
|
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
},
|
||||||
const emitter = createGlobalEmitter<{
|
},
|
||||||
[key: string]: ServerEvent
|
|
||||||
}>()
|
|
||||||
|
|
||||||
type Queued = QueuedServerEvent
|
|
||||||
const FLUSH_FRAME_MS = 16
|
|
||||||
const STREAM_YIELD_MS = 8
|
|
||||||
const CONNECT_TIMEOUT_MS = 2_000
|
|
||||||
const RECONNECT_DELAY_MS = 1_000
|
|
||||||
|
|
||||||
let queue: Queued[] = []
|
|
||||||
let buffer: Queued[] = []
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined
|
|
||||||
let last = 0
|
|
||||||
|
|
||||||
function flush() {
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
|
|
||||||
if (queue.length === 0) return
|
|
||||||
|
|
||||||
const events = queue
|
|
||||||
queue = buffer
|
|
||||||
buffer = events
|
|
||||||
queue.length = 0
|
|
||||||
|
|
||||||
last = Date.now()
|
|
||||||
const output = coalesceServerEvents(events)
|
|
||||||
batch(() => {
|
|
||||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
|
||||||
})
|
|
||||||
|
|
||||||
buffer.length = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function schedule() {
|
|
||||||
if (timer) return
|
|
||||||
const elapsed = Date.now() - last
|
|
||||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
|
||||||
}
|
|
||||||
|
|
||||||
function publish(event: OpenCodeEvent) {
|
|
||||||
const directory = event.location?.directory ?? "global"
|
|
||||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
|
||||||
}
|
|
||||||
|
|
||||||
function wait(delay: number, signal: AbortSignal) {
|
|
||||||
return new Promise<void>((resolve) => {
|
|
||||||
const timer = setTimeout(done, delay)
|
|
||||||
signal.addEventListener("abort", done, { once: true })
|
|
||||||
function done() {
|
|
||||||
clearTimeout(timer)
|
|
||||||
signal.removeEventListener("abort", done)
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
let attempt: AbortController | undefined
|
|
||||||
let run: Promise<void> | undefined
|
|
||||||
let started = false
|
|
||||||
let generation = 0
|
|
||||||
const [connection, setConnection] = createStore<{
|
|
||||||
status: ServerConnectionStatus
|
|
||||||
attempt: number
|
|
||||||
error?: string
|
|
||||||
}>({ status: "connecting", attempt: 0 })
|
|
||||||
|
|
||||||
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
|
||||||
let connectedAt: number | undefined
|
|
||||||
|
|
||||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
|
||||||
const request = new AbortController()
|
|
||||||
const cancel = () => request.abort(signal.reason)
|
|
||||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
|
||||||
signal.addEventListener("abort", cancel, { once: true })
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Open the event stream and validate its initial handshake.
|
|
||||||
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
|
||||||
const first = await iterator.next()
|
|
||||||
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (first.done) {
|
|
||||||
const error =
|
|
||||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
|
||||||
return { error, connectedAt }
|
|
||||||
}
|
|
||||||
if (first.value.type !== "server.connected")
|
|
||||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
|
||||||
|
|
||||||
// Publish the connected state before forwarding live events.
|
|
||||||
clearTimeout(timeout)
|
|
||||||
publish(first.value)
|
|
||||||
connectedAt = Date.now()
|
|
||||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
|
||||||
|
|
||||||
// Forward events until the stream closes or this connection is cancelled.
|
|
||||||
let yielded = Date.now()
|
|
||||||
while (!signal.aborted) {
|
|
||||||
const event = await iterator.next()
|
|
||||||
if (signal.aborted) return { error: undefined, connectedAt }
|
|
||||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
|
||||||
publish(event.value)
|
|
||||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
|
||||||
yielded = Date.now()
|
|
||||||
await wait(0, signal)
|
|
||||||
}
|
|
||||||
return { error: undefined, connectedAt }
|
|
||||||
} catch (error) {
|
|
||||||
return { error, connectedAt }
|
|
||||||
} finally {
|
|
||||||
request.abort()
|
|
||||||
clearTimeout(timeout)
|
|
||||||
signal.removeEventListener("abort", cancel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runStream(active: number) {
|
|
||||||
let retries = 0
|
|
||||||
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
|
||||||
while (!abort.signal.aborted && started && generation === active) {
|
|
||||||
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
|
||||||
const controller = new AbortController()
|
|
||||||
attempt = controller
|
|
||||||
const onAbort = () => controller.abort()
|
|
||||||
abort.signal.addEventListener("abort", onAbort)
|
|
||||||
const result = await connect(controller.signal)
|
|
||||||
abort.signal.removeEventListener("abort", onAbort)
|
|
||||||
|
|
||||||
if (abort.signal.aborted || !started || generation !== active) {
|
|
||||||
if (attempt === controller) attempt = undefined
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
|
||||||
retries += 1
|
|
||||||
const message =
|
|
||||||
result.error === undefined
|
|
||||||
? undefined
|
|
||||||
: result.error instanceof Error
|
|
||||||
? result.error.message
|
|
||||||
: String(result.error)
|
|
||||||
console.info("[global-sdk] event stream disconnected", {
|
|
||||||
url: server.http.url,
|
|
||||||
fetch: eventFetch ? "platform" : "webview",
|
|
||||||
attempt: retries,
|
|
||||||
error: message,
|
|
||||||
})
|
|
||||||
setConnection({ status: "reconnecting", attempt: retries, error: message })
|
|
||||||
await wait(RECONNECT_DELAY_MS, controller.signal)
|
|
||||||
if (attempt === controller) attempt = undefined
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function start() {
|
|
||||||
if (started) return run
|
|
||||||
started = true
|
|
||||||
const active = ++generation
|
|
||||||
const previous = run
|
|
||||||
const current = (async () => {
|
|
||||||
if (previous) await previous
|
|
||||||
await runStream(active)
|
|
||||||
})().finally(() => {
|
|
||||||
if (run !== current) return
|
|
||||||
run = undefined
|
|
||||||
flush()
|
|
||||||
})
|
|
||||||
run = current
|
|
||||||
return run
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop() {
|
|
||||||
started = false
|
|
||||||
generation++
|
|
||||||
attempt?.abort()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
makeEventListener(window, "pagehide", stop)
|
|
||||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
|
||||||
void start()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
stop()
|
dirEmitter.clear()
|
||||||
abort.abort()
|
|
||||||
if (timer) clearTimeout(timer)
|
|
||||||
timer = undefined
|
|
||||||
queue = []
|
|
||||||
buffer = []
|
|
||||||
emitter.clear()
|
emitter.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
server,
|
server,
|
||||||
scope,
|
scope,
|
||||||
url: server.http.url,
|
url: server.http.url,
|
||||||
api,
|
api,
|
||||||
connection: {
|
connection,
|
||||||
status: () => connection.status,
|
eventByDir: {
|
||||||
attempt: () => connection.attempt,
|
on: dirEmitter.on.bind(dirEmitter),
|
||||||
error: () => connection.error,
|
listen: dirEmitter.listen.bind(dirEmitter),
|
||||||
},
|
},
|
||||||
event: {
|
event: {
|
||||||
on: emitter.on.bind(emitter),
|
on: emitter.on.bind(emitter),
|
||||||
@@ -365,7 +114,7 @@ export type DirectorySDK = {
|
|||||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||||
|
|
||||||
const unsub = serverSDK.event.on(directory, (event) => {
|
const unsub = serverSDK.eventByDir.on(directory, (event) => {
|
||||||
emitter.emit(event.type, event)
|
emitter.emit(event.type, event)
|
||||||
})
|
})
|
||||||
onCleanup(unsub)
|
onCleanup(unsub)
|
||||||
|
|||||||
@@ -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, SessionStatus } from "@opencode-ai/client/promise"
|
import type { FileDiffInfo, PermissionRequest, QuestionRequest, 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,6 +198,7 @@ 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[]>,
|
||||||
@@ -280,6 +281,9 @@ 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),
|
||||||
@@ -525,6 +529,9 @@ 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),
|
||||||
@@ -1332,6 +1339,36 @@ 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)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import type {
|
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||||
McpListInput,
|
|
||||||
McpResourceCatalogInput,
|
|
||||||
SessionApi,
|
|
||||||
SessionInfo,
|
|
||||||
SessionListInput,
|
|
||||||
} from "@opencode-ai/client/promise"
|
|
||||||
import { QueryClient } from "@tanstack/solid-query"
|
import { QueryClient } from "@tanstack/solid-query"
|
||||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||||
import {
|
import {
|
||||||
loadActiveSessionsQuery,
|
loadActiveSessionsQuery,
|
||||||
loadMcpQuery,
|
|
||||||
loadMcpResourcesQuery,
|
|
||||||
reconcileActiveSessionStatuses,
|
reconcileActiveSessionStatuses,
|
||||||
seedActiveSessionStatuses,
|
seedActiveSessionStatuses,
|
||||||
shouldRefreshWorkspaceSessions,
|
shouldRefreshWorkspaceSessions,
|
||||||
@@ -21,56 +13,6 @@ import { ServerScope } from "@/utils/server-scope"
|
|||||||
import { createServerSession } from "./server-session"
|
import { createServerSession } from "./server-session"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
|
|
||||||
type McpApi = ServerApi["mcp"]
|
|
||||||
|
|
||||||
describe("MCP queries", () => {
|
|
||||||
test("loads current servers for the requested location", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
const result = await queryClient.fetchQuery(
|
|
||||||
loadMcpQuery(ServerScope.local, "/project", {
|
|
||||||
list: async (input: McpListInput = {}) => {
|
|
||||||
calls.push(input)
|
|
||||||
return {
|
|
||||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
|
||||||
data: [
|
|
||||||
{ name: "docs", status: { status: "connected" } },
|
|
||||||
{ name: "search", status: { status: "pending" } },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
} as unknown as McpApi),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
|
||||||
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads and keys the current resource catalog", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const queryClient = new QueryClient()
|
|
||||||
const result = await queryClient.fetchQuery(
|
|
||||||
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
|
||||||
resource: {
|
|
||||||
catalog: async (input: McpResourceCatalogInput = {}) => {
|
|
||||||
calls.push(input)
|
|
||||||
return {
|
|
||||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
|
||||||
data: {
|
|
||||||
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
|
||||||
templates: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as McpApi),
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
|
||||||
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe("active session query", () => {
|
describe("active session query", () => {
|
||||||
test("loads active sessions immediately and once per server cache", async () => {
|
test("loads active sessions immediately and once per server cache", async () => {
|
||||||
let calls = 0
|
let calls = 0
|
||||||
|
|||||||
@@ -10,14 +10,9 @@ import {
|
|||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
clearProviderRev,
|
clearProviderRev,
|
||||||
loadAgentsQuery,
|
|
||||||
loadCommands,
|
|
||||||
loadGlobalConfigQuery,
|
loadGlobalConfigQuery,
|
||||||
loadIntegrationsQuery,
|
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
|
||||||
loadProvidersQuery,
|
loadProvidersQuery,
|
||||||
loadReferencesQuery,
|
|
||||||
} from "./global-sync/bootstrap"
|
} from "./global-sync/bootstrap"
|
||||||
import { createChildStoreManager } from "./global-sync/child-store"
|
import { createChildStoreManager } from "./global-sync/child-store"
|
||||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||||
@@ -40,21 +35,15 @@ import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
|||||||
import { persisted } from "@/utils/persist"
|
import { persisted } from "@/utils/persist"
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import type {
|
import type {
|
||||||
McpListInput,
|
|
||||||
McpListOutput,
|
|
||||||
McpResource,
|
|
||||||
McpResourceCatalogInput,
|
|
||||||
McpResourceCatalogOutput,
|
|
||||||
McpServer,
|
|
||||||
SessionActiveOutput,
|
SessionActiveOutput,
|
||||||
SessionStatus,
|
SessionStatus,
|
||||||
} from "@opencode-ai/client/promise"
|
} from "@opencode-ai/client/promise"
|
||||||
import { toggleMcp } from "./global-sync/mcp"
|
import { toggleMcp } from "./global-sync/mcp"
|
||||||
import { createServerSession, type ServerSession } from "./server-session"
|
import { createServerSession, type ServerSession } from "./server-session"
|
||||||
import { createCatalogSync } from "./server-sync/catalog"
|
|
||||||
import { createConnectionSync } from "./server-sync/connection"
|
import { createConnectionSync } from "./server-sync/connection"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { useServer } from "./server"
|
import { useServer } from "./server"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
|
|
||||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||||
const type = event.current?.type ?? event.type
|
const type = event.current?.type ?? event.type
|
||||||
@@ -88,16 +77,6 @@ const SESSION_LIST_EVENTS = new Set([
|
|||||||
"session.usage.updated",
|
"session.usage.updated",
|
||||||
])
|
])
|
||||||
|
|
||||||
type McpListApi = {
|
|
||||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
|
||||||
}
|
|
||||||
|
|
||||||
type McpResourceApi = {
|
|
||||||
readonly resource: {
|
|
||||||
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||||
initialData?: undefined
|
initialData?: undefined
|
||||||
queryKey: K
|
queryKey: K
|
||||||
@@ -107,47 +86,6 @@ type SessionActiveApi = {
|
|||||||
readonly active: () => Promise<SessionActiveOutput>
|
readonly active: () => Promise<SessionActiveOutput>
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadMcpQuery = (
|
|
||||||
scope: ServerScope,
|
|
||||||
directory: string,
|
|
||||||
api: McpListApi,
|
|
||||||
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
|
||||||
queryOptions<
|
|
||||||
Record<string, McpServer["status"]>,
|
|
||||||
Error,
|
|
||||||
Record<string, McpServer["status"]>,
|
|
||||||
readonly [ServerScope, string, "mcp"]
|
|
||||||
>({
|
|
||||||
queryKey: [scope, directory, "mcp"] as const,
|
|
||||||
queryFn: async () => {
|
|
||||||
return api
|
|
||||||
.list({ location: { directory } })
|
|
||||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadMcpResourcesQuery = (
|
|
||||||
scope: ServerScope,
|
|
||||||
directory: string,
|
|
||||||
api: McpResourceApi,
|
|
||||||
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
|
||||||
queryOptions<
|
|
||||||
Record<string, McpResource>,
|
|
||||||
Error,
|
|
||||||
Record<string, McpResource>,
|
|
||||||
readonly [ServerScope, string, "mcpResources"]
|
|
||||||
>({
|
|
||||||
queryKey: [scope, directory, "mcpResources"] as const,
|
|
||||||
queryFn: async () => {
|
|
||||||
return api.resource
|
|
||||||
.catalog({ location: { directory } })
|
|
||||||
.then((result) =>
|
|
||||||
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
placeholderData: {},
|
|
||||||
})
|
|
||||||
|
|
||||||
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||||
queryOptions({
|
queryOptions({
|
||||||
queryKey: [scope, directory, "lsp"] as const,
|
queryKey: [scope, directory, "lsp"] as const,
|
||||||
@@ -194,21 +132,15 @@ export function reconcileActiveSessionStatuses(
|
|||||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||||
return {
|
return {
|
||||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||||
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
|
path: () => loadPathQuery(scope, null, serverAPI.location),
|
||||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||||
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
|
||||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
|
||||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
|
||||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
|
||||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
|
||||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
|
||||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||||
|
|
||||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const owner = getOwner()
|
const owner = getOwner()
|
||||||
@@ -237,7 +169,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queries: [
|
queries: [
|
||||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
||||||
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
||||||
{ ...queryOptionsApi.path(null), enabled: connected() },
|
{ ...queryOptionsApi.path(), enabled: connected() },
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
const activeSessionsQuery = useQuery(() => ({
|
const activeSessionsQuery = useQuery(() => ({
|
||||||
@@ -338,10 +270,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
if (!connected()) return
|
if (!connected()) return
|
||||||
void bootstrapInstance(directory)
|
void bootstrapInstance(directory)
|
||||||
},
|
},
|
||||||
onMcp: (directory, setStore) => {
|
onMcp: (directory) => {
|
||||||
void loadCommands(directory, serverSDK.api.command)
|
void Promise.all([
|
||||||
.then((commands) => setStore("command", commands))
|
data.location.command.sync({ directory }),
|
||||||
.catch((err) => {
|
data.location.mcp.server.sync({ directory }),
|
||||||
|
data.location.mcp.resource.sync({ directory }),
|
||||||
|
]).catch((err) => {
|
||||||
showToast({
|
showToast({
|
||||||
variant: "error",
|
variant: "error",
|
||||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||||
@@ -357,32 +291,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
|
data,
|
||||||
global: {
|
global: {
|
||||||
|
get path() {
|
||||||
|
return globalStore.path
|
||||||
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
return globalStore.provider
|
return globalStore.provider
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const catalog = createCatalogSync({
|
|
||||||
scope: serverSDK.scope,
|
|
||||||
queryClient,
|
|
||||||
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
|
|
||||||
load: (directory) =>
|
|
||||||
Promise.all([
|
|
||||||
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
|
|
||||||
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
|
|
||||||
]).then(() => undefined),
|
|
||||||
})
|
|
||||||
const refreshVcs = (directory: string) =>
|
|
||||||
serverSDK.api.vcs
|
|
||||||
.get({ location: { directory } })
|
|
||||||
.then((result) =>
|
|
||||||
children.vcs(directory, {
|
|
||||||
branch: result.data.branch.current,
|
|
||||||
default_branch: result.data.branch.default,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.catch(() => undefined)
|
|
||||||
const connection = createConnectionSync({
|
const connection = createConnectionSync({
|
||||||
status: serverSDK.connection.status,
|
status: serverSDK.connection.status,
|
||||||
invalidate: () => {
|
invalidate: () => {
|
||||||
@@ -400,7 +318,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
.filter(children.active)
|
.filter(children.active)
|
||||||
.forEach((directory) => {
|
.forEach((directory) => {
|
||||||
queue.push(directory)
|
queue.push(directory)
|
||||||
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
|
void data.location.sync({ directory }).catch(() => undefined)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -498,8 +416,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
children.pin(key)
|
children.pin(key)
|
||||||
const promise = Promise.resolve().then(async () => {
|
const promise = Promise.resolve().then(async () => {
|
||||||
const child = children.ensureChild(directory)
|
const child = children.ensureChild(directory)
|
||||||
const initial = child[0].status === "loading"
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
|
data.location.sync({ directory }),
|
||||||
bootstrapDirectory({
|
bootstrapDirectory({
|
||||||
directory,
|
directory,
|
||||||
scope: serverSDK.scope,
|
scope: serverSDK.scope,
|
||||||
@@ -518,7 +436,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
session,
|
session,
|
||||||
}),
|
}),
|
||||||
initial ? refreshVcs(directory) : Promise.resolve(),
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -568,7 +485,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
return event
|
return event
|
||||||
}
|
}
|
||||||
|
|
||||||
const unsub = serverSDK.event.listen((e) => {
|
const unsub = serverSDK.eventByDir.listen((e) => {
|
||||||
const directory = e.name
|
const directory = e.name
|
||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
const event = e.details
|
const event = e.details
|
||||||
@@ -628,8 +545,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
homeSessions.refresh(event.type)
|
homeSessions.refresh(event.type)
|
||||||
catalog.handleEvent({ type: eventType, directory })
|
|
||||||
connection.handleEvent({ type: eventType, directory })
|
connection.handleEvent({ type: eventType, directory })
|
||||||
|
if (eventType === "catalog.updated" || eventType === "integration.updated") {
|
||||||
|
void queryClient.invalidateQueries(queryOptionsApi.providers(directory === "global" ? null : key))
|
||||||
|
}
|
||||||
|
|
||||||
if (directory === "global") {
|
if (directory === "global") {
|
||||||
applyGlobalEvent({
|
applyGlobalEvent({
|
||||||
@@ -661,21 +580,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
eventType === "agent.updated"
|
eventType === "agent.updated"
|
||||||
)
|
)
|
||||||
queue.push(key)
|
queue.push(key)
|
||||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
|
||||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
|
||||||
const [store, setStore] = existing
|
const [store, setStore] = existing
|
||||||
if (eventType === "agent.updated")
|
|
||||||
void queryClient
|
|
||||||
.fetchQuery(queryOptionsApi.agents(key))
|
|
||||||
.then((data) => setStore("agent", data))
|
|
||||||
.catch(() => {})
|
|
||||||
if (eventType === "command.updated")
|
|
||||||
void loadCommands(directory, serverSDK.api.command)
|
|
||||||
.then((commands) => setStore("command", commands))
|
|
||||||
.catch(() => {})
|
|
||||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||||
const projected = toDirectoryEvent(event)
|
const projected = toDirectoryEvent(event)
|
||||||
if (projected)
|
if (projected && eventType !== "vcs.branch.updated")
|
||||||
applyDirectoryEvent({
|
applyDirectoryEvent({
|
||||||
event: projected,
|
event: projected,
|
||||||
directory,
|
directory,
|
||||||
@@ -694,7 +602,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
loadReferences: () => {
|
loadReferences: () => {
|
||||||
if (!children.active(key)) return
|
if (!children.active(key)) return
|
||||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -719,6 +627,26 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const refreshProviders = () => {
|
||||||
|
const locations = Object.keys(children.children).filter(children.active)
|
||||||
|
if (locations.length === 0) locations.push(data.location.default().directory)
|
||||||
|
locations.filter(Boolean).forEach((directory) => {
|
||||||
|
data.location.provider.invalidate({ directory })
|
||||||
|
data.location.model.invalidate({ directory })
|
||||||
|
})
|
||||||
|
return Promise.all(
|
||||||
|
[
|
||||||
|
queryClient.refetchQueries({
|
||||||
|
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||||
|
}),
|
||||||
|
...locations.filter(Boolean).flatMap((directory) => [
|
||||||
|
data.location.provider.sync({ directory }),
|
||||||
|
data.location.model.sync({ directory }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
).then(() => undefined)
|
||||||
|
}
|
||||||
|
|
||||||
const updateConfigMutation = useMutation(() => ({
|
const updateConfigMutation = useMutation(() => ({
|
||||||
mutationFn: async (config: Config) => {
|
mutationFn: async (config: Config) => {
|
||||||
// TODO: Restore config updates when the V2 client exposes a config API.
|
// TODO: Restore config updates when the V2 client exposes a config API.
|
||||||
@@ -749,7 +677,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
peek: children.peek,
|
peek: children.peek,
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
refreshProviders: catalog.refreshActive,
|
refreshProviders,
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
@@ -788,8 +716,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
platform.openExternal(attempt.data.url)
|
platform.openExternal(attempt.data.url)
|
||||||
},
|
},
|
||||||
refresh: async () => {
|
refresh: async () => {
|
||||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
data.location.mcp.server.invalidate({ directory: key })
|
||||||
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
data.location.mcp.resource.invalidate({ directory: key })
|
||||||
|
await Promise.all([
|
||||||
|
data.location.mcp.server.sync({ directory: key }),
|
||||||
|
data.location.mcp.resource.sync({ directory: key }),
|
||||||
|
])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
@@ -797,8 +729,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
export function createServerSyncContext(serverSDK: ServerSDK, data: Data) {
|
||||||
const inner = createServerSyncContextInner(serverSDK)
|
const inner = createServerSyncContextInner(serverSDK, data)
|
||||||
return Object.assign(inner, {
|
return Object.assign(inner, {
|
||||||
ensureDirSyncContext: createRefCountMap(
|
ensureDirSyncContext: createRefCountMap(
|
||||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||||
|
|||||||
@@ -22,3 +22,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const useData = () => {
|
||||||
|
const server = useServer()
|
||||||
|
return server.ctx.data
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { createEffect, type Accessor } from "solid-js"
|
||||||
import { createQuery } from "@tanstack/solid-query"
|
|
||||||
import type { Accessor } from "solid-js"
|
|
||||||
|
|
||||||
export function useIntegrations(directory: Accessor<string | undefined>) {
|
export function useIntegrations(directory: Accessor<string | undefined>) {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const query = createQuery(() => {
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (serverSDK.connection.status() !== "connected") return
|
||||||
const value = directory()
|
const value = directory()
|
||||||
return {
|
void data.location.integration.sync(value ? { directory: value } : undefined).catch(() => undefined)
|
||||||
...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
|
|
||||||
enabled: serverSDK.connection.status() === "connected",
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
|
list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import { Schema } from "effect"
|
|||||||
import type { ServerConnection } from "@/context/servers"
|
import type { ServerConnection } from "@/context/servers"
|
||||||
import { sessionHref } from "@/utils/session-route"
|
import { sessionHref } from "@/utils/session-route"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
|
||||||
export function DirectoryDataProvider(
|
export function DirectoryDataProvider(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -25,6 +27,8 @@ export function DirectoryDataProvider(
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const directory = () => props.directory
|
const directory = () => props.directory
|
||||||
const slug = createMemo(() => base64Encode(directory()))
|
const slug = createMemo(() => base64Encode(directory()))
|
||||||
@@ -52,6 +56,21 @@ export function DirectoryDataProvider(
|
|||||||
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (serverSDK.connection.status() !== "connected") return
|
||||||
|
const ref = { directory: directory() }
|
||||||
|
void data.location.sync(ref).catch(() => undefined)
|
||||||
|
const sessionID = params.id
|
||||||
|
if (!sessionID) return
|
||||||
|
void Promise.allSettled([
|
||||||
|
data.session.sync(sessionID, { children: true }),
|
||||||
|
data.session.pending.sync(sessionID),
|
||||||
|
data.session.message.sync(sessionID),
|
||||||
|
data.session.permission.sync(sessionID),
|
||||||
|
data.session.form.sync(sessionID),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
if (!sessionID) return
|
if (!sessionID) return
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
|||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { useLocal } from "@/context/local"
|
import { useLocal } from "@/context/local"
|
||||||
import { usePrompt } from "@/context/prompt"
|
import { usePrompt } from "@/context/prompt"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||||
import { useSessionKey } from "@/pages/session/session-layout"
|
import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
@@ -17,7 +16,6 @@ export function createNewSessionDraftController(props: {
|
|||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}) {
|
}) {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const route = useSessionKey()
|
const route = useSessionKey()
|
||||||
@@ -29,7 +27,6 @@ export function createNewSessionDraftController(props: {
|
|||||||
const controls = createPromptInputController({
|
const controls = createPromptInputController({
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
sessionID: () => route.params.id,
|
sessionID: () => route.params.id,
|
||||||
queryOptions: serverSync.queryOptions,
|
|
||||||
model,
|
model,
|
||||||
})
|
})
|
||||||
const projectControls = createPromptProjectControls({
|
const projectControls = createPromptProjectControls({
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ import { diffs as list } from "@/utils/diffs"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||||
import { createSessionLineage } from "./session/session-lineage"
|
import { createSessionLineage } from "./session/session-lineage"
|
||||||
|
|
||||||
@@ -384,7 +384,6 @@ export default function Page() {
|
|||||||
const inputController = createPromptInputController({
|
const inputController = createPromptInputController({
|
||||||
sessionKey: controller.identity.sessionKey,
|
sessionKey: controller.identity.sessionKey,
|
||||||
sessionID: () => controller.identity.params.id,
|
sessionID: () => controller.identity.params.id,
|
||||||
queryOptions: serverSync.queryOptions,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const sessionPanelKey = createMemo(() =>
|
const sessionPanelKey = createMemo(() =>
|
||||||
@@ -2128,9 +2127,12 @@ export default function Page() {
|
|||||||
const id = controller.data.parentID()
|
const id = controller.data.parentID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
navigate(
|
navigate(
|
||||||
|
sessionHref(
|
||||||
controller.identity.params.serverKey
|
controller.identity.params.serverKey
|
||||||
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
|
? requireServerKey(controller.identity.params.serverKey)
|
||||||
: legacySessionHref(sdk().directory, id),
|
: ServerConnection.key(serverSDK.server),
|
||||||
|
id,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
setPromptRef: (el) => {
|
setPromptRef: (el) => {
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { createQuery } from "@tanstack/solid-query"
|
|
||||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||||
import { type Accessor, createMemo } from "solid-js"
|
import { type Accessor, createMemo } from "solid-js"
|
||||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||||
@@ -8,28 +7,26 @@ import { useDirectoryPicker } from "@/components/directory-picker"
|
|||||||
import { useGlobal, useServerCtx } from "@/context/global"
|
import { useGlobal, useServerCtx } from "@/context/global"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import type { QueryOptionsApi } from "@/context/server-sync"
|
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
import { serverName, ServerConnection, useServers } from "@/context/servers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { useData } from "@/context/server"
|
||||||
|
|
||||||
export function createPromptInputController(input: {
|
export function createPromptInputController(input: {
|
||||||
sessionKey: Accessor<string>
|
sessionKey: Accessor<string>
|
||||||
sessionID: Accessor<string | undefined>
|
sessionID: Accessor<string | undefined>
|
||||||
queryOptions: Pick<QueryOptionsApi, "agents">
|
|
||||||
model?: ModelSelection
|
model?: ModelSelection
|
||||||
}) {
|
}) {
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const view = layout.view(input.sessionKey)
|
const view = layout.view(input.sessionKey)
|
||||||
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
|
||||||
|
|
||||||
return createMemo<PromptInputControls>(() => {
|
return createMemo<PromptInputControls>(() => {
|
||||||
return {
|
return {
|
||||||
@@ -37,14 +34,14 @@ export function createPromptInputController(input: {
|
|||||||
available: sync().data.agent,
|
available: sync().data.agent,
|
||||||
options: local.agent.list().map((agent) => agent.name),
|
options: local.agent.list().map((agent) => agent.name),
|
||||||
current: local.agent.current()?.name ?? "",
|
current: local.agent.current()?.name ?? "",
|
||||||
loading: agentsQuery.isLoading,
|
loading: data.location.agent.list({ directory: sdk().directory }) === undefined,
|
||||||
visible: local.agent.visible(),
|
visible: local.agent.visible(),
|
||||||
select: local.agent.set,
|
select: local.agent.set,
|
||||||
},
|
},
|
||||||
model: {
|
model: {
|
||||||
selection: input.model ?? local.model,
|
selection: input.model ?? local.model,
|
||||||
paid: providers.paid().length > 0,
|
paid: providers.paid().length > 0,
|
||||||
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
|
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
|
||||||
},
|
},
|
||||||
session: {
|
session: {
|
||||||
id: input.sessionID(),
|
id: input.sessionID(),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
||||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
import { useData } from "@/context/server"
|
||||||
|
|
||||||
export const todoState = (input: {
|
export const todoState = (input: {
|
||||||
count: number
|
count: number
|
||||||
@@ -34,29 +34,13 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const queryClient = useQueryClient()
|
const data = useData()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const
|
createEffect(() => {
|
||||||
const shells = createQuery(() => ({
|
if (!params.id || serverSDK.connection.status() !== "connected") return
|
||||||
queryKey: shellKey(),
|
void data.shell.sync({ directory: sdk().directory }).catch(() => undefined)
|
||||||
enabled: !!params.id && serverSDK.connection.status() === "connected",
|
})
|
||||||
queryFn: () =>
|
|
||||||
sdk()
|
|
||||||
.api.shell.list({ location: { directory: sdk().directory } })
|
|
||||||
.then((result) => result.data ?? []),
|
|
||||||
}))
|
|
||||||
onCleanup(
|
|
||||||
sdk().event.listen((event) => {
|
|
||||||
if (
|
|
||||||
event.details.type !== "shell.created" &&
|
|
||||||
event.details.type !== "shell.exited" &&
|
|
||||||
event.details.type !== "shell.deleted"
|
|
||||||
)
|
|
||||||
return
|
|
||||||
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
const questionRequest = createMemo((): FormInfo | undefined => {
|
const questionRequest = createMemo((): FormInfo | undefined => {
|
||||||
return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
|
return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
|
||||||
@@ -171,7 +155,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
|
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
|
||||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||||
if (
|
if (
|
||||||
blocking.some(
|
blocking.some(
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function SessionPermissionDock(props: {
|
|||||||
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
||||||
{language.t("ui.permission.deny")}
|
{language.t("ui.permission.deny")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Show when={props.request.save?.length}>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="normal"
|
size="normal"
|
||||||
@@ -45,6 +46,7 @@ export function SessionPermissionDock(props: {
|
|||||||
>
|
>
|
||||||
{language.t("ui.permission.allowAlways")}
|
{language.t("ui.permission.allowAlways")}
|
||||||
</Button>
|
</Button>
|
||||||
|
</Show>
|
||||||
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||||
{language.t("ui.permission.allowOnce")}
|
{language.t("ui.permission.allowOnce")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ import { useSDK } from "@/context/sdk"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import type { SessionController } from "@/pages/session/session-controller"
|
import type { SessionController } from "@/pages/session/session-controller"
|
||||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { sessionTitle } from "@/utils/session-title"
|
import { sessionTitle } from "@/utils/session-title"
|
||||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||||
import { createTimelineProjection } from "./projection"
|
import { createTimelineProjection } from "./projection"
|
||||||
import { useServer } from "@/context/server"
|
import { useServer } from "@/context/server"
|
||||||
|
import { ServerConnection } from "@/context/servers"
|
||||||
|
|
||||||
const emptyMessages: Message[] = []
|
const emptyMessages: Message[] = []
|
||||||
const emptyParts: Part[] = []
|
const emptyParts: Part[] = []
|
||||||
@@ -46,6 +48,7 @@ export function createTimelineController(input: {
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -146,19 +149,22 @@ export function createTimelineController(input: {
|
|||||||
if (!id || pending.unshare || !shareEnabled()) return
|
if (!id || pending.unshare || !shareEnabled()) return
|
||||||
}
|
}
|
||||||
const href = (id: string) =>
|
const href = (id: string) =>
|
||||||
|
sessionHref(
|
||||||
input.session.identity.params.serverKey
|
input.session.identity.params.serverKey
|
||||||
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
|
? requireServerKey(input.session.identity.params.serverKey)
|
||||||
: legacySessionHref(sdk().directory, id)
|
: ServerConnection.key(serverSDK.server),
|
||||||
|
id,
|
||||||
|
)
|
||||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||||
if (input.session.identity.params.id !== id) return
|
if (input.session.identity.params.id !== id) return
|
||||||
if (parent) return navigate(href(parent))
|
if (parent) return navigate(href(parent))
|
||||||
if (next) return navigate(href(next))
|
if (next) return navigate(href(next))
|
||||||
if (input.session.identity.params.serverKey)
|
|
||||||
return tabs.newDraft({
|
return tabs.newDraft({
|
||||||
server: requireServerKey(input.session.identity.params.serverKey),
|
server: input.session.identity.params.serverKey
|
||||||
|
? requireServerKey(input.session.identity.params.serverKey)
|
||||||
|
: ServerConnection.key(serverSDK.server),
|
||||||
directory: sdk().directory,
|
directory: sdk().directory,
|
||||||
})
|
})
|
||||||
navigate(`/${input.session.identity.params.dir}/session`)
|
|
||||||
}
|
}
|
||||||
const exportSession = async (id: string) => {
|
const exportSession = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -38,10 +38,8 @@
|
|||||||
"immer": "11.1.4",
|
"immer": "11.1.4",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"open": "10.1.2",
|
"open": "10.1.2",
|
||||||
|
"semver": "catalog:",
|
||||||
"solid-js": "catalog:",
|
"solid-js": "catalog:",
|
||||||
"tree-sitter-bash": "0.25.0",
|
|
||||||
"tree-sitter-powershell": "0.25.10",
|
|
||||||
"web-tree-sitter": "0.25.10",
|
|
||||||
"uqr": "0.1.3",
|
"uqr": "0.1.3",
|
||||||
"ws": "8.21.0"
|
"ws": "8.21.0"
|
||||||
},
|
},
|
||||||
@@ -50,6 +48,7 @@
|
|||||||
"@opencode-ai/protocol": "workspace:*",
|
"@opencode-ai/protocol": "workspace:*",
|
||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
|
"@types/semver": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||||
|
|||||||
@@ -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,10 +9,8 @@ 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 collectFiles(path.join(root, "dist")))
|
(await files(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())
|
||||||
@@ -33,3 +31,16 @@ 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,7 +12,6 @@ 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, "..")
|
||||||
@@ -92,7 +91,6 @@ 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) {
|
||||||
@@ -141,7 +139,6 @@ 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,7 +8,6 @@ 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"
|
||||||
@@ -77,16 +76,6 @@ 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",
|
||||||
@@ -103,7 +92,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, simulationGraphPlugin],
|
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||||
external: ["node-gyp"],
|
external: ["node-gyp"],
|
||||||
format: "esm",
|
format: "esm",
|
||||||
minify: true,
|
minify: true,
|
||||||
@@ -134,7 +123,6 @@ 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"),
|
||||||
@@ -151,7 +139,6 @@ 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]) {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
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,10 +1,9 @@
|
|||||||
import { createHash } from "node:crypto"
|
import { createHash } from "node:crypto"
|
||||||
import { copyFile, mkdir, readFile, stat } from "node:fs/promises"
|
import { copyFile, mkdir, readdir, 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 } from "../src/node/target"
|
||||||
import { collectFiles } from "./files"
|
|
||||||
|
|
||||||
const dir = path.resolve(import.meta.dirname, "..")
|
const dir = path.resolve(import.meta.dirname, "..")
|
||||||
|
|
||||||
@@ -17,6 +16,17 @@ 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), "..")
|
||||||
@@ -33,15 +43,11 @@ export async function collectNodeAssets(target: NodeTarget) {
|
|||||||
key: photonWasmAsset,
|
key: photonWasmAsset,
|
||||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||||
},
|
},
|
||||||
...Object.values(shellParserWasmAssets).map((key) => ({
|
|
||||||
key,
|
|
||||||
source: fileURLToPath(import.meta.resolve(key)),
|
|
||||||
})),
|
|
||||||
...attentionSoundAssets.map((key) => ({
|
...attentionSoundAssets.map((key) => ({
|
||||||
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 collectFiles(ptyRoot))
|
...(await files(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}`,
|
||||||
@@ -75,7 +81,5 @@ 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(
|
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
|
||||||
(await collectFiles(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -47,7 +47,7 @@ export async function replyPermission(input: {
|
|||||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||||
...(previews.length > 0 ? { content: previews } : {}),
|
...(previews.length > 0 ? { content: previews } : {}),
|
||||||
},
|
},
|
||||||
options,
|
options: input.event.data.save?.length ? options : options.filter((option) => option.optionId !== "always"),
|
||||||
})
|
})
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ export function nodeTarget(platform: string, arch: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||||
export const shellParserWasmAssets = {
|
|
||||||
runtime: "web-tree-sitter/tree-sitter.wasm",
|
|
||||||
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
|
|
||||||
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
|
|
||||||
} as const
|
|
||||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||||
|
|
||||||
export const attentionSoundAssets = [
|
export const attentionSoundAssets = [
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
export type Policy = boolean | "notify"
|
|
||||||
export type Action = "none" | "upgrade"
|
|
||||||
|
|
||||||
const maximumComponent = "9007199254740991"
|
|
||||||
const versionPattern =
|
|
||||||
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
|
||||||
|
|
||||||
export function action(current: string, latest: string, policy: Policy): Action {
|
|
||||||
if (policy === false) return "none"
|
|
||||||
const currentVersion = parseReleaseVersion(current)
|
|
||||||
const latestVersion = parseReleaseVersion(latest)
|
|
||||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
|
||||||
// Major upgrades are never installed automatically.
|
|
||||||
if (currentVersion.major !== latestVersion.major) return "none"
|
|
||||||
return "upgrade"
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseReleaseVersion(input: string) {
|
|
||||||
if (input.length > 256) return
|
|
||||||
const match = input.trim().match(versionPattern)
|
|
||||||
if (!match) return
|
|
||||||
if ([match[1], match[2], match[3]].some(invalidComponent)) return
|
|
||||||
if (
|
|
||||||
match[4]
|
|
||||||
?.split(".")
|
|
||||||
.some((identifier) => identifier.length > 1 && identifier.startsWith("0") && /^[0-9]+$/.test(identifier))
|
|
||||||
)
|
|
||||||
return
|
|
||||||
return {
|
|
||||||
major: match[1],
|
|
||||||
core: `${match[1]}.${match[2]}.${match[3]}`,
|
|
||||||
prerelease: match[4]?.split(".") ?? [],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sameRelease(current: NonNullable<ReturnType<typeof parseReleaseVersion>>, latest: typeof current) {
|
|
||||||
if (current.core !== latest.core || current.prerelease.length !== latest.prerelease.length) return false
|
|
||||||
return current.prerelease.every((identifier, index) => {
|
|
||||||
const other = latest.prerelease[index]
|
|
||||||
if (identifier === other) return true
|
|
||||||
// semver compares oversized numeric prerelease identifiers after numeric coercion.
|
|
||||||
return /^[0-9]+$/.test(identifier) && /^[0-9]+$/.test(other) && Number(identifier) === Number(other)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidComponent(value: string) {
|
|
||||||
if (value.length > 1 && value.startsWith("0")) return true
|
|
||||||
if (value.length !== maximumComponent.length) return value.length > maximumComponent.length
|
|
||||||
return value > maximumComponent
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { action } from "./updater-action"
|
import { action, decodePolicy } from "./updater"
|
||||||
import { decodePolicy } from "./updater"
|
|
||||||
|
|
||||||
describe("updater", () => {
|
describe("updater", () => {
|
||||||
test("reads autoupdate from JSONC", () => {
|
test("reads autoupdate from JSONC", () => {
|
||||||
@@ -31,48 +30,4 @@ describe("updater", () => {
|
|||||||
test("upgrades when latest is lower (rollback)", () => {
|
test("upgrades when latest is lower (rollback)", () => {
|
||||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||||
})
|
})
|
||||||
|
|
||||||
test("accepts strict release version variants", () => {
|
|
||||||
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
|
|
||||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
|
|
||||||
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
|
|
||||||
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
|
|
||||||
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves strict validity", () => {
|
|
||||||
const invalid = [
|
|
||||||
"=1.2.3",
|
|
||||||
"V1.2.3",
|
|
||||||
"1.2",
|
|
||||||
"1.2.3.4",
|
|
||||||
"01.2.3",
|
|
||||||
"1.02.3",
|
|
||||||
"1.2.03",
|
|
||||||
"1.2.3-01",
|
|
||||||
"1.2.3-",
|
|
||||||
"1.2.3+",
|
|
||||||
"1.2.3-alpha..1",
|
|
||||||
"1.2.3_alpha",
|
|
||||||
"9007199254740992.0.0",
|
|
||||||
"0.9007199254740992.0",
|
|
||||||
"0.0.9007199254740992",
|
|
||||||
]
|
|
||||||
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
|
|
||||||
})
|
|
||||||
|
|
||||||
test("handles numeric limits without losing precision", () => {
|
|
||||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
|
|
||||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
|
||||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
|
|
||||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
|
|
||||||
})
|
|
||||||
|
|
||||||
test("rejects versions longer than semver's limit before trimming", () => {
|
|
||||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
|
|
||||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
|||||||
import { ChildProcess } from "effect/unstable/process"
|
import { ChildProcess } from "effect/unstable/process"
|
||||||
import { parse, type ParseError } from "jsonc-parser"
|
import { parse, type ParseError } from "jsonc-parser"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
import { action, type Policy } from "./updater-action"
|
import semver from "semver"
|
||||||
|
|
||||||
declare const OPENCODE_CLI_NAME: string | undefined
|
declare const OPENCODE_CLI_NAME: string | undefined
|
||||||
|
|
||||||
|
export type Policy = boolean | "notify"
|
||||||
|
export type Action = "none" | "upgrade"
|
||||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||||
|
|
||||||
const packageName =
|
const packageName =
|
||||||
@@ -32,6 +34,14 @@ export function decodePolicy(text: string): Policy | undefined {
|
|||||||
if (typeof value === "boolean" || value === "notify") return value
|
if (typeof value === "boolean" || value === "notify") return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function action(current: string, latest: string, policy: Policy): Action {
|
||||||
|
if (policy === false) return "none"
|
||||||
|
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||||
|
// Major upgrades are never installed automatically.
|
||||||
|
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||||
|
return "upgrade"
|
||||||
|
}
|
||||||
|
|
||||||
export const layer = Layer.effect(
|
export const layer = Layer.effect(
|
||||||
Service,
|
Service,
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -156,4 +166,3 @@ export const layer = Layer.effect(
|
|||||||
)
|
)
|
||||||
|
|
||||||
export * as Updater from "./updater"
|
export * as Updater from "./updater"
|
||||||
export { action, type Action, type Policy } from "./updater-action"
|
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ describe("acp permission behavior", () => {
|
|||||||
send(
|
send(
|
||||||
permissionAsked("ses_allow", "perm_always", {
|
permissionAsked("ses_allow", "perm_always", {
|
||||||
action: "read",
|
action: "read",
|
||||||
|
save: ["/workspace/file.ts"],
|
||||||
metadata: { path: "/workspace/file.ts" },
|
metadata: { path: "/workspace/file.ts" },
|
||||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||||
}),
|
}),
|
||||||
@@ -84,10 +85,10 @@ describe("acp permission behavior", () => {
|
|||||||
},
|
},
|
||||||
options: [
|
options: [
|
||||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
|
||||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
expect(permissionRequests[0]?.options.map((option) => option.optionId)).toEqual(["once", "reject"])
|
||||||
expect(permissionRequests[1]).toMatchObject({
|
expect(permissionRequests[1]).toMatchObject({
|
||||||
sessionId: "ses_allow",
|
sessionId: "ses_allow",
|
||||||
toolCall: {
|
toolCall: {
|
||||||
@@ -557,6 +558,7 @@ function permissionAsked(
|
|||||||
input: {
|
input: {
|
||||||
readonly action?: string
|
readonly action?: string
|
||||||
readonly metadata?: Record<string, unknown>
|
readonly metadata?: Record<string, unknown>
|
||||||
|
readonly save?: string[]
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
} = {},
|
} = {},
|
||||||
) {
|
) {
|
||||||
@@ -565,6 +567,7 @@ function permissionAsked(
|
|||||||
sessionID,
|
sessionID,
|
||||||
action: input.action ?? "shell",
|
action: input.action ?? "shell",
|
||||||
resources: ["*"],
|
resources: ["*"],
|
||||||
|
...(input.save ? { save: input.save } : {}),
|
||||||
metadata: input.metadata ?? { command: "printf hello" },
|
metadata: input.metadata ?? { command: "printf hello" },
|
||||||
...(input.source ? { source: input.source } : {}),
|
...(input.source ? { source: input.source } : {}),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,371 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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"
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,10 @@
|
|||||||
import { expect, test } from "bun:test"
|
import { expect, test } from "bun:test"
|
||||||
import { fileURLToPath } from "node:url"
|
|
||||||
import { collectNodeAssets } from "../script/node-assets"
|
import { collectNodeAssets } from "../script/node-assets"
|
||||||
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
import { nodeTarget } from "../src/node/target"
|
||||||
|
|
||||||
test("collects each SEA asset key once", async () => {
|
test("collects each SEA asset key once", async () => {
|
||||||
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||||
const keys = assets.map((asset) => asset.key)
|
const keys = assets.map((asset) => asset.key)
|
||||||
|
|
||||||
expect(new Set(keys).size).toBe(keys.length)
|
expect(new Set(keys).size).toBe(keys.length)
|
||||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
|
||||||
{
|
|
||||||
key: shellParserWasmAssets.runtime,
|
|
||||||
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ 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,
|
||||||
)
|
)
|
||||||
@@ -433,6 +435,8 @@ 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 () => {
|
||||||
|
|||||||
@@ -3,8 +3,7 @@ import { readFile } from "node:fs/promises"
|
|||||||
import { createRequire } from "node:module"
|
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 } from "./src/node/target"
|
||||||
import { verifySimulationGraph } from "./script/verify-artifact"
|
|
||||||
|
|
||||||
const dir = import.meta.dirname
|
const dir = import.meta.dirname
|
||||||
|
|
||||||
@@ -49,15 +48,6 @@ 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",
|
||||||
@@ -222,9 +212,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
|||||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||||
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
|
|
||||||
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
|
|
||||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
|
||||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||||
try {
|
try {
|
||||||
@@ -250,7 +237,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
|||||||
rawTextPlugin(),
|
rawTextPlugin(),
|
||||||
runtimeRequirePlugin(),
|
runtimeRequirePlugin(),
|
||||||
fffNodePlugin(),
|
fffNodePlugin(),
|
||||||
simulationGraphPlugin(),
|
|
||||||
solid({
|
solid({
|
||||||
solid: {
|
solid: {
|
||||||
generate: "universal",
|
generate: "universal",
|
||||||
@@ -266,7 +252,6 @@ 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: {
|
||||||
@@ -276,6 +261,7 @@ 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)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"./promise": "./src/promise/index.ts",
|
"./promise": "./src/promise/index.ts",
|
||||||
"./promise/api": "./src/promise/api.ts",
|
"./promise/api": "./src/promise/api.ts",
|
||||||
"./service": "./src/promise/service.ts",
|
"./service": "./src/promise/service.ts",
|
||||||
|
"./solid": "./src/solid/index.ts",
|
||||||
"./effect": "./src/effect/index.ts",
|
"./effect": "./src/effect/index.ts",
|
||||||
"./effect/api": "./src/effect/api.ts",
|
"./effect/api": "./src/effect/api.ts",
|
||||||
"./effect/service": "./src/effect/service.ts"
|
"./effect/service": "./src/effect/service.ts"
|
||||||
@@ -36,11 +37,15 @@
|
|||||||
"@opencode-ai/protocol": "workspace:*"
|
"@opencode-ai/protocol": "workspace:*"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"effect": "4.0.0-beta.101"
|
"effect": "4.0.0-beta.101",
|
||||||
|
"solid-js": ">=1.9.0"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"effect": {
|
"effect": {
|
||||||
"optional": true
|
"optional": true
|
||||||
|
},
|
||||||
|
"solid-js": {
|
||||||
|
"optional": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -49,6 +54,7 @@
|
|||||||
"@tsconfig/bun": "catalog:",
|
"@tsconfig/bun": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"effect": "catalog:"
|
"effect": "catalog:",
|
||||||
|
"solid-js": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ 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"
|
||||||
@@ -1306,6 +1307,7 @@ export type Endpoint15_3Input = {
|
|||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string> | undefined
|
readonly save?: ReadonlyArray<string> | undefined
|
||||||
|
readonly opaque?: boolean | undefined
|
||||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||||
readonly source?: Permission.Source | undefined
|
readonly source?: Permission.Source | undefined
|
||||||
readonly agent?: Agent.ID | undefined
|
readonly agent?: Agent.ID | undefined
|
||||||
@@ -1502,38 +1504,69 @@ 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<Reference.Info> }
|
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
||||||
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
|
export type QuestionRequestListOperation<E = never> = (
|
||||||
|
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 Endpoint23_0Input = { readonly projectID: Project.ID }
|
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
||||||
export type Endpoint23_0Output = Worktree.List
|
export type Endpoint24_0Output = Worktree.List
|
||||||
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||||
|
|
||||||
export type Endpoint23_1Input = {
|
export type Endpoint24_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 Endpoint23_1Output = Worktree.Info
|
export type Endpoint24_1Output = Worktree.Info
|
||||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
|
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||||
|
|
||||||
export type Endpoint23_2Input = {
|
export type Endpoint24_2Input = {
|
||||||
readonly projectID: Project.ID
|
readonly projectID: Project.ID
|
||||||
readonly directory: AbsolutePath
|
readonly directory: AbsolutePath
|
||||||
readonly force: boolean
|
readonly force: boolean
|
||||||
}
|
}
|
||||||
export type Endpoint23_2Output = void
|
export type Endpoint24_2Output = void
|
||||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
|
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||||
|
|
||||||
export type Endpoint23_3Input = { readonly projectID: Project.ID }
|
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
||||||
export type Endpoint23_3Output = void
|
export type Endpoint24_3Output = void
|
||||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
|
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
||||||
|
|
||||||
export interface WorktreeApi<E = never> {
|
export interface WorktreeApi<E = never> {
|
||||||
readonly list: WorktreeListOperation<E>
|
readonly list: WorktreeListOperation<E>
|
||||||
@@ -1542,25 +1575,25 @@ export interface WorktreeApi<E = never> {
|
|||||||
readonly refresh: WorktreeRefreshOperation<E>
|
readonly refresh: WorktreeRefreshOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint24_0Input = {
|
export type Endpoint25_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 Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||||
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||||
|
|
||||||
export type Endpoint24_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 Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||||
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||||
|
|
||||||
export type Endpoint24_2Input = {
|
export type Endpoint25_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 Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||||
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
||||||
|
|
||||||
export interface VcsApi<E = never> {
|
export interface VcsApi<E = never> {
|
||||||
readonly get: VcsGetOperation<E>
|
readonly get: VcsGetOperation<E>
|
||||||
@@ -1568,20 +1601,20 @@ export interface VcsApi<E = never> {
|
|||||||
readonly diff: VcsDiffOperation<E>
|
readonly diff: VcsDiffOperation<E>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
|
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||||
|
|
||||||
export type Endpoint25_1Input = {
|
export type Endpoint26_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 = void
|
export type Endpoint26_1Output = void
|
||||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_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 Endpoint26_0Output =
|
export type Endpoint27_0Output =
|
||||||
| { readonly status: "required" | "completed" }
|
| { readonly status: "required" | "completed" }
|
||||||
| {
|
| {
|
||||||
readonly status: "running"
|
readonly status: "running"
|
||||||
@@ -1592,36 +1625,36 @@ export type Endpoint26_0Output =
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
| { readonly status: "error"; readonly error: string }
|
| { readonly status: "error"; readonly error: string }
|
||||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_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 Endpoint27_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 Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||||
|
|
||||||
export type Endpoint27_1Input = {
|
export type Endpoint28_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 Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_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 Endpoint28_0Input = {
|
export type Endpoint29_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 = ReadonlyArray<Config.Entry>
|
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||||
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||||
|
|
||||||
export interface ConfigApi<E = never> {
|
export interface ConfigApi<E = never> {
|
||||||
readonly get: ConfigGetOperation<E>
|
readonly get: ConfigGetOperation<E>
|
||||||
@@ -1650,6 +1683,7 @@ 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,30 +200,38 @@ 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,
|
||||||
Endpoint27_0Input,
|
Endpoint26_1Input,
|
||||||
|
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"
|
||||||
|
|
||||||
@@ -962,6 +970,7 @@ const Endpoint15_3 = (raw: RawClient["server.permission"]) => (input: Endpoint15
|
|||||||
action: input["action"],
|
action: input["action"],
|
||||||
resources: input["resources"],
|
resources: input["resources"],
|
||||||
save: input["save"],
|
save: input["save"],
|
||||||
|
opaque: input["opaque"],
|
||||||
metadata: input["metadata"],
|
metadata: input["metadata"],
|
||||||
source: input["source"],
|
source: input["source"],
|
||||||
agent: input["agent"],
|
agent: input["agent"],
|
||||||
@@ -1150,110 +1159,145 @@ const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
|
|||||||
remove: Endpoint21_5(raw),
|
remove: Endpoint21_5(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
|
const Endpoint22_0 = (raw: RawClient["server.question"]) => (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 adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
|
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||||
|
|
||||||
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
|
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||||
preserveEffect<Endpoint23_0Output>()(
|
preserveEffect<Endpoint24_0Output>()(
|
||||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
|
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||||
preserveEffect<Endpoint23_1Output>()(
|
preserveEffect<Endpoint24_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 Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
|
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||||
preserveEffect<Endpoint23_2Output>()(
|
preserveEffect<Endpoint24_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 Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
|
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||||
preserveEffect<Endpoint23_3Output>()(
|
preserveEffect<Endpoint24_3Output>()(
|
||||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
|
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||||
list: Endpoint23_0(raw),
|
list: Endpoint24_0(raw),
|
||||||
create: Endpoint23_1(raw),
|
create: Endpoint24_1(raw),
|
||||||
remove: Endpoint23_2(raw),
|
remove: Endpoint24_2(raw),
|
||||||
refresh: Endpoint23_3(raw),
|
refresh: Endpoint24_3(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
|
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||||
preserveEffect<Endpoint24_0Output>()(
|
preserveEffect<Endpoint25_0Output>()(
|
||||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
|
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
||||||
preserveEffect<Endpoint24_1Output>()(
|
preserveEffect<Endpoint25_1Output>()(
|
||||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
|
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||||
preserveEffect<Endpoint24_2Output>()(
|
preserveEffect<Endpoint25_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 adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
|
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||||
get: Endpoint24_0(raw),
|
get: Endpoint25_0(raw),
|
||||||
status: Endpoint24_1(raw),
|
status: Endpoint25_1(raw),
|
||||||
diff: Endpoint24_2(raw),
|
diff: Endpoint25_2(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||||
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||||
|
|
||||||
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
|
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
|
||||||
preserveEffect<Endpoint25_1Output>()(
|
preserveEffect<Endpoint26_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 adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||||
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
|
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
|
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
||||||
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||||
|
|
||||||
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
|
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
|
||||||
|
|
||||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
||||||
preserveEffect<Endpoint27_0Output>()(
|
preserveEffect<Endpoint28_0Output>()(
|
||||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||||
preserveEffect<Endpoint27_1Output>()(
|
preserveEffect<Endpoint28_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 adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||||
providers: Endpoint27_0(raw),
|
providers: Endpoint28_0(raw),
|
||||||
query: Endpoint27_1(raw),
|
query: Endpoint28_1(raw),
|
||||||
})
|
})
|
||||||
|
|
||||||
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
|
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||||
preserveEffect<Endpoint28_0Output>()(
|
preserveEffect<Endpoint29_0Output>()(
|
||||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||||
)
|
)
|
||||||
|
|
||||||
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
|
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||||
|
|
||||||
const adaptClient = (raw: RawClient) => ({
|
const adaptClient = (raw: RawClient) => ({
|
||||||
health: adaptGroup0(raw["server.health"]),
|
health: adaptGroup0(raw["server.health"]),
|
||||||
@@ -1278,13 +1322,14 @@ 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"]),
|
||||||
reference: adaptGroup22(raw["server.reference"]),
|
question: adaptGroup22(raw["server.question"]),
|
||||||
worktree: adaptGroup23(raw["server.worktree"]),
|
reference: adaptGroup23(raw["server.reference"]),
|
||||||
vcs: adaptGroup24(raw["server.vcs"]),
|
worktree: adaptGroup24(raw["server.worktree"]),
|
||||||
debug: adaptGroup25(raw["server.debug"]),
|
vcs: adaptGroup25(raw["server.vcs"]),
|
||||||
migration: adaptGroup26(raw["server.migration"]),
|
debug: adaptGroup26(raw["server.debug"]),
|
||||||
websearch: adaptGroup27(raw["server.websearch"]),
|
migration: adaptGroup27(raw["server.migration"]),
|
||||||
config: adaptGroup28(raw["server.config"]),
|
websearch: adaptGroup28(raw["server.websearch"]),
|
||||||
|
config: adaptGroup29(raw["server.config"]),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||||
|
|||||||
@@ -194,6 +194,14 @@ import type {
|
|||||||
ShellOutputOutput,
|
ShellOutputOutput,
|
||||||
ShellRemoveInput,
|
ShellRemoveInput,
|
||||||
ShellRemoveOutput,
|
ShellRemoveOutput,
|
||||||
|
QuestionRequestListInput,
|
||||||
|
QuestionRequestListOutput,
|
||||||
|
QuestionListInput,
|
||||||
|
QuestionListOutput,
|
||||||
|
QuestionReplyInput,
|
||||||
|
QuestionReplyOutput,
|
||||||
|
QuestionRejectInput,
|
||||||
|
QuestionRejectOutput,
|
||||||
ReferenceListInput,
|
ReferenceListInput,
|
||||||
ReferenceListOutput,
|
ReferenceListOutput,
|
||||||
WorktreeListInput,
|
WorktreeListInput,
|
||||||
@@ -1382,6 +1390,7 @@ export function make(options: ClientOptions) {
|
|||||||
action: input["action"],
|
action: input["action"],
|
||||||
resources: input["resources"],
|
resources: input["resources"],
|
||||||
save: input["save"],
|
save: input["save"],
|
||||||
|
opaque: input["opaque"],
|
||||||
metadata: input["metadata"],
|
metadata: input["metadata"],
|
||||||
source: input["source"],
|
source: input["source"],
|
||||||
agent: input["agent"],
|
agent: input["agent"],
|
||||||
@@ -1652,6 +1661,56 @@ 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>(
|
||||||
|
|||||||
@@ -324,6 +324,12 @@ 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 }
|
||||||
@@ -904,6 +910,15 @@ 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
|
||||||
@@ -1362,6 +1377,7 @@ export type PermissionRequest = {
|
|||||||
action: string
|
action: string
|
||||||
resources: Array<string>
|
resources: Array<string>
|
||||||
save?: Array<string>
|
save?: Array<string>
|
||||||
|
opaque?: boolean
|
||||||
metadata?: { [x: string]: JsonValue }
|
metadata?: { [x: string]: JsonValue }
|
||||||
source?: PermissionSource
|
source?: PermissionSource
|
||||||
}
|
}
|
||||||
@@ -1378,6 +1394,7 @@ export type PermissionAsked = {
|
|||||||
action: string
|
action: string
|
||||||
resources: Array<string>
|
resources: Array<string>
|
||||||
save?: Array<string>
|
save?: Array<string>
|
||||||
|
opaque?: boolean
|
||||||
metadata?: { [x: string]: any }
|
metadata?: { [x: string]: any }
|
||||||
source?: PermissionSource
|
source?: PermissionSource
|
||||||
}
|
}
|
||||||
@@ -1410,6 +1427,23 @@ 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
|
||||||
@@ -1650,6 +1684,17 @@ 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
|
||||||
@@ -1835,7 +1880,6 @@ 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" }>
|
||||||
}
|
}
|
||||||
@@ -2072,6 +2116,9 @@ export type V2Event =
|
|||||||
| ShellCreated
|
| ShellCreated
|
||||||
| ShellExited
|
| ShellExited
|
||||||
| ShellDeleted
|
| ShellDeleted
|
||||||
|
| QuestionAsked
|
||||||
|
| QuestionReplied
|
||||||
|
| QuestionRejected
|
||||||
| FormCreated
|
| FormCreated
|
||||||
| FormReplied
|
| FormReplied
|
||||||
| FormCancelled
|
| FormCancelled
|
||||||
@@ -2251,6 +2298,14 @@ 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 }
|
||||||
@@ -5177,6 +5232,7 @@ export type PermissionCreateInput = {
|
|||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string>
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
@@ -5186,6 +5242,7 @@ export type PermissionCreateInput = {
|
|||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string>
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
@@ -5195,6 +5252,7 @@ export type PermissionCreateInput = {
|
|||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string>
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
@@ -5204,15 +5262,27 @@ export type PermissionCreateInput = {
|
|||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string>
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
}["save"]
|
}["save"]
|
||||||
|
readonly opaque?: {
|
||||||
|
readonly id?: string | null
|
||||||
|
readonly action: string
|
||||||
|
readonly resources: ReadonlyArray<string>
|
||||||
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
|
readonly agent?: string | null
|
||||||
|
}["opaque"]
|
||||||
readonly metadata?: {
|
readonly metadata?: {
|
||||||
readonly id?: string | null
|
readonly id?: string | null
|
||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string>
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
@@ -5222,6 +5292,7 @@ export type PermissionCreateInput = {
|
|||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string>
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
@@ -5231,6 +5302,7 @@ export type PermissionCreateInput = {
|
|||||||
readonly action: string
|
readonly action: string
|
||||||
readonly resources: ReadonlyArray<string>
|
readonly resources: ReadonlyArray<string>
|
||||||
readonly save?: ReadonlyArray<string>
|
readonly save?: ReadonlyArray<string>
|
||||||
|
readonly opaque?: boolean
|
||||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||||
readonly agent?: string | null
|
readonly agent?: string | null
|
||||||
@@ -5539,6 +5611,36 @@ 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
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import { batch, onCleanup, onMount } from "solid-js"
|
||||||
|
import { createStore } from "solid-js/store"
|
||||||
|
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
|
||||||
|
|
||||||
|
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||||
|
export type ClientConnectionEvent = {
|
||||||
|
readonly type: "client.connection"
|
||||||
|
readonly created: number
|
||||||
|
readonly data: {
|
||||||
|
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||||
|
readonly attempt: number
|
||||||
|
readonly error?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ClientConnectionOptions = {
|
||||||
|
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||||
|
readonly onEvent: (event: OpenCodeEvent) => void
|
||||||
|
readonly flushInterval?: number
|
||||||
|
readonly pageLifecycle?: boolean
|
||||||
|
readonly log?: {
|
||||||
|
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||||
|
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectTimeout = 2_000
|
||||||
|
const reconnectDelay = 1_000
|
||||||
|
const connectionHistoryLimit = 50
|
||||||
|
|
||||||
|
type CurrentDelta = Extract<
|
||||||
|
OpenCodeEvent,
|
||||||
|
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||||
|
>
|
||||||
|
|
||||||
|
export function coalesceClientEvents(events: OpenCodeEvent[]) {
|
||||||
|
return events.reduce<OpenCodeEvent[]>((output, event) => {
|
||||||
|
const current = currentDelta(event)
|
||||||
|
const previous = output[output.length - 1]
|
||||||
|
const prior = currentDelta(previous)
|
||||||
|
if (
|
||||||
|
!current ||
|
||||||
|
!prior ||
|
||||||
|
previous?.location?.directory !== event.location?.directory ||
|
||||||
|
currentDeltaKey(prior) !== currentDeltaKey(current)
|
||||||
|
) {
|
||||||
|
output.push(event)
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||||
|
output[output.length - 1] = {
|
||||||
|
...current,
|
||||||
|
data:
|
||||||
|
current.type === "session.compaction.delta"
|
||||||
|
? { ...current.data, text: fragment }
|
||||||
|
: { ...current.data, delta: fragment },
|
||||||
|
} as CurrentDelta
|
||||||
|
return output
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||||
|
if (
|
||||||
|
event?.type === "session.text.delta" ||
|
||||||
|
event?.type === "session.reasoning.delta" ||
|
||||||
|
event?.type === "session.tool.input.delta" ||
|
||||||
|
event?.type === "session.compaction.delta"
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDeltaKey(event: CurrentDelta) {
|
||||||
|
if (event.type === "session.tool.input.delta")
|
||||||
|
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||||
|
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||||
|
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentDeltaFragment(event: CurrentDelta) {
|
||||||
|
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
|
||||||
|
const abort = new AbortController()
|
||||||
|
const history: ClientConnectionEvent[] = []
|
||||||
|
const [connection, setConnection] = createStore<{
|
||||||
|
status: ClientConnectionStatus
|
||||||
|
attempt: number
|
||||||
|
error?: string
|
||||||
|
}>({ status: "connecting", attempt: 0 })
|
||||||
|
let api = initialApi
|
||||||
|
let pending: OpenCodeEvent[] = []
|
||||||
|
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||||
|
let stream: AbortController | undefined
|
||||||
|
let run: Promise<void> | undefined
|
||||||
|
let started = false
|
||||||
|
let generation = 0
|
||||||
|
|
||||||
|
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||||
|
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||||
|
if (history.length > connectionHistoryLimit) history.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(event: OpenCodeEvent) {
|
||||||
|
pending.push(event)
|
||||||
|
if (flushTimer) return
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = undefined
|
||||||
|
const events = pending
|
||||||
|
pending = []
|
||||||
|
batch(() => coalesceClientEvents(events).forEach(options.onEvent))
|
||||||
|
}, options.flushInterval ?? 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(signal: AbortSignal, attempt: number) {
|
||||||
|
let connectedAt: number | undefined
|
||||||
|
const request = new AbortController()
|
||||||
|
const cancel = () => request.abort(signal.reason)
|
||||||
|
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||||
|
signal.addEventListener("abort", cancel, { once: true })
|
||||||
|
|
||||||
|
try {
|
||||||
|
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||||
|
options.log?.info?.("event stream connecting", { attempt })
|
||||||
|
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||||
|
const first = await iterator.next()
|
||||||
|
if (signal.aborted) return { error: undefined, connectedAt }
|
||||||
|
if (first.done)
|
||||||
|
return {
|
||||||
|
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
|
||||||
|
connectedAt,
|
||||||
|
}
|
||||||
|
if (first.value.type !== "server.connected")
|
||||||
|
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||||
|
|
||||||
|
clearTimeout(timeout)
|
||||||
|
record("connected", attempt)
|
||||||
|
connectedAt = Date.now()
|
||||||
|
options.log?.info?.("event stream connected")
|
||||||
|
publish(first.value)
|
||||||
|
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||||
|
|
||||||
|
while (!signal.aborted) {
|
||||||
|
const event = await iterator.next()
|
||||||
|
if (signal.aborted) return { error: undefined, connectedAt }
|
||||||
|
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||||
|
if ("durable" in event.value)
|
||||||
|
options.log?.debug?.("event", {
|
||||||
|
type: event.value.type,
|
||||||
|
aggregateID: event.value.durable.aggregateID,
|
||||||
|
seq: event.value.durable.seq,
|
||||||
|
})
|
||||||
|
publish(event.value)
|
||||||
|
}
|
||||||
|
return { error: undefined, connectedAt }
|
||||||
|
} catch (error) {
|
||||||
|
return { error, connectedAt }
|
||||||
|
} finally {
|
||||||
|
request.abort()
|
||||||
|
clearTimeout(timeout)
|
||||||
|
signal.removeEventListener("abort", cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStream(active: number) {
|
||||||
|
let attempt = 0
|
||||||
|
while (!abort.signal.aborted && started && generation === active) {
|
||||||
|
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt, error: undefined })
|
||||||
|
const controller = new AbortController()
|
||||||
|
stream = controller
|
||||||
|
const cancel = () => controller.abort(abort.signal.reason)
|
||||||
|
abort.signal.addEventListener("abort", cancel)
|
||||||
|
const result = await connect(controller.signal, attempt)
|
||||||
|
abort.signal.removeEventListener("abort", cancel)
|
||||||
|
if (abort.signal.aborted || !started || generation !== active) return
|
||||||
|
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
|
||||||
|
attempt += 1
|
||||||
|
const message = errorMessage(result.error)
|
||||||
|
record("disconnected", attempt, message)
|
||||||
|
options.log?.info?.("event stream disconnected", { attempt, error: message })
|
||||||
|
setConnection({ status: "reconnecting", attempt, error: message })
|
||||||
|
|
||||||
|
if (options.reconnect) {
|
||||||
|
const next = await options.reconnect(controller.signal).catch((error) => {
|
||||||
|
if (!controller.signal.aborted)
|
||||||
|
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
|
||||||
|
})
|
||||||
|
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
|
||||||
|
if (next) {
|
||||||
|
api = next
|
||||||
|
if (attempt === 1) continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await wait(reconnectDelay, controller.signal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function start() {
|
||||||
|
if (started) return run
|
||||||
|
started = true
|
||||||
|
const active = ++generation
|
||||||
|
const previous = run
|
||||||
|
const current = (async () => {
|
||||||
|
if (previous) await previous
|
||||||
|
await runStream(active)
|
||||||
|
})().finally(() => {
|
||||||
|
if (run !== current) return
|
||||||
|
run = undefined
|
||||||
|
})
|
||||||
|
run = current
|
||||||
|
return run
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop() {
|
||||||
|
started = false
|
||||||
|
generation += 1
|
||||||
|
stream?.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
if (options.pageLifecycle) {
|
||||||
|
const pagehide = () => stop()
|
||||||
|
const pageshow = (event: PageTransitionEvent) => {
|
||||||
|
if (event.persisted) void start()
|
||||||
|
}
|
||||||
|
window.addEventListener("pagehide", pagehide)
|
||||||
|
window.addEventListener("pageshow", pageshow)
|
||||||
|
onCleanup(() => {
|
||||||
|
window.removeEventListener("pagehide", pagehide)
|
||||||
|
window.removeEventListener("pageshow", pageshow)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
void start()
|
||||||
|
})
|
||||||
|
|
||||||
|
onCleanup(() => {
|
||||||
|
stop()
|
||||||
|
abort.abort()
|
||||||
|
if (flushTimer) clearTimeout(flushTimer)
|
||||||
|
pending = []
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: () => connection.status,
|
||||||
|
attempt: () => connection.attempt,
|
||||||
|
error: () => connection.error,
|
||||||
|
internal: {
|
||||||
|
history: () => history.slice(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (error === undefined) return undefined
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
function wait(delay: number, signal: AbortSignal) {
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
const timer = setTimeout(done, delay)
|
||||||
|
signal.addEventListener("abort", done, { once: true })
|
||||||
|
function done() {
|
||||||
|
clearTimeout(timer)
|
||||||
|
signal.removeEventListener("abort", done)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./data"
|
||||||
|
export * from "./connection"
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import type { OpenCodeEvent } from "../src/promise"
|
||||||
|
import { coalesceClientEvents } from "../src/solid/connection"
|
||||||
|
|
||||||
|
describe("coalesceClientEvents", () => {
|
||||||
|
const delta = (id: string, value: string, ordinal = 0) =>
|
||||||
|
({
|
||||||
|
id,
|
||||||
|
created: 1,
|
||||||
|
type: "session.text.delta",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||||
|
}) as OpenCodeEvent
|
||||||
|
|
||||||
|
test("merges adjacent deltas for the same stream", () => {
|
||||||
|
const result = coalesceClientEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("coalesces tool input deltas by tool ID", () => {
|
||||||
|
const current = (eventID: string, id: string, value: string) =>
|
||||||
|
({
|
||||||
|
id: eventID,
|
||||||
|
created: 1,
|
||||||
|
type: "session.tool.input.delta",
|
||||||
|
location: { directory: "/repo" },
|
||||||
|
data: { sessionID: "ses", assistantMessageID: "msg", id, delta: value },
|
||||||
|
}) as OpenCodeEvent
|
||||||
|
const result = coalesceClientEvents([
|
||||||
|
current("evt_1", "call_1", "{"),
|
||||||
|
current("evt_2", "call_1", "}"),
|
||||||
|
current("evt_3", "call_2", "[]"),
|
||||||
|
])
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result[0]).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||||
|
expect(result[1]).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||||
|
})
|
||||||
|
|
||||||
|
test("preserves boundaries between distinct delta streams", () => {
|
||||||
|
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||||
|
expect(coalesceClientEvents(events).map((event) => event.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -53,12 +53,6 @@
|
|||||||
"node": "./src/image/photon-wasm.node.ts",
|
"node": "./src/image/photon-wasm.node.ts",
|
||||||
"default": "./src/image/photon-wasm.bun.ts"
|
"default": "./src/image/photon-wasm.bun.ts"
|
||||||
},
|
},
|
||||||
"#shell-parser-wasm": {
|
|
||||||
"workerd": "./src/shell/parser-wasm.workerd.ts",
|
|
||||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
|
||||||
"node": "./src/shell/parser-wasm.node.ts",
|
|
||||||
"default": "./src/shell/parser-wasm.bun.ts"
|
|
||||||
},
|
|
||||||
"#process-lock-ffi": {
|
"#process-lock-ffi": {
|
||||||
"workerd": "./src/util/process-lock-ffi.workerd.ts",
|
"workerd": "./src/util/process-lock-ffi.workerd.ts",
|
||||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||||
@@ -137,10 +131,7 @@
|
|||||||
"ignore": "7.0.5",
|
"ignore": "7.0.5",
|
||||||
"jsonc-parser": "3.3.1",
|
"jsonc-parser": "3.3.1",
|
||||||
"mime-types": "3.0.2",
|
"mime-types": "3.0.2",
|
||||||
"tree-sitter-bash": "0.25.0",
|
|
||||||
"tree-sitter-powershell": "0.25.10",
|
|
||||||
"venice-ai-sdk-provider": "2.1.1",
|
"venice-ai-sdk-provider": "2.1.1",
|
||||||
"web-tree-sitter": "0.25.10",
|
|
||||||
"which": "6.0.1",
|
"which": "6.0.1",
|
||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ const result = await Bun.build({
|
|||||||
target: "node",
|
target: "node",
|
||||||
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", "#process-lock-ffi", "#v1-migration"],
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
name: "bundle-shell-scan",
|
name: "bundle-shell-scan",
|
||||||
|
|||||||
@@ -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),
|
||||||
execute: () => Effect.succeed({ frames: Stream.empty }),
|
frames: () => Stream.empty,
|
||||||
},
|
},
|
||||||
defaults: {
|
defaults: {
|
||||||
headers: info.headers,
|
headers: info.headers,
|
||||||
|
|||||||
@@ -401,15 +401,6 @@ 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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user