mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-16 09:28:27 -04:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a2eeebca2 | |||
| 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 *"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||||
@@ -362,7 +361,6 @@ function ProviderConnection(props: {
|
|||||||
setBack: (handler: () => void) => void
|
setBack: (handler: () => void) => void
|
||||||
}) {
|
}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
@@ -386,10 +384,7 @@ function ProviderConnection(props: {
|
|||||||
const provider = createMemo(() => ({
|
const provider = createMemo(() => ({
|
||||||
id: props.provider,
|
id: props.provider,
|
||||||
name:
|
name:
|
||||||
providers.all().get(props.provider)?.name ??
|
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
||||||
serverSync.data.provider.all.get(props.provider)?.name ??
|
|
||||||
controller.integration()?.name ??
|
|
||||||
props.provider,
|
|
||||||
}))
|
}))
|
||||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||||
if (!value) return ""
|
if (!value) return ""
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { batch, For } from "solid-js"
|
|||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { ExternalLink } from "@/components/external-link"
|
import { ExternalLink } from "@/components/external-link"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||||
|
|
||||||
@@ -42,6 +43,7 @@ export function DialogCustomProvider(props: Props) {
|
|||||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const [form, setForm] = createStore<FormState>({
|
const [form, setForm] = createStore<FormState>({
|
||||||
@@ -117,7 +119,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
|||||||
form,
|
form,
|
||||||
t: language.t,
|
t: language.t,
|
||||||
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
disabledProviders: serverSync.data.config.disabled_providers ?? [],
|
||||||
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
|
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
|
||||||
})
|
})
|
||||||
batch(() => {
|
batch(() => {
|
||||||
setForm("err", output.err)
|
setForm("err", output.err)
|
||||||
|
|||||||
@@ -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,5 +1,6 @@
|
|||||||
import { Component, createMemo, Show } from "solid-js"
|
import { Component, createMemo, Show } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useData } from "@/context/server"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||||
import { List } from "@opencode-ai/ui/list"
|
import { List } from "@opencode-ai/ui/list"
|
||||||
import { Switch } from "@opencode-ai/ui/switch"
|
import { Switch } from "@opencode-ai/ui/switch"
|
||||||
@@ -14,12 +15,13 @@ const statusLabels = {
|
|||||||
} as const
|
} as const
|
||||||
|
|
||||||
export const DialogSelectMcp: Component = () => {
|
export const DialogSelectMcp: Component = () => {
|
||||||
const sync = useSync()
|
const data = useData()
|
||||||
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
const items = createMemo(() =>
|
const items = createMemo(() =>
|
||||||
Object.entries(sync().data.mcp ?? {})
|
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
|
||||||
.map(([name, status]) => ({ name, status: status.status }))
|
.map((server) => ({ name: server.name, status: server.status.status }))
|
||||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{(i) => {
|
{(i) => {
|
||||||
const mcpStatus = () => sync().data.mcp[i.name]
|
const mcpStatus = () =>
|
||||||
|
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
|
||||||
const status = () => mcpStatus()?.status
|
const status = () => mcpStatus()?.status
|
||||||
const statusLabel = () => {
|
const statusLabel = () => {
|
||||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
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 { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { ExternalLink } from "./external-link"
|
import { ExternalLink } from "./external-link"
|
||||||
|
|
||||||
@@ -77,30 +78,41 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const directorySDK = useSDK()
|
const directorySDK = useSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
|
|
||||||
const [serverMcp] = createResource(
|
createEffect(() => {
|
||||||
serverSDK,
|
if (serverSDK.connection.status() !== "connected") return
|
||||||
(sdk) =>
|
const ref = { directory: directorySDK().directory }
|
||||||
sdk.api.mcp
|
void Promise.all([
|
||||||
.list()
|
data.location.mcp.server.sync(),
|
||||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
data.location.skill.sync(),
|
||||||
.catch(() => ({})),
|
data.location.mcp.server.sync(ref),
|
||||||
{ initialValue: {} },
|
data.location.skill.sync(ref),
|
||||||
)
|
]).catch(() => undefined)
|
||||||
|
})
|
||||||
|
|
||||||
const globalMcpNames = createMemo(() =>
|
const globalMcpNames = createMemo(() =>
|
||||||
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
[
|
||||||
|
...new Set([
|
||||||
|
...Object.keys(serverSync.data.config.mcp ?? {}),
|
||||||
|
...(data.location.mcp.server.list() ?? []).map((server) => server.name),
|
||||||
|
]),
|
||||||
|
].sort(),
|
||||||
)
|
)
|
||||||
const projectMcpNames = createMemo(() => {
|
const projectMcpNames = createMemo(() => {
|
||||||
const shared = new Set(globalMcpNames())
|
const shared = new Set(globalMcpNames())
|
||||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||||
if (configured.length > 0) return configured.sort()
|
if (configured.length > 0) return configured.sort()
|
||||||
return Object.keys(sync().data.mcp ?? {})
|
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
|
||||||
|
.map((server) => server.name)
|
||||||
.filter((name) => !shared.has(name))
|
.filter((name) => !shared.has(name))
|
||||||
.sort()
|
.sort()
|
||||||
})
|
})
|
||||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
const mcpEnabled = (name: string) =>
|
||||||
|
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
|
||||||
|
.status === "connected"
|
||||||
|
|
||||||
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
||||||
const projectPlugins = createMemo(() => {
|
const projectPlugins = createMemo(() => {
|
||||||
@@ -108,23 +120,12 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||||
})
|
})
|
||||||
|
|
||||||
const [serverSkills] = createResource(
|
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||||
serverSDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const [directorySkills] = createResource(
|
|
||||||
directorySDK,
|
|
||||||
(sdk): Promise<SkillItem[]> =>
|
|
||||||
sdk.api.skill
|
|
||||||
.list({ location: { directory: sdk.directory } })
|
|
||||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
|
||||||
{ initialValue: [] },
|
|
||||||
)
|
|
||||||
const projectSkills = createMemo(() => {
|
const projectSkills = createMemo(() => {
|
||||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
const shared = new Set(serverSkills().map(skillKey))
|
||||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
|
||||||
|
(item) => !shared.has(skillKey(item)),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
const mcpRows = (items: string[]) => (
|
const mcpRows = (items: string[]) => (
|
||||||
@@ -205,7 +206,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
|||||||
<Show when={projectSkills().length > 0}>
|
<Show when={projectSkills().length > 0}>
|
||||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||||
</Show>
|
</Show>
|
||||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||||
</div>
|
</div>
|
||||||
</TabsV2.Content>
|
</TabsV2.Content>
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { usePermission } from "@/context/permission"
|
|||||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { createSessionTabs } from "@/pages/session/helpers"
|
import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
@@ -82,6 +83,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
|||||||
|
|
||||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const data = useData()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
@@ -113,8 +115,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
return [...result, path]
|
return [...result, path]
|
||||||
}, [])
|
}, [])
|
||||||
})
|
})
|
||||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||||
const attachments = createMemo(() =>
|
const attachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -246,7 +248,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
const resources = createMemo(() =>
|
const resources = createMemo(() =>
|
||||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
|
||||||
id: `resource:${resource.server}:${resource.uri}`,
|
id: `resource:${resource.server}:${resource.uri}`,
|
||||||
kind: "resource" as const,
|
kind: "resource" as const,
|
||||||
label: `@${resource.name}`,
|
label: `@${resource.name}`,
|
||||||
@@ -292,7 +294,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
|||||||
})),
|
})),
|
||||||
])
|
])
|
||||||
const slashCommands = createMemo(() => [
|
const slashCommands = createMemo(() => [
|
||||||
...sync().data.command.map((item) => ({
|
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
|
||||||
id: `custom.${item.name}`,
|
id: `custom.${item.name}`,
|
||||||
trigger: item.name,
|
trigger: item.name,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
} from "@/context/prompt"
|
} from "@/context/prompt"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
@@ -115,6 +116,7 @@ const EXAMPLES = [
|
|||||||
|
|
||||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
|
const data = useData()
|
||||||
|
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const files = useFile()
|
const files = useFile()
|
||||||
@@ -250,8 +252,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
|
|
||||||
return paths
|
return paths
|
||||||
})
|
})
|
||||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||||
const imageAttachments = createMemo(() =>
|
const imageAttachments = createMemo(() =>
|
||||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||||
)
|
)
|
||||||
@@ -585,7 +587,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const mcpResourceList = createMemo(() =>
|
const mcpResourceList = createMemo(() =>
|
||||||
Object.values(sync().data.mcp_resource).map(
|
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map(
|
||||||
(resource): AtOption => ({
|
(resource): AtOption => ({
|
||||||
type: "resource",
|
type: "resource",
|
||||||
name: resource.name,
|
name: resource.name,
|
||||||
@@ -702,7 +704,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
|||||||
type: "builtin" as const,
|
type: "builtin" as const,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const custom = sync().data.command.map((cmd) => ({
|
const custom = (data.location.command.list({ directory: sdk().directory }) ?? []).map((cmd) => ({
|
||||||
id: `custom.${cmd.name}`,
|
id: `custom.${cmd.name}`,
|
||||||
trigger: cmd.name,
|
trigger: cmd.name,
|
||||||
title: cmd.name,
|
title: cmd.name,
|
||||||
|
|||||||
@@ -286,6 +286,20 @@ beforeAll(async () => {
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
mock.module("@/context/server", () => ({
|
||||||
|
useData: () => ({
|
||||||
|
session: {
|
||||||
|
remember: () => undefined,
|
||||||
|
setStatus: () => undefined,
|
||||||
|
},
|
||||||
|
location: {
|
||||||
|
command: {
|
||||||
|
list: () => commands,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
mock.module("@/context/platform", () => ({
|
mock.module("@/context/platform", () => ({
|
||||||
usePlatform: () => ({
|
usePlatform: () => ({
|
||||||
fetch: fetch,
|
fetch: fetch,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { Message } from "@/types"
|
import type { Message } from "@/types"
|
||||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Binary } from "@opencode-ai/core/util/binary"
|
import { Binary } from "@opencode-ai/core/util/binary"
|
||||||
@@ -7,6 +8,7 @@ import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
|||||||
import { batch, startTransition, type Accessor } from "solid-js"
|
import { batch, startTransition, type Accessor } from "solid-js"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLocal, type ModelSelection } from "@/context/local"
|
import { useLocal, type ModelSelection } from "@/context/local"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
@@ -37,7 +39,7 @@ export type FollowupDraft = {
|
|||||||
|
|
||||||
type FollowupSendInput = {
|
type FollowupSendInput = {
|
||||||
api: DirectorySDK["api"]["session"]
|
api: DirectorySDK["api"]["session"]
|
||||||
serverSync: ServerSync
|
data: Data
|
||||||
sync: DirectorySync
|
sync: DirectorySync
|
||||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||||
draft: FollowupDraft
|
draft: FollowupDraft
|
||||||
@@ -54,17 +56,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
|||||||
const images = draftImages(input.draft.prompt)
|
const images = draftImages(input.draft.prompt)
|
||||||
const setBusy = () => {
|
const setBusy = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
input.data.session.setStatus(input.draft.sessionID, "running")
|
||||||
}
|
}
|
||||||
|
|
||||||
const setIdle = () => {
|
const setIdle = () => {
|
||||||
if (!input.optimisticBusy) return
|
if (!input.optimisticBusy) return
|
||||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
input.data.session.setStatus(input.draft.sessionID, "idle")
|
||||||
}
|
}
|
||||||
|
|
||||||
const [head, ...tail] = text.split(" ")
|
const [head, ...tail] = text.split(" ")
|
||||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
|
||||||
setBusy()
|
setBusy()
|
||||||
try {
|
try {
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
@@ -228,6 +230,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const permission = usePermission()
|
const permission = usePermission()
|
||||||
const prompt = input.prompt
|
const prompt = input.prompt
|
||||||
@@ -281,6 +284,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||||
|
data.session.remember(info)
|
||||||
target.session.remember(info)
|
target.session.remember(info)
|
||||||
const [, setStore] = target.child(dir)
|
const [, setStore] = target.child(dir)
|
||||||
setStore("session", (list: SessionInfo[]) => {
|
setStore("session", (list: SessionInfo[]) => {
|
||||||
@@ -329,6 +333,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
const submissionSDK = sdk()
|
const submissionSDK = sdk()
|
||||||
const submissionSync = sync()
|
const submissionSync = sync()
|
||||||
const submissionServerSync = serverSync
|
const submissionServerSync = serverSync
|
||||||
|
const submissionData = data
|
||||||
const submissionScope = submissionSDK.scope
|
const submissionScope = submissionSDK.scope
|
||||||
const projectDirectory = submissionSDK.directory
|
const projectDirectory = submissionSDK.directory
|
||||||
const sessionID = params.id
|
const sessionID = params.id
|
||||||
@@ -503,11 +508,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
if (text.startsWith("/")) {
|
if (text.startsWith("/")) {
|
||||||
const [cmdName, ...args] = text.split(" ")
|
const [cmdName, ...args] = text.split(" ")
|
||||||
const commandName = cmdName.slice(1)
|
const commandName = cmdName.slice(1)
|
||||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
const customCommand = submissionData.location.command
|
||||||
|
.list({ directory: sessionDirectory })
|
||||||
|
?.find((command) => command.name === commandName)
|
||||||
if (customCommand) {
|
if (customCommand) {
|
||||||
clearInput()
|
clearInput()
|
||||||
const messageID = Identifier.ascending("message")
|
const messageID = Identifier.ascending("message")
|
||||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
submissionData.session.setStatus(session.id, "running")
|
||||||
void submissionSDK.api.session
|
void submissionSDK.api.session
|
||||||
.command({
|
.command({
|
||||||
sessionID: session.id,
|
sessionID: session.id,
|
||||||
@@ -524,7 +531,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
submissionData.session.setStatus(session.id, "idle")
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||||
@@ -552,14 +559,14 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
|||||||
void sendFollowupDraft({
|
void sendFollowupDraft({
|
||||||
api: submissionSDK.api.session,
|
api: submissionSDK.api.session,
|
||||||
sync: submissionSync,
|
sync: submissionSync,
|
||||||
serverSync: submissionServerSync,
|
data: submissionData,
|
||||||
session: () => session,
|
session: () => session,
|
||||||
draft,
|
draft,
|
||||||
messageID,
|
messageID,
|
||||||
optimisticBusy: sessionDirectory === projectDirectory,
|
optimisticBusy: sessionDirectory === projectDirectory,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
if (sessionDirectory === projectDirectory) {
|
if (sessionDirectory === projectDirectory) {
|
||||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
submissionData.session.setStatus(session.id, "idle")
|
||||||
}
|
}
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||||
import { useQueryClient } from "@tanstack/solid-query"
|
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
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, createMemo, createResource, onCleanup } from "solid-js"
|
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
|
|
||||||
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
|
|||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const queryClient = useQueryClient()
|
|
||||||
const location = () => {
|
const location = () => {
|
||||||
const directory = options.directory()
|
const directory = options.directory()
|
||||||
return directory ? { directory } : undefined
|
return directory ? { directory } : undefined
|
||||||
@@ -118,10 +115,17 @@ export function createProviderConnectionController(options: {
|
|||||||
}
|
}
|
||||||
const finish = async () => {
|
const finish = async () => {
|
||||||
cancelPolling()
|
cancelPolling()
|
||||||
const directory = options.directory()
|
const ref = location()
|
||||||
await queryClient
|
data.location.integration.invalidate(ref)
|
||||||
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
data.location.provider.invalidate(ref)
|
||||||
.catch(() => undefined)
|
data.location.model.invalidate(ref)
|
||||||
|
data.location.model.invalidateDefault(ref)
|
||||||
|
await Promise.all([
|
||||||
|
data.location.integration.sync(ref),
|
||||||
|
data.location.provider.sync(ref),
|
||||||
|
data.location.model.sync(ref),
|
||||||
|
data.location.model.syncDefault(ref),
|
||||||
|
]).catch(() => undefined)
|
||||||
if (polling.disposed) return
|
if (polling.disposed) return
|
||||||
options.onComplete()
|
options.onComplete()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { createMediaQuery } from "@solid-primitives/media"
|
|||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
@@ -44,6 +45,7 @@ function openSessionContext(args: {
|
|||||||
|
|
||||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -62,7 +64,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
|||||||
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
||||||
})
|
})
|
||||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||||
|
|
||||||
const usd = createMemo(
|
const usd = createMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js
|
|||||||
import type { Project } from "@/types"
|
import type { Project } from "@/types"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
|
|||||||
}) {
|
}) {
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const openWorkspaces = useSettingsDialog("workspaces")
|
const openWorkspaces = useSettingsDialog("workspaces")
|
||||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||||
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
|
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
|
||||||
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
||||||
const workspaces = () =>
|
const workspaces = () =>
|
||||||
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||||
import type { JSX } from "solid-js"
|
import type { JSX } from "solid-js"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
import { checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { findLast } from "@opencode-ai/core/util/array"
|
import { findLast } from "@opencode-ai/core/util/array"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
@@ -96,12 +97,13 @@ const emptyUserMessages: UserMessage[] = []
|
|||||||
|
|
||||||
export function SessionContextTab() {
|
export function SessionContextTab() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const { params, view } = useSessionLayout()
|
const { params, view } = useSessionLayout()
|
||||||
|
|
||||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||||
|
|
||||||
const messages = createMemo(
|
const messages = createMemo(
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DateTime } from "luxon"
|
|||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { Icon } from "@opencode-ai/ui/icon"
|
import { Icon } from "@opencode-ai/ui/icon"
|
||||||
import { Mark } from "@opencode-ai/ui/logo"
|
import { Mark } from "@opencode-ai/ui/logo"
|
||||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||||
@@ -19,6 +20,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
const data = useData()
|
||||||
|
|
||||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||||
@@ -37,7 +39,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
|||||||
const label = (value: string) => {
|
const label = (value: string) => {
|
||||||
if (value === MAIN_WORKTREE) {
|
if (value === MAIN_WORKTREE) {
|
||||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||||
const branch = sync().data.vcs?.branch
|
const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||||
return language.t("session.new.worktree.main")
|
return language.t("session.new.worktree.main")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
|
|||||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||||
return draft?.type === "draft" ? draft.directory : undefined
|
return draft?.type === "draft" ? draft.directory : undefined
|
||||||
}
|
}
|
||||||
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
|
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
||||||
return undefined
|
return undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
|||||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||||
import { usePermission } from "@/context/permission"
|
import { usePermission } from "@/context/permission"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import {
|
import {
|
||||||
monoDefault,
|
monoDefault,
|
||||||
monoFontFamily,
|
monoFontFamily,
|
||||||
@@ -34,7 +33,7 @@ export function createPermissionScopeController(
|
|||||||
const s = server()
|
const s = server()
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
if (!s || !id) return undefined
|
if (!s || !id) return undefined
|
||||||
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
|
return serverCtx()?.data.session.lineage.peek(id)?.session.location.directory
|
||||||
})
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { getRelativeTime } from "@/utils/time"
|
import { getRelativeTime } from "@/utils/time"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
@@ -49,6 +50,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
@@ -71,13 +73,19 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
|||||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||||
const captureDeleteContext = () => {
|
const captureDeleteContext = () => {
|
||||||
const sdk = serverSDK
|
const sdk = serverSDK
|
||||||
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
return {
|
||||||
|
sdk,
|
||||||
|
data,
|
||||||
|
sync: serverSync,
|
||||||
|
server: ServerConnection.key(sdk.server),
|
||||||
|
activeDirectory: props.activeDirectory,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const loadSessions = async (context = captureDeleteContext()) => {
|
const loadSessions = async (context = captureDeleteContext()) => {
|
||||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||||
return mergeWorkspaceSessionInventory(
|
return mergeWorkspaceSessionInventory(
|
||||||
fetched,
|
fetched,
|
||||||
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
context.data.session.list(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const sessionQuery = useQuery(() => ({
|
const sessionQuery = useQuery(() => ({
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { type ServerHealth } from "@/utils/server-health"
|
|||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useMcpToggle } from "@/context/mcp"
|
import { useMcpToggle } from "@/context/mcp"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServer } from "@/context/server"
|
import { useData, useServer } from "@/context/server"
|
||||||
|
|
||||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||||
const parts = value.split(file)
|
const parts = value.split(file)
|
||||||
@@ -103,6 +103,7 @@ type ServerStatusItem = {
|
|||||||
|
|
||||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
|
|
||||||
@@ -119,8 +120,9 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
|||||||
dialogRun += 1
|
dialogRun += 1
|
||||||
})
|
})
|
||||||
const toggleMcp = useMcpToggle()
|
const toggleMcp = useMcpToggle()
|
||||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
|
||||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
|
||||||
|
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
|
||||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||||
const lspCount = createMemo(() => lspItems().length)
|
const lspCount = createMemo(() => lspItems().length)
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
hasServiceNeedingAttention,
|
hasServiceNeedingAttention,
|
||||||
serverStatusDotClass,
|
serverStatusDotClass,
|
||||||
} from "./status-popover-indicator"
|
} from "./status-popover-indicator"
|
||||||
import { useServer } from "@/context/server"
|
import { useData, useServer } from "@/context/server"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
|
||||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||||
|
|
||||||
@@ -23,17 +24,20 @@ export function StatusPopover() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
|
const sdk = useSDK()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||||
|
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
|
||||||
const attention = createMemo(() =>
|
const attention = createMemo(() =>
|
||||||
hasServiceNeedingAttention({
|
hasServiceNeedingAttention({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -87,17 +91,20 @@ export function StatusPopoverV2() {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
|
const sdk = useSDK()
|
||||||
const [shown, setShown] = createSignal(false)
|
const [shown, setShown] = createSignal(false)
|
||||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||||
|
const ready = createMemo(() => serverHealth() === false || (mcp() !== undefined && sync().data.lsp_ready))
|
||||||
const attention = createMemo(() =>
|
const attention = createMemo(() =>
|
||||||
hasServiceNeedingAttention({
|
hasServiceNeedingAttention({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const issue = createMemo(() =>
|
const issue = createMemo(() =>
|
||||||
hasNonBlockingServiceIssue({
|
hasNonBlockingServiceIssue({
|
||||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -84,14 +84,18 @@ function SessionTabEntry(props: {
|
|||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||||
const persisted = createMemo(() => tabs.info[props.id])
|
const persisted = createMemo(() => tabs.info[props.id])
|
||||||
const [loadedSession] = createResource(
|
const [loadedSession] = createResource(
|
||||||
() => {
|
() => {
|
||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx
|
||||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||||
},
|
},
|
||||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
({ id, ctx }) =>
|
||||||
|
ctx.data.session
|
||||||
|
.sync(id)
|
||||||
|
.then(() => ctx.data.session.get(id))
|
||||||
|
.catch(() => undefined),
|
||||||
)
|
)
|
||||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
||||||
@@ -103,13 +107,13 @@ function SessionTabEntry(props: {
|
|||||||
const ctx = props.serverCtx
|
const ctx = props.serverCtx
|
||||||
if (!value || !ctx) return
|
if (!value || !ctx) return
|
||||||
|
|
||||||
ctx.sync.session.remember({ ...value, title })
|
ctx.data.session.remember({ ...value, title })
|
||||||
try {
|
try {
|
||||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const current = session()
|
const current = session()
|
||||||
const currentCtx = props.serverCtx
|
const currentCtx = props.serverCtx
|
||||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
|
||||||
showToast({
|
showToast({
|
||||||
title: language.t("common.requestFailed"),
|
title: language.t("common.requestFailed"),
|
||||||
description: err instanceof Error ? err.message : undefined,
|
description: err instanceof Error ? err.message : undefined,
|
||||||
@@ -126,9 +130,13 @@ function SessionTabEntry(props: {
|
|||||||
prefetched = true
|
prefetched = true
|
||||||
createRoot((dispose) => {
|
createRoot((dispose) => {
|
||||||
try {
|
try {
|
||||||
void ctx.sync
|
void Promise.allSettled([
|
||||||
.ensureDirSyncContext(value.location.directory)
|
ctx.data.session.sync(value.id, { children: true }),
|
||||||
.session.sync(value.id)
|
ctx.data.session.pending.sync(value.id),
|
||||||
|
ctx.data.session.message.sync(value.id),
|
||||||
|
ctx.data.session.permission.sync(value.id),
|
||||||
|
ctx.data.session.form.sync(value.id),
|
||||||
|
])
|
||||||
.catch(() => {})
|
.catch(() => {})
|
||||||
.finally(dispose)
|
.finally(dispose)
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
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 { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||||
import {
|
import { loadPathQuery, loadProjectsQuery } 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"
|
||||||
|
|
||||||
@@ -18,44 +10,11 @@ type WorktreeApi = ServerApi["worktree"]
|
|||||||
|
|
||||||
describe("query keys", () => {
|
describe("query keys", () => {
|
||||||
test("partitions identical directories by server scope", () => {
|
test("partitions identical directories by server scope", () => {
|
||||||
const api = {} as CatalogApi
|
|
||||||
const location = {} as ServerApi["location"]
|
const location = {} as ServerApi["location"]
|
||||||
const remote = "https://debian.example" as typeof ServerScope.local
|
const remote = "https://debian.example" as typeof ServerScope.local
|
||||||
|
|
||||||
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
||||||
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
|
||||||
})
|
|
||||||
|
|
||||||
test("loads the current provider and model catalog", async () => {
|
|
||||||
const calls: unknown[] = []
|
|
||||||
const api = {
|
|
||||||
provider: {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(["provider", input])
|
|
||||||
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
model: {
|
|
||||||
list: async (input: unknown) => {
|
|
||||||
calls.push(["model", input])
|
|
||||||
return { location: {}, data: [] }
|
|
||||||
},
|
|
||||||
default: async (input: unknown) => {
|
|
||||||
calls.push(["default", input])
|
|
||||||
return { location: {}, data: null }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} as unknown as CatalogApi
|
|
||||||
|
|
||||||
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
|
||||||
|
|
||||||
expect(calls).toEqual([
|
|
||||||
["provider", { location: { directory: "/repo" } }],
|
|
||||||
["model", { location: { directory: "/repo" } }],
|
|
||||||
["default", { location: { directory: "/repo" } }],
|
|
||||||
])
|
|
||||||
expect(result.connected).toEqual(["openai"])
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads current location metadata", async () => {
|
test("loads current location metadata", async () => {
|
||||||
@@ -73,39 +32,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 +87,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,12 @@
|
|||||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||||
import type {
|
import type {
|
||||||
AgentListInput,
|
|
||||||
AgentListOutput,
|
|
||||||
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,20 +17,16 @@ 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, normalizeProjectInfo } 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 type { ServerScope } from "@/utils/server-scope"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
|
||||||
import type { ServerApi } from "@/utils/server"
|
import type { ServerApi } from "@/utils/server"
|
||||||
import { sameDirectory } from "@/utils/workspace"
|
import { sameDirectory } from "@/utils/workspace"
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
provider: NormalizedProviderListResponse
|
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
@@ -69,12 +55,6 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
|||||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerRev = new Map<string, number>()
|
|
||||||
|
|
||||||
export function clearProviderRev(scope: ServerScope, directory: string) {
|
|
||||||
providerRev.delete(ScopedKey.from(scope, directory))
|
|
||||||
}
|
|
||||||
|
|
||||||
function runAll(list: Array<() => Promise<unknown>>) {
|
function runAll(list: Array<() => Promise<unknown>>) {
|
||||||
return Promise.allSettled(list.map((item) => item()))
|
return Promise.allSettled(list.map((item) => item()))
|
||||||
}
|
}
|
||||||
@@ -109,9 +89,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({
|
||||||
@@ -150,7 +129,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
|
|||||||
})
|
})
|
||||||
|
|
||||||
export async function bootstrapGlobal(input: {
|
export async function bootstrapGlobal(input: {
|
||||||
serverAPI: CatalogApi & {
|
serverAPI: {
|
||||||
readonly location: LocationApi
|
readonly location: LocationApi
|
||||||
readonly project: ProjectApi
|
readonly project: ProjectApi
|
||||||
readonly worktree: WorktreeApi
|
readonly worktree: WorktreeApi
|
||||||
@@ -164,7 +143,6 @@ export async function bootstrapGlobal(input: {
|
|||||||
}) {
|
}) {
|
||||||
const slow = [
|
const slow = [
|
||||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)),
|
|
||||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||||
() =>
|
() =>
|
||||||
input.queryClient
|
input.queryClient
|
||||||
@@ -224,53 +202,6 @@ function warmSessions(input: {
|
|||||||
).then(() => undefined)
|
).then(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) =>
|
|
||||||
queryOptions({
|
|
||||||
queryKey: [scope, directory, "providers"],
|
|
||||||
queryFn: () =>
|
|
||||||
retry(async () => {
|
|
||||||
const location = directory ? { location: { directory } } : undefined
|
|
||||||
const [providers, models, defaultModel] = await Promise.all([
|
|
||||||
sdk.provider.list(location),
|
|
||||||
sdk.model.list(location),
|
|
||||||
sdk.model.default(location),
|
|
||||||
])
|
|
||||||
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
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 +215,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: {
|
||||||
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>
|
||||||
@@ -314,49 +233,25 @@ export async function bootstrapDirectory(input: {
|
|||||||
config: Config
|
config: Config
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
provider: NormalizedProviderListResponse
|
|
||||||
}
|
}
|
||||||
queryClient: QueryClient
|
queryClient: QueryClient
|
||||||
session?: ServerSession
|
session?: ServerSession
|
||||||
}) {
|
}) {
|
||||||
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 }))
|
||||||
}
|
}
|
||||||
if (loading) input.setStore("status", "partial")
|
if (loading) input.setStore("status", "partial")
|
||||||
|
|
||||||
const revKey = ScopedKey.from(input.scope, input.directory)
|
|
||||||
const rev = (providerRev.get(revKey) ?? 0) + 1
|
|
||||||
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,23 +286,41 @@ export async function bootstrapDirectory(input: {
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
() => 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
|
retry(() =>
|
||||||
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
input.api.question.request
|
||||||
.catch((err) => {
|
.list({ location: { directory: input.directory } })
|
||||||
const project = getFilename(input.directory)
|
.then((result) => result.data)
|
||||||
showToast({
|
.then((questions) => {
|
||||||
variant: "error",
|
const ids = questions.map((question) => question.sessionID)
|
||||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
const grouped = groupBySession(
|
||||||
description: formatServerError(err, input.translate),
|
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)),
|
||||||
].filter(Boolean) as (() => Promise<any>)[]
|
].filter(Boolean) as (() => Promise<any>)[]
|
||||||
|
|
||||||
await waitForPaint()
|
await waitForPaint()
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
||||||
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 }> = []
|
||||||
let providerQuerySuccess = true
|
|
||||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||||
store[0],
|
store[0],
|
||||||
store[1],
|
store[1],
|
||||||
@@ -17,12 +16,26 @@ 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 path = { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||||
|
const data = {
|
||||||
|
location: {
|
||||||
|
info: () => undefined,
|
||||||
|
agent: { list: () => undefined },
|
||||||
|
command: { list: () => undefined },
|
||||||
|
reference: { list: () => undefined },
|
||||||
|
provider: { list: () => undefined },
|
||||||
|
model: { list: () => undefined, default: () => 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 () => ({}) }),
|
||||||
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
||||||
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
|
|
||||||
path: (directory: string | null) => ({
|
path: (directory: string | null) => ({
|
||||||
queryKey: [directory, "path"],
|
queryKey: [directory, "path"],
|
||||||
queryFn: async () => ({
|
queryFn: async () => ({
|
||||||
@@ -60,7 +73,7 @@ beforeAll(async () => {
|
|||||||
return options().queryKey?.[1] === "path"
|
return options().queryKey?.[1] === "path"
|
||||||
},
|
},
|
||||||
get isSuccess() {
|
get isSuccess() {
|
||||||
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
|
return false
|
||||||
},
|
},
|
||||||
get isRefetchError() {
|
get isRefetchError() {
|
||||||
return false
|
return false
|
||||||
@@ -69,7 +82,6 @@ beforeAll(async () => {
|
|||||||
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
||||||
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
||||||
if (options().queryKey?.[1] === "lsp") return []
|
if (options().queryKey?.[1] === "lsp") return []
|
||||||
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
|
|
||||||
return undefined
|
return undefined
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -100,7 +112,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
|
|
||||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||||
@@ -134,7 +147,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -167,7 +181,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -198,7 +213,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,7 +230,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 +250,21 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
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(1)
|
||||||
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 +291,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -293,11 +301,8 @@ 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(1)
|
||||||
expect(queries[0]?.().enabled).toBe(false)
|
expect(queries[0]?.().enabled).toBe(false)
|
||||||
expect(queries[3]?.().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 +310,6 @@ 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[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 })
|
||||||
@@ -317,9 +319,8 @@ describe("createChildStoreManager", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not mark a cancelled provider query as ready", () => {
|
test("does not mark unsynced provider data as ready", () => {
|
||||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||||
providerQuerySuccess = false
|
|
||||||
|
|
||||||
const dispose = createOwner((owner) => {
|
const dispose = createOwner((owner) => {
|
||||||
manager = createChildStoreManager({
|
manager = createChildStoreManager({
|
||||||
@@ -334,7 +335,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -343,7 +345,6 @@ describe("createChildStoreManager", () => {
|
|||||||
const [store] = manager.child("/cancelled")
|
const [store] = manager.child("/cancelled")
|
||||||
expect(store.provider_ready).toBe(false)
|
expect(store.provider_ready).toBe(false)
|
||||||
} finally {
|
} finally {
|
||||||
providerQuerySuccess = true
|
|
||||||
dispose()
|
dispose()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -365,7 +366,8 @@ describe("createChildStoreManager", () => {
|
|||||||
onDispose() {},
|
onDispose() {},
|
||||||
translate: (key) => key,
|
translate: (key) => key,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
global: { provider },
|
data,
|
||||||
|
global: { path },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -374,11 +376,9 @@ 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)
|
|
||||||
|
|
||||||
connected = true
|
connected = true
|
||||||
expect(queries[0]?.().enabled).toBe(true)
|
expect(queries[0]?.().enabled).toBe(true)
|
||||||
expect(queries[4]?.().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,
|
||||||
@@ -17,8 +17,9 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
|||||||
import { useQuery } from "@tanstack/solid-query"
|
import { useQuery } from "@tanstack/solid-query"
|
||||||
import { QueryOptionsApi } from "../server-sync"
|
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 type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
|
import { normalizeAgentList, normalizeProviderList } from "./utils"
|
||||||
|
|
||||||
export function createChildStoreManager(input: {
|
export function createChildStoreManager(input: {
|
||||||
owner: Owner
|
owner: Owner
|
||||||
@@ -32,8 +33,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: {
|
||||||
provider: NormalizedProviderListResponse
|
path: Path
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||||
@@ -45,7 +47,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 +121,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,59 +186,49 @@ 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(),
|
||||||
}))
|
}))
|
||||||
const providerQuery = useQuery(() => ({
|
|
||||||
...input.queryOptions.providers(key),
|
|
||||||
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,
|
||||||
icon: initialIcon,
|
icon: initialIcon,
|
||||||
get provider_ready() {
|
get provider_ready() {
|
||||||
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
|
return (
|
||||||
|
input.data.location.provider.list({ directory }) !== undefined &&
|
||||||
|
input.data.location.model.list({ directory }) !== undefined &&
|
||||||
|
input.data.location.model.default({ directory }) !== undefined
|
||||||
|
)
|
||||||
},
|
},
|
||||||
get provider() {
|
get provider() {
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
const provider = input.data.location.provider.list({ directory })
|
||||||
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
|
const model = input.data.location.model.list({ directory })
|
||||||
const provider = providerQuery.data
|
const defaultModel = input.data.location.model.default({ directory })
|
||||||
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
if (!provider || !model || defaultModel === undefined) return { all: new Map(), connected: [], default: {} }
|
||||||
return provider
|
return normalizeProviderList(provider, model, defaultModel)
|
||||||
},
|
},
|
||||||
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 +240,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 +263,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 +276,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 +339,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 +355,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,9 +100,15 @@ 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({
|
||||||
const permission = createServerPermissionState({ sdk, sync })
|
api: () => sdk.api,
|
||||||
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
event: sdk.event,
|
||||||
|
connection: sdk.connection,
|
||||||
|
directory: "",
|
||||||
|
})
|
||||||
|
const sync = createServerSyncContext(sdk, data)
|
||||||
|
const permission = createServerPermissionState({ sdk, sync, data })
|
||||||
|
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||||
|
|
||||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } fr
|
|||||||
import { useLocation } from "@solidjs/router"
|
import { useLocation } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||||
import { useServerSync } from "./server-sync"
|
|
||||||
import { ServerConnection, useServers } from "./servers"
|
import { ServerConnection, useServers } from "./servers"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import type { Project } from "@/types"
|
import type { Project } from "@/types"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { hasCustomAgent, resolveAgent } from "./local-agent"
|
|||||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
|
import { useData } from "./server"
|
||||||
|
import { normalizeAgentList } from "./global-sync/utils"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||||
|
|
||||||
@@ -61,13 +63,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
|||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const models = useModels()
|
const models = useModels()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
|
||||||
const id = createMemo(() => params.id || undefined)
|
const id = createMemo(() => params.id || undefined)
|
||||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
const list = createMemo(() =>
|
||||||
|
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
|
||||||
|
(item) => item.mode !== "subagent" && !item.hidden,
|
||||||
|
),
|
||||||
|
)
|
||||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
|
|||||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { ServerSDK } from "./server-sdk"
|
import type { ServerSDK } from "./server-sdk"
|
||||||
import type { ServerSync } from "./server-sync"
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
@@ -109,7 +109,7 @@ function buildNotificationIndex(list: Notification[]) {
|
|||||||
return index
|
return index
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) {
|
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
@@ -197,14 +197,13 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookup = async (directory: string, sessionID?: string) => {
|
const lookup = async (sessionID?: string) => {
|
||||||
if (!sessionID) return undefined
|
if (!sessionID) return undefined
|
||||||
const sync = input.sync.ensureDirSyncContext(directory)
|
const session = input.data.session.get(sessionID)
|
||||||
const session = sync.session.get(sessionID)
|
|
||||||
if (session) return session
|
if (session) return session
|
||||||
return sync.session
|
return input.data.session
|
||||||
.sync(sessionID)
|
.sync(sessionID)
|
||||||
.then(() => sync.session.get(sessionID))
|
.then(() => input.data.session.get(sessionID))
|
||||||
.catch(() => undefined)
|
.catch(() => undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +218,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
|||||||
|
|
||||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
void lookup(directory, sessionID).then((session) => {
|
void lookup(sessionID).then((session) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (!session) return
|
if (!session) return
|
||||||
if (session.parentID) return
|
if (session.parentID) return
|
||||||
@@ -251,7 +250,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
|||||||
time: number,
|
time: number,
|
||||||
) => {
|
) => {
|
||||||
const sessionID = event.properties.sessionID
|
const sessionID = event.properties.sessionID
|
||||||
void lookup(directory, sessionID).then((session) => {
|
void lookup(sessionID).then((session) => {
|
||||||
if (meta.disposed) return
|
if (meta.disposed) return
|
||||||
if (session?.parentID) return
|
if (session?.parentID) return
|
||||||
|
|
||||||
@@ -278,7 +277,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" &&
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { PermissionRequest } from "@opencode-ai/client/promise"
|
|||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import type { ServerSDK } from "@/context/server-sdk"
|
import type { ServerSDK } from "@/context/server-sdk"
|
||||||
import type { ServerSync } from "./server-sync"
|
import type { ServerSync } from "./server-sync"
|
||||||
|
import type { Data } from "@opencode-ai/client/solid"
|
||||||
import { useParams, useSearchParams } from "@solidjs/router"
|
import { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { useGlobal } from "./global"
|
import { useGlobal } from "./global"
|
||||||
@@ -52,9 +53,9 @@ 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; data: Data }) {
|
||||||
const [store, setStore, _, ready] = persisted(
|
const [store, setStore, _, ready] = persisted(
|
||||||
{
|
{
|
||||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||||
@@ -141,10 +142,8 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessions(directory?: string) {
|
function sessions(_directory?: string) {
|
||||||
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
return input.data.session.list()
|
||||||
if (!directory) return info
|
|
||||||
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||||
@@ -160,15 +159,15 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isPending(permission: PermissionRequest) {
|
function isPending(permission: PermissionRequest) {
|
||||||
const pending = input.sync.session.data.permission[permission.sessionID]
|
const pending = input.data.session.permission.list(permission.sessionID)
|
||||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||||
if (override !== undefined) return override
|
if (override !== undefined) return override
|
||||||
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
if (input.data.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||||
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
const lineage = await input.data.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||||
if (meta.disposed || !lineage) return false
|
if (meta.disposed || !lineage) return false
|
||||||
return shouldAutoRespond(permission, directory)
|
return shouldAutoRespond(permission, directory)
|
||||||
}
|
}
|
||||||
@@ -197,7 +196,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
|
||||||
|
|||||||
@@ -4,20 +4,12 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
|||||||
import { batch, getOwner, onCleanup, untrack } from "solid-js"
|
import { batch, getOwner, onCleanup, untrack } from "solid-js"
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
import { createStore, reconcile } from "solid-js/store"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import type { InitError } from "../pages/error"
|
|
||||||
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
||||||
import {
|
import {
|
||||||
bootstrapDirectory,
|
bootstrapDirectory,
|
||||||
bootstrapGlobal,
|
bootstrapGlobal,
|
||||||
clearProviderRev,
|
|
||||||
loadAgentsQuery,
|
|
||||||
loadCommands,
|
|
||||||
loadGlobalConfigQuery,
|
loadGlobalConfigQuery,
|
||||||
loadIntegrationsQuery,
|
|
||||||
loadPathQuery,
|
loadPathQuery,
|
||||||
loadProjectsQuery,
|
|
||||||
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"
|
||||||
@@ -32,7 +24,6 @@ import { createRefreshQueue } from "./global-sync/queue"
|
|||||||
import { directoryKey } from "./global-sync/utils"
|
import { directoryKey } from "./global-sync/utils"
|
||||||
import { pathKey, PathKey } from "@/utils/path-key"
|
import { pathKey, PathKey } from "@/utils/path-key"
|
||||||
import { createDirSyncContext } from "./directory-sync"
|
import { createDirSyncContext } from "./directory-sync"
|
||||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
|
||||||
import { createRefCountMap } from "@/utils/refcount"
|
import { createRefCountMap } from "@/utils/refcount"
|
||||||
import { ServerConnection } from "./servers"
|
import { ServerConnection } from "./servers"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
@@ -40,21 +31,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
|
||||||
@@ -68,11 +53,8 @@ export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type GlobalStore = {
|
type GlobalStore = {
|
||||||
ready: boolean
|
|
||||||
error?: InitError
|
|
||||||
path: Path
|
path: Path
|
||||||
project: Project[]
|
project: Project[]
|
||||||
provider: NormalizedProviderListResponse
|
|
||||||
provider_auth: ProviderAuthResponse
|
provider_auth: ProviderAuthResponse
|
||||||
config: Config
|
config: Config
|
||||||
reload: undefined | "pending" | "complete"
|
reload: undefined | "pending" | "complete"
|
||||||
@@ -88,16 +70,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 +79,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 +125,14 @@ 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),
|
|
||||||
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()
|
||||||
@@ -222,22 +146,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||||
const connected = () => serverSDK.connection.status() === "connected"
|
const connected = () => serverSDK.connection.status() === "connected"
|
||||||
const hydrateSessionState = async (sessionID: string) => {
|
const hydrateSession = (sessionID: string) => session.sync(sessionID)
|
||||||
await session.hydrateTransient(sessionID, async () => {
|
|
||||||
const [pending, forms] = await Promise.all([
|
|
||||||
serverSDK.api.session.inbox.list({ sessionID }),
|
|
||||||
serverSDK.api.form.list({ sessionID }),
|
|
||||||
])
|
|
||||||
return { pending, forms }
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
|
||||||
|
|
||||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
const [configQuery, pathQuery] = useQueries(() => ({
|
||||||
queries: [
|
queries: [
|
||||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
||||||
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
{ ...queryOptionsApi.path(), enabled: connected() },
|
||||||
{ ...queryOptionsApi.path(null), enabled: connected() },
|
|
||||||
],
|
],
|
||||||
}))
|
}))
|
||||||
const activeSessionsQuery = useQuery(() => ({
|
const activeSessionsQuery = useQuery(() => ({
|
||||||
@@ -246,7 +160,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const active = await serverSDK.api.session.active()
|
const active = await serverSDK.api.session.active()
|
||||||
reconcileActiveSessionStatuses(session, active)
|
reconcileActiveSessionStatuses(session, active)
|
||||||
Object.keys(active).forEach((sessionID) => {
|
Object.keys(active).forEach((sessionID) => {
|
||||||
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
|
void session.resolve(sessionID).catch(() => undefined)
|
||||||
})
|
})
|
||||||
return active
|
return active
|
||||||
},
|
},
|
||||||
@@ -255,9 +169,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||||
get ready() {
|
|
||||||
return bootstrap.isSuccess
|
|
||||||
},
|
|
||||||
project: [],
|
project: [],
|
||||||
provider_auth: {},
|
provider_auth: {},
|
||||||
get path() {
|
get path() {
|
||||||
@@ -265,11 +176,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
if (pathQuery.isLoading) return EMPTY
|
if (pathQuery.isLoading) return EMPTY
|
||||||
return pathQuery.data ?? EMPTY
|
return pathQuery.data ?? EMPTY
|
||||||
},
|
},
|
||||||
get provider() {
|
|
||||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
|
||||||
if (providerQuery.isLoading) return EMPTY
|
|
||||||
return providerQuery.data ?? EMPTY
|
|
||||||
},
|
|
||||||
get config() {
|
get config() {
|
||||||
if (configQuery.isLoading) return {}
|
if (configQuery.isLoading) return {}
|
||||||
return configQuery.data ?? {}
|
return configQuery.data ?? {}
|
||||||
@@ -338,10 +244,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) }),
|
||||||
@@ -353,36 +261,16 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
const key = directoryKey(directory)
|
const key = directoryKey(directory)
|
||||||
queue.clear(key)
|
queue.clear(key)
|
||||||
sessionMeta.delete(key)
|
sessionMeta.delete(key)
|
||||||
clearProviderRev(serverSDK.scope, key)
|
|
||||||
},
|
},
|
||||||
translate: language.t,
|
translate: language.t,
|
||||||
queryOptions: queryOptionsApi,
|
queryOptions: queryOptionsApi,
|
||||||
|
data,
|
||||||
global: {
|
global: {
|
||||||
get provider() {
|
get path() {
|
||||||
return globalStore.provider
|
return globalStore.path
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
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: () => {
|
||||||
@@ -393,14 +281,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
connected: (info) => {
|
connected: (info) => {
|
||||||
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
|
if (info.reconnect) void session.refreshPinned(() => Promise.resolve()).catch(() => undefined)
|
||||||
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
||||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||||
Object.keys(children.children)
|
Object.keys(children.children)
|
||||||
.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 +386,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,
|
||||||
@@ -508,7 +396,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
config: globalStore.config,
|
config: globalStore.config,
|
||||||
path: globalStore.path,
|
path: globalStore.path,
|
||||||
project: globalStore.project,
|
project: globalStore.project,
|
||||||
provider: globalStore.provider,
|
|
||||||
},
|
},
|
||||||
api: serverSDK.api,
|
api: serverSDK.api,
|
||||||
store: child[0],
|
store: child[0],
|
||||||
@@ -518,7 +405,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
queryClient,
|
queryClient,
|
||||||
session,
|
session,
|
||||||
}),
|
}),
|
||||||
initial ? refreshVcs(directory) : Promise.resolve(),
|
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -568,7 +454,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,7 +514,6 @@ 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 (directory === "global") {
|
if (directory === "global") {
|
||||||
@@ -661,21 +546,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 +568,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)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -710,7 +584,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const projectApi = {
|
const projectApi = {
|
||||||
loadSessions,
|
|
||||||
meta(directory: string, patch: ProjectMeta) {
|
meta(directory: string, patch: ProjectMeta) {
|
||||||
children.projectMeta(directory, patch)
|
children.projectMeta(directory, patch)
|
||||||
},
|
},
|
||||||
@@ -727,29 +600,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
bootstrap.refetch()
|
bootstrap.refetch()
|
||||||
// Invalidate all provider queries so newly configured custom providers
|
data.location.provider.invalidate()
|
||||||
// appear immediately in the available provider list across all directories.
|
data.location.model.invalidate()
|
||||||
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
|
data.location.model.invalidateDefault()
|
||||||
queryClient.invalidateQueries({
|
void Promise.all([
|
||||||
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
data.location.provider.sync(),
|
||||||
})
|
data.location.model.sync(),
|
||||||
|
data.location.model.syncDefault(),
|
||||||
|
])
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: globalStore,
|
data: globalStore,
|
||||||
set,
|
set,
|
||||||
get ready() {
|
|
||||||
return globalStore.ready
|
|
||||||
},
|
|
||||||
get error() {
|
|
||||||
return globalStore.error
|
|
||||||
},
|
|
||||||
child: children.child,
|
child: children.child,
|
||||||
peek: children.peek,
|
|
||||||
disableMcp: children.disableMcp,
|
disableMcp: children.disableMcp,
|
||||||
queryOptions: queryOptionsApi,
|
|
||||||
refreshProviders: catalog.refreshActive,
|
|
||||||
// bootstrap,
|
// bootstrap,
|
||||||
updateConfig: updateConfigMutation.mutateAsync,
|
updateConfig: updateConfigMutation.mutateAsync,
|
||||||
project: projectApi,
|
project: projectApi,
|
||||||
@@ -788,8 +654,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 +667,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),
|
||||||
@@ -814,8 +684,3 @@ export const useServerSync = () => {
|
|||||||
const server = useServer()
|
const server = useServer()
|
||||||
return server.ctx.sync
|
return server.ctx.sync
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useQueryOptions() {
|
|
||||||
const sync = useServerSync()
|
|
||||||
return sync.queryOptions
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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) ?? [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useQueryOptions } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { normalizeProviderList } from "@/context/global-sync/utils"
|
||||||
import { Iterable, pipe } from "effect"
|
import { Iterable, pipe } from "effect"
|
||||||
import { type Accessor } from "solid-js"
|
import { createEffect, createMemo, type Accessor } from "solid-js"
|
||||||
import { emptyProviderCatalog } from "./provider-catalog"
|
import { emptyProviderCatalog } from "./provider-catalog"
|
||||||
import { useQuery } from "@tanstack/solid-query"
|
|
||||||
import { pathKey } from "@/utils/path-key"
|
|
||||||
|
|
||||||
export const popularProviders = [
|
export const popularProviders = [
|
||||||
"opencode",
|
"opencode",
|
||||||
@@ -18,16 +18,41 @@ export const popularProviders = [
|
|||||||
const popularProviderSet = new Set(popularProviders)
|
const popularProviderSet = new Set(popularProviders)
|
||||||
|
|
||||||
export function useProviders(directory: Accessor<string | undefined>) {
|
export function useProviders(directory: Accessor<string | undefined>) {
|
||||||
const providersQuery = useQuery(() => {
|
const data = useData()
|
||||||
const queryOpts = useQueryOptions()
|
const sdk = useServerSDK()
|
||||||
|
const location = () => {
|
||||||
const dir = directory()
|
const dir = directory()
|
||||||
return queryOpts.providers(dir ? pathKey(dir) : null)
|
return dir ? { directory: dir } : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (sdk.connection.status() !== "connected") return
|
||||||
|
const ref = location()
|
||||||
|
void Promise.all([
|
||||||
|
data.location.provider.sync(ref),
|
||||||
|
data.location.model.sync(ref),
|
||||||
|
data.location.model.syncDefault(ref),
|
||||||
|
]).catch(() => undefined)
|
||||||
})
|
})
|
||||||
|
|
||||||
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
const providers = createMemo(() => {
|
||||||
|
const ref = location()
|
||||||
|
const provider = data.location.provider.list(ref)
|
||||||
|
const model = data.location.model.list(ref)
|
||||||
|
const defaultModel = data.location.model.default(ref)
|
||||||
|
if (!provider || !model || defaultModel === undefined) return emptyProviderCatalog
|
||||||
|
return normalizeProviderList(provider, model, defaultModel)
|
||||||
|
})
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => providersQuery.isSuccess,
|
ready: () => {
|
||||||
|
const ref = location()
|
||||||
|
return (
|
||||||
|
data.location.provider.list(ref) !== undefined &&
|
||||||
|
data.location.model.list(ref) !== undefined &&
|
||||||
|
data.location.model.default(ref) !== undefined
|
||||||
|
)
|
||||||
|
},
|
||||||
all: () => providers().all,
|
all: () => providers().all,
|
||||||
default: () => providers().default,
|
default: () => providers().default,
|
||||||
popular: () =>
|
popular: () =>
|
||||||
|
|||||||
@@ -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()))
|
||||||
@@ -33,8 +37,7 @@ export function DirectoryDataProvider(
|
|||||||
return `/${slug()}/session/${sessionID}`
|
return `/${slug()}/session/${sessionID}`
|
||||||
}
|
}
|
||||||
const navigateToSession = async (sessionID: string) => {
|
const navigateToSession = async (sessionID: string) => {
|
||||||
const session = serverSync.session
|
await data.session.lineage.resolve(sessionID).catch(() => undefined)
|
||||||
await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)])
|
|
||||||
navigate(href(sessionID))
|
navigate(href(sessionID))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +55,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
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||||
if (prefetched.has(key)) return
|
if (prefetched.has(key)) return
|
||||||
prefetched.add(key)
|
prefetched.add(key)
|
||||||
void untrack(() => ctx.sync.session.sync(record.session.id)).catch(() => {})
|
void untrack(() => ctx.data.session.sync(record.session.id)).catch(() => {})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ export function createHomeSessionsController(home: HomeController) {
|
|||||||
const directory = project?.worktree ?? session.location.directory
|
const directory = project?.worktree ?? session.location.directory
|
||||||
const ctx = home.server.focusedContext()
|
const ctx = home.server.focusedContext()
|
||||||
if (!ctx) return
|
if (!ctx) return
|
||||||
ctx.sync.session.remember(session)
|
ctx.data.session.remember(session)
|
||||||
ctx.projects.open(directory)
|
ctx.projects.open(directory)
|
||||||
if (options?.background) {
|
if (options?.background) {
|
||||||
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||||
|
|||||||
@@ -11,32 +11,28 @@ export function useSessionTabAvatarState(
|
|||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||||
const serverCtx = useServerCtx(connection)
|
const serverCtx = useServerCtx(connection)
|
||||||
const sync = () => serverCtx()?.sync
|
|
||||||
const hasPermissions = createMemo(() => {
|
const hasPermissions = createMemo(() => {
|
||||||
const ctx = serverCtx()
|
const ctx = serverCtx()
|
||||||
if (!ctx) return false
|
if (!ctx) return false
|
||||||
const sync = ctx.sync
|
|
||||||
const permission = ctx.permission
|
const permission = ctx.permission
|
||||||
const [store] = sync.child(directory(), { bootstrap: false })
|
return !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
|
||||||
return !!sessionPermissionRequest(store.session, sync.session.data.permission, sessionId(), (item) => {
|
|
||||||
return !permission.autoResponds(item, directory())
|
return !permission.autoResponds(item, directory())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const hasQuestions = createMemo(() => {
|
const hasQuestions = createMemo(() => {
|
||||||
const serverSync = sync()
|
const data = serverCtx()?.data
|
||||||
if (!serverSync) return false
|
if (!data) return false
|
||||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId())
|
||||||
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
|
|
||||||
})
|
})
|
||||||
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
||||||
const unread = createMemo(
|
const unread = createMemo(
|
||||||
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
||||||
)
|
)
|
||||||
const loading = createMemo(() => {
|
const loading = createMemo(() => {
|
||||||
const serverSync = sync()
|
const data = serverCtx()?.data
|
||||||
if (!serverSync) return false
|
if (!data) return false
|
||||||
if (needsAttention()) return false
|
if (needsAttention()) return false
|
||||||
return serverSync.session.data.session_working(sessionId())
|
return data.session.status(sessionId()) === "running"
|
||||||
})
|
})
|
||||||
return { unread, loading }
|
return { unread, loading }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { useProviders } from "@/hooks/use-providers"
|
import { useProviders } from "@/hooks/use-providers"
|
||||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
@@ -107,7 +106,6 @@ function ProviderTip() {
|
|||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const serverSync = useServerSync()
|
|
||||||
const providers = useProviders(() => sdk().directory)
|
const providers = useProviders(() => sdk().directory)
|
||||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||||
Persist.global("new-session.provider-tip"),
|
Persist.global("new-session.provider-tip"),
|
||||||
@@ -115,7 +113,7 @@ function ProviderTip() {
|
|||||||
)
|
)
|
||||||
const visible = createMemo(
|
const visible = createMemo(
|
||||||
() =>
|
() =>
|
||||||
serverSync.child(sdk().directory)[0].provider_ready &&
|
providers.ready() &&
|
||||||
persistedReady() &&
|
persistedReady() &&
|
||||||
providers.paid().length === 0 &&
|
providers.paid().length === 0 &&
|
||||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createMemo } from "solid-js"
|
import { createEffect, createMemo } from "solid-js"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import {
|
import {
|
||||||
@@ -47,7 +47,7 @@ export function createNewSessionWorkspaceController(input: {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const visible = createMemo(() => sync().project?.vcs === "git")
|
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||||
const selected = createMemo(() => {
|
const selected = createMemo(() => {
|
||||||
@@ -74,12 +74,17 @@ export function createNewSessionWorkspaceController(input: {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||||
const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch)
|
createEffect(() => {
|
||||||
|
const project = sync().project
|
||||||
|
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
||||||
|
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||||
|
})
|
||||||
|
const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current)
|
||||||
const branch = createMemo(() =>
|
const branch = createMemo(() =>
|
||||||
resolveNewSessionBranch({
|
resolveNewSessionBranch({
|
||||||
worktree: value(),
|
worktree: value(),
|
||||||
local: localBranch(),
|
local: localBranch(),
|
||||||
worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch,
|
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
const remember = (worktree = value()) => {
|
const remember = (worktree = value()) => {
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ import { CommentsProvider, useComments } from "@/context/comments"
|
|||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { ModelsProvider } from "@/context/models"
|
import { ModelsProvider } from "@/context/models"
|
||||||
@@ -101,7 +102,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"
|
||||||
|
|
||||||
@@ -151,8 +152,8 @@ async function runPromptRollbackMutation<T, R>(input: {
|
|||||||
// workspace-scoped state (terminal, directory providers) lives below.
|
// workspace-scoped state (terminal, directory providers) lives below.
|
||||||
export function TargetSessionRouteContent() {
|
export function TargetSessionRouteContent() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory)
|
const directory = createMemo(() => data.session.lineage.peek(params.id)?.session.location.directory)
|
||||||
return (
|
return (
|
||||||
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
// Settings must keep the target-server SDK, sync, and models context and remain registered
|
||||||
// when session content falls back to the route error boundary.
|
// when session content falls back to the route error boundary.
|
||||||
@@ -238,11 +239,11 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
|
|||||||
function ResolvedTargetSessionRoute() {
|
function ResolvedTargetSessionRoute() {
|
||||||
const params = useParams<{ serverKey: string; id: string }>()
|
const params = useParams<{ serverKey: string; id: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const sync = useServerSync()
|
const data = useData()
|
||||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||||
const current = createSessionLineage(
|
const current = createSessionLineage(
|
||||||
() => params.id,
|
() => params.id,
|
||||||
() => sync.session.lineage,
|
() => data.session.lineage,
|
||||||
)
|
)
|
||||||
const directory = createMemo(() => current()?.session.location.directory)
|
const directory = createMemo(() => current()?.session.location.directory)
|
||||||
|
|
||||||
@@ -330,6 +331,7 @@ function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boo
|
|||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
const data = useData()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
@@ -384,7 +386,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(() =>
|
||||||
@@ -572,12 +573,17 @@ export default function Page() {
|
|||||||
const project = sync().project
|
const project = sync().project
|
||||||
return !!project && project.vcs !== "git"
|
return !!project && project.vcs !== "git"
|
||||||
})
|
})
|
||||||
|
const vcs = createMemo(() => data.location.vcs.info({ directory: sdk().directory }))
|
||||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||||
const list: ChangeMode[] = []
|
const list: ChangeMode[] = []
|
||||||
const project = sync().project
|
const project = sync().project
|
||||||
const vcs = sync().data.vcs
|
|
||||||
if (project?.vcs === "git") list.push("git")
|
if (project?.vcs === "git") list.push("git")
|
||||||
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
|
if (
|
||||||
|
project?.vcs === "git" &&
|
||||||
|
vcs()?.branch.current &&
|
||||||
|
vcs()?.branch.default &&
|
||||||
|
vcs()?.branch.current !== vcs()?.branch.default
|
||||||
|
) {
|
||||||
list.push("branch")
|
list.push("branch")
|
||||||
}
|
}
|
||||||
list.push("turn")
|
list.push("turn")
|
||||||
@@ -601,8 +607,8 @@ export default function Page() {
|
|||||||
serverSDK.scope,
|
serverSDK.scope,
|
||||||
"session-vcs",
|
"session-vcs",
|
||||||
sdk().directory,
|
sdk().directory,
|
||||||
sync().data.vcs?.branch ?? "",
|
vcs()?.branch.current ?? "",
|
||||||
sync().data.vcs?.default_branch ?? "",
|
vcs()?.branch.default ?? "",
|
||||||
] as const,
|
] as const,
|
||||||
)
|
)
|
||||||
const vcsQuery = createQuery(() => {
|
const vcsQuery = createQuery(() => {
|
||||||
@@ -849,7 +855,7 @@ export default function Page() {
|
|||||||
return [
|
return [
|
||||||
sdk().directory,
|
sdk().directory,
|
||||||
id,
|
id,
|
||||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
id ? data.session.status(id) : "idle",
|
||||||
id ? composer.blocked() : false,
|
id ? composer.blocked() : false,
|
||||||
] as const
|
] as const
|
||||||
},
|
},
|
||||||
@@ -1034,7 +1040,7 @@ export default function Page() {
|
|||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type,
|
() => data.session.status(controller.identity.params.id ?? ""),
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||||
refreshVcs()
|
refreshVcs()
|
||||||
@@ -1646,7 +1652,7 @@ export default function Page() {
|
|||||||
target.session.remember({ ...session, revert: next })
|
target.session.remember({ ...session, revert: next })
|
||||||
}
|
}
|
||||||
|
|
||||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
const busy = (sessionID: string) => data.session.status(sessionID) === "running"
|
||||||
const queuedFollowups = createMemo(() => {
|
const queuedFollowups = createMemo(() => {
|
||||||
const id = controller.identity.params.id
|
const id = controller.identity.params.id
|
||||||
if (!id) return emptyFollowups
|
if (!id) return emptyFollowups
|
||||||
@@ -1677,8 +1683,8 @@ export default function Page() {
|
|||||||
const ok = await sendFollowupDraft({
|
const ok = await sendFollowupDraft({
|
||||||
api: sdk().api.session,
|
api: sdk().api.session,
|
||||||
sync: sync(),
|
sync: sync(),
|
||||||
serverSync: serverSync,
|
data,
|
||||||
session: () => sync().session.get(input.sessionID),
|
session: () => data.session.get(input.sessionID),
|
||||||
draft: item,
|
draft: item,
|
||||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
@@ -1743,7 +1749,7 @@ export default function Page() {
|
|||||||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||||
|
|
||||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
if (data.session.get(sessionID)?.parentID) return Promise.resolve()
|
||||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||||
if (!item) return Promise.resolve()
|
if (!item) return Promise.resolve()
|
||||||
if (followupBusy(sessionID)) return Promise.resolve()
|
if (followupBusy(sessionID)) return Promise.resolve()
|
||||||
@@ -2128,9 +2134,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,43 +7,40 @@ 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 { 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"
|
||||||
|
import { normalizeAgentList } from "@/context/global-sync/utils"
|
||||||
|
|
||||||
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 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 {
|
||||||
agents: {
|
agents: {
|
||||||
available: sync().data.agent,
|
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
|
||||||
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(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { type Accessor, createEffect, createMemo, createResource, onCleanup } fr
|
|||||||
import { createStore } from "solid-js/store"
|
import { createStore } from "solid-js/store"
|
||||||
import type { PromptInputState } from "@/components/prompt-input"
|
import type { PromptInputState } from "@/components/prompt-input"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||||
import type { SessionComposerController } from "./session-composer-state"
|
import type { SessionComposerController } from "./session-composer-state"
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ export function createSessionComposerRegionController(input: {
|
|||||||
setDockRef: (el: HTMLDivElement) => void
|
setDockRef: (el: HTMLDivElement) => void
|
||||||
}) {
|
}) {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const [store, setStore] = createStore({
|
const [store, setStore] = createStore({
|
||||||
ready: input.ready() || input.state.dock(),
|
ready: input.ready() || input.state.dock(),
|
||||||
height: 320,
|
height: 320,
|
||||||
@@ -104,7 +106,7 @@ export function createSessionComposerRegionController(input: {
|
|||||||
|
|
||||||
const parentID = createMemo(() => {
|
const parentID = createMemo(() => {
|
||||||
const id = input.sessionID()
|
const id = input.sessionID()
|
||||||
return id ? sync().session.get(id)?.parentID : undefined
|
return id ? data.session.get(id)?.parentID : undefined
|
||||||
})
|
})
|
||||||
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||||
const progress = useSpring(
|
const progress = useSpring(
|
||||||
|
|||||||
@@ -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,36 +34,20 @@ 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(data.session.list(), data.session.form.list, params.id)
|
||||||
})
|
})
|
||||||
|
|
||||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||||
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
|
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
|
||||||
return !permission.autoResponds(item, sdk().directory)
|
return !permission.autoResponds(item, sdk().directory)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -84,16 +68,16 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||||
)
|
)
|
||||||
|
|
||||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
const live = createMemo(() => data.session.status(params.id ?? "") === "running" || blocked())
|
||||||
const primary = () => {
|
const primary = () => {
|
||||||
const id = params.id
|
const id = params.id
|
||||||
return !!id && !serverSync.session.get(id)?.parentID
|
return !!id && !data.session.get(id)?.parentID
|
||||||
}
|
}
|
||||||
const backgroundBlocking = createMemo(() => {
|
const backgroundBlocking = createMemo(() => {
|
||||||
if (!primary()) return []
|
if (!primary()) return []
|
||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
const assistant = (serverSync.session.data.session_message[id] ?? []).findLast(
|
const assistant = data.session.message.list(id).findLast(
|
||||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||||
)
|
)
|
||||||
if (assistant?.type !== "assistant") return []
|
if (assistant?.type !== "assistant") return []
|
||||||
@@ -116,7 +100,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
const id = params.id
|
const id = params.id
|
||||||
if (!id) return []
|
if (!id) return []
|
||||||
const blocking = backgroundBlocking()
|
const blocking = backgroundBlocking()
|
||||||
const messages = serverSync.session.data.session_message[id] ?? []
|
const messages = data.session.message.list(id)
|
||||||
const completed = new Set(
|
const completed = new Set(
|
||||||
messages.flatMap((message) => {
|
messages.flatMap((message) => {
|
||||||
if (message.type !== "synthetic") return []
|
if (message.type !== "synthetic") return []
|
||||||
@@ -144,9 +128,9 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||||||
]
|
]
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
const active = Object.values(serverSync.session.data.info).flatMap((info) => {
|
const active = data.session.list().flatMap((info) => {
|
||||||
if (info?.parentID !== id) return []
|
if (info?.parentID !== id) return []
|
||||||
if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
|
if (data.session.status(info.id) === "idle") return []
|
||||||
if (
|
if (
|
||||||
blocking.some(
|
blocking.some(
|
||||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/clie
|
|||||||
|
|
||||||
function sessionTreeRequest<T>(
|
function sessionTreeRequest<T>(
|
||||||
session: SessionInfo[],
|
session: SessionInfo[],
|
||||||
request: Record<string, T[] | undefined>,
|
request: Record<string, T[] | undefined> | ((sessionID: string) => T[] | undefined),
|
||||||
sessionID?: string,
|
sessionID?: string,
|
||||||
include: (item: T) => boolean = () => true,
|
include: (item: T) => boolean = () => true,
|
||||||
) {
|
) {
|
||||||
@@ -28,14 +28,15 @@ function sessionTreeRequest<T>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = ids.find((id) => request[id]?.some(include))
|
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
|
||||||
|
const id = ids.find((id) => list(id)?.some(include))
|
||||||
if (!id) return
|
if (!id) return
|
||||||
return request[id]?.find(include)
|
return list(id)?.find(include)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sessionPermissionRequest(
|
export function sessionPermissionRequest(
|
||||||
session: SessionInfo[],
|
session: SessionInfo[],
|
||||||
request: Record<string, PermissionRequest[] | undefined>,
|
request: Record<string, PermissionRequest[] | undefined> | ((sessionID: string) => PermissionRequest[] | undefined),
|
||||||
sessionID?: string,
|
sessionID?: string,
|
||||||
include?: (item: PermissionRequest) => boolean,
|
include?: (item: PermissionRequest) => boolean,
|
||||||
) {
|
) {
|
||||||
@@ -44,7 +45,7 @@ export function sessionPermissionRequest(
|
|||||||
|
|
||||||
export function sessionQuestionForm(
|
export function sessionQuestionForm(
|
||||||
session: SessionInfo[],
|
session: SessionInfo[],
|
||||||
request: Record<string, FormInfo[] | undefined>,
|
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
|
||||||
sessionID?: string,
|
sessionID?: string,
|
||||||
) {
|
) {
|
||||||
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
|
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Message, UserMessage } from "@/types"
|
|||||||
import { createMemo, type Accessor } from "solid-js"
|
import { createMemo, type Accessor } from "solid-js"
|
||||||
import { useFile } from "@/context/file"
|
import { useFile } from "@/context/file"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { same } from "@/utils/same"
|
import { same } from "@/utils/same"
|
||||||
import { createSessionTabs } from "./helpers"
|
import { createSessionTabs } from "./helpers"
|
||||||
import {
|
import {
|
||||||
@@ -24,16 +25,17 @@ export function createSessionController(input: {
|
|||||||
}) {
|
}) {
|
||||||
const file = useFile()
|
const file = useFile()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const layout = useSessionLayout()
|
const layout = useSessionLayout()
|
||||||
const sessionID = createMemo(() => layout.params.id)
|
const sessionID = createMemo(() => layout.params.id)
|
||||||
const info = createMemo(() => {
|
const info = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
return id ? sync().session.get(id) : undefined
|
return id ? data.session.get(id) : undefined
|
||||||
})
|
})
|
||||||
const parentID = createMemo(() => info()?.parentID)
|
const parentID = createMemo(() => info()?.parentID)
|
||||||
const parent = createMemo(() => {
|
const parent = createMemo(() => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
return id ? sync().session.get(id) : undefined
|
return id ? data.session.get(id) : undefined
|
||||||
})
|
})
|
||||||
const status = createMemo(() => {
|
const status = createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
@@ -75,7 +77,7 @@ export function createSessionController(input: {
|
|||||||
status,
|
status,
|
||||||
working: createMemo(() => {
|
working: createMemo(() => {
|
||||||
const id = sessionID()
|
const id = sessionID()
|
||||||
return id ? sync().data.session_working(id) : false
|
return id ? data.session.status(id) === "running" : false
|
||||||
}),
|
}),
|
||||||
revertMessageID,
|
revertMessageID,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,8 +48,10 @@ 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 data = server.ctx.data
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
@@ -60,7 +64,7 @@ export function createTimelineController(input: {
|
|||||||
const boundary = input.session.history
|
const boundary = input.session.history
|
||||||
.messages()
|
.messages()
|
||||||
.find((message) => message.role === "user" && !visible.has(message.id))?.id
|
.find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||||
const projected = sync().data.session_message[id] ?? []
|
const projected = data.session.message.list(id)
|
||||||
if (!boundary) return projected
|
if (!boundary) return projected
|
||||||
const index = projected.findIndex((message) => message.id === boundary)
|
const index = projected.findIndex((message) => message.id === boundary)
|
||||||
return index < 0 ? projected : projected.slice(0, index)
|
return index < 0 ? projected : projected.slice(0, index)
|
||||||
@@ -146,19 +150,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 {
|
||||||
@@ -180,7 +187,7 @@ export function createTimelineController(input: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const remove = async (id: string) => {
|
const remove = async (id: string) => {
|
||||||
const session = sync().session.get(id)
|
const session = data.session.get(id)
|
||||||
if (!session) return false
|
if (!session) return false
|
||||||
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
|
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
|
||||||
const index = sessions.findIndex((item) => item.id === id)
|
const index = sessions.findIndex((item) => item.id === id)
|
||||||
@@ -203,7 +210,7 @@ export function createTimelineController(input: {
|
|||||||
|
|
||||||
function DeleteDialog(props: { sessionID: string }) {
|
function DeleteDialog(props: { sessionID: string }) {
|
||||||
const name = createMemo(
|
const name = createMemo(
|
||||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||||
)
|
)
|
||||||
const confirm = async () => {
|
const confirm = async () => {
|
||||||
await remove(props.sessionID)
|
await remove(props.sessionID)
|
||||||
@@ -254,7 +261,7 @@ export function createTimelineController(input: {
|
|||||||
() => [input.session.data.parentID(), childTaskDescription()] as const,
|
() => [input.session.data.parentID(), childTaskDescription()] as const,
|
||||||
([id, description]) => {
|
([id, description]) => {
|
||||||
if (!id || description || sync().data.message[id] !== undefined) return
|
if (!id || description || sync().data.message[id] !== undefined) return
|
||||||
void sync().session.sync(id)
|
void Promise.all([data.session.sync(id), sync().session.sync(id)])
|
||||||
},
|
},
|
||||||
{ defer: true },
|
{ defer: true },
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
|||||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||||
import { useLanguage } from "@/context/language"
|
import { useLanguage } from "@/context/language"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useData } from "@/context/server"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { scheduleConnectedMeasure } from "./measure"
|
import { scheduleConnectedMeasure } from "./measure"
|
||||||
@@ -414,7 +414,7 @@ function MessageTimelineView(
|
|||||||
) {
|
) {
|
||||||
let touchGesture: number | undefined
|
let touchGesture: number | undefined
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const serverSync = useServerSync()
|
const data = useData()
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
||||||
@@ -438,6 +438,11 @@ function MessageTimelineView(
|
|||||||
const projection = props.data.projection
|
const projection = props.data.projection
|
||||||
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
|
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
|
||||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||||
|
createEffect(() => {
|
||||||
|
const directory = sync().project?.worktree
|
||||||
|
if (!directory) return
|
||||||
|
void data.location.vcs.sync({ directory }).catch(() => undefined)
|
||||||
|
})
|
||||||
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||||
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||||
const setSummary = (open: boolean) => {
|
const setSummary = (open: boolean) => {
|
||||||
@@ -1468,8 +1473,8 @@ function MessageTimelineView(
|
|||||||
project={project()}
|
project={project()}
|
||||||
directory={sessionDirectory()}
|
directory={sessionDirectory()}
|
||||||
local={!workspaceSession()}
|
local={!workspaceSession()}
|
||||||
branch={sync().data.vcs?.branch}
|
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||||
baseBranch={serverSync.child(project().worktree)[0].vcs?.branch}
|
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||||
diffs={sessionDiffs()}
|
diffs={sessionDiffs()}
|
||||||
sessionID={id}
|
sessionID={id}
|
||||||
moveEligible={props.workspaceMoveEligible}
|
moveEligible={props.workspaceMoveEligible}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { usePrompt } from "@/context/prompt"
|
|||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
|
import { useData } from "@/context/server"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||||
@@ -54,6 +55,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
|
const data = useData()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -304,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
|||||||
if (!message) return
|
if (!message) return
|
||||||
const parts = sync().data.part[message.id]
|
const parts = sync().data.part[message.id]
|
||||||
|
|
||||||
if (sync().data.session_working(sessionID)) {
|
if (data.session.status(sessionID) === "running") {
|
||||||
await session.interrupt({ sessionID }).catch(() => {})
|
await session.interrupt({ sessionID }).catch(() => {})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user