mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-14 21:56:54 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20a953b29c |
@@ -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
|
||||
---
|
||||
|
||||
Add an opt-in portable shell permission scanner. Opaque commands use normal shell authorization without inferring
|
||||
external directories, while the default tree-sitter path remains unchanged.
|
||||
Replace Core shell permission parsing with portable, fail-closed Bash and PowerShell scanners.
|
||||
|
||||
@@ -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
|
||||
timeout-minutes: 20
|
||||
run: GITHUB_ACTIONS=false bun turbo test
|
||||
run: GITHUB_ACTIONS=false bun turbo test ${{ runner.os == 'Windows' && '--filter=!opencode-drive' || '' }}
|
||||
env:
|
||||
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
|
||||
if: always()
|
||||
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.
|
||||
- Prefer `dev:live` over plain `bun run dev` for this workflow. An implicit managed-service connection may replace the live server when the worktree client version differs; explicit `--server` warns and continues without replacing it.
|
||||
|
||||
## V2 TUI Stories
|
||||
|
||||
- When a user asks for a TUI story, add a fixture-driven story under `packages/tui/src/feature-plugins/system/storybook` and register it in `index.tsx`.
|
||||
- Render the real production component rather than a visual copy. Keep submissions and other side effects local to the story so it is safe to explore repeatedly.
|
||||
- Expose the meaningful state dimensions through story keybindings and list them in `StoryFooter`; include a reset command when combinations can leave the fixture in a confusing state.
|
||||
- Run a specific story with `OPENCODE_STORY=<story-id> bun run dev:live` from the development worktree, and exercise narrow and wide terminal sizes when layout is relevant.
|
||||
|
||||
## TUI Theme Tokens
|
||||
|
||||
- Choose theme tokens by semantic role, not by their current color. Do not use raw `theme.hue` values or borrow an unrelated semantic token to achieve a preferred appearance.
|
||||
- Use `text.feedback` and `background.feedback` only for outcome or status feedback such as errors, warnings, success messages, and informational messages. Use `formfield` states for form-control text, ordinals, and selection markers, and `action` states for actions.
|
||||
- If the theme does not expose a token for the required semantic role, extend the theme schema, defaults, resolution, and types with that role before using it in a component. Do not repurpose the nearest-looking existing token.
|
||||
- When changing the public theme token surface, verify the built-in light and dark defaults and the custom-theme fallback path in addition to the affected TUI component.
|
||||
|
||||
## Branch Names
|
||||
|
||||
Use a short branch name of at most three words, separated by hyphens. Do not use slashes or type prefixes such as `feat/` or `fix/`.
|
||||
@@ -175,14 +161,14 @@ const table = sqliteTable("session", {
|
||||
## V2 Session Core
|
||||
|
||||
- Keep durable events minimal: record irreducible new facts and do not repeat state derivable by folding the ordered aggregate history. Enrich projections and read models with previous or derived state when consumers need self-contained views.
|
||||
- Keep durable prompt admission separate from model execution. `Session.prompt(...)` publishes `session.inbox.enqueued`, whose projection inserts one durable `session_inbox` row, before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. Delivery publishes `session.inbox.delivered`; its projection consumes the inbox row and inserts the visible message in the same transaction. `session_inbox` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. While a user or synthetic inbox item is pending, reusing its ID reconciles only when Session, type, complete payload, metadata, and delivery match; conflicting reuse fails. Once delivered, retry reconciliation for those message-producing items uses the projected message and does not require retained enqueue history or the original delivery mode. Control items keep their operation-specific conflict behavior.
|
||||
- Keep durable prompt admission separate from model execution. `SessionV2.prompt(...)` admits one durable `session_pending` row before scheduling advisory `SessionExecution.wake(sessionID)` unless `resume: false` requests admit-only behavior. The serialized runner promotes admitted inputs into visible user messages at safe boundaries, consuming the pending row in the same event transaction; `session_pending` stores only unconsumed work.
|
||||
- Reusing a Session ID adopts the existing Session. Reusing a prompt message ID reconciles an exact retry only when Session, prompt, and delivery mode match; conflicting reuse fails. Retry of an already-promoted input reconciles against the projected message and the durable admitted event rather than a retained row.
|
||||
- Keep `SessionExecution` process-global and Session-ID based. Its local implementation owns the process-local Session coordinator and discovers placement through `SessionStore` plus `LocationServiceMap.get(session.location)` only when a drain starts; no layer should take a Session ID. V2 interruption targets the active process-local ownership chain for that Session; interruption of a known but idle or locally unowned Session is a no-op, while the public API rejects an unknown Session.
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. Steers deliver in enqueue order at safe step boundaries, stopping before compaction or move control items. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. Most Steps have one Physical Attempt; overflow-triggered compaction recovery may rebuild one Step for a second attempt. Do not bridge through legacy `SessionPrompt.loop(...)` or delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. Advisory wakes drain eligible durable inbox rows only; post-crash continuation recovery requires a separate explicit design before it may retry provider work. A drain has no durable identity or transcript boundary.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default and promote at the next safe step boundary while the current drain requires continuation. An explicit `queue` input remains pending until the Session would otherwise become idle; promote one queued input at that boundary, then reevaluate continuation before promoting another. Promoting any new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
- Keep EventV2 replay owner claims separate from clustered Session execution ownership.
|
||||
- Keep the Instructions algebra and built-ins in `src/instructions`; keep instruction producers with their observed domains, and keep Session History selection plus `InstructionState` and `InstructionEntry` persistence Session-owned. `InstructionDiscovery` observes ambient global and upward-project instructions. The runner composes built-ins, discovery, guidance, and entries explicitly in `loadInstructions`; there is no instruction registry.
|
||||
- `session.instructions.updated` stores changed source keys and content hashes and may freeze rendered chronological update text. Blob values live once in `instruction_blob`; the projected `instruction_state` row is the normal boundary-processing source of current and initial values. Request assembly renders the epoch baseline from stored values, while later frozen updates enter history as durable System messages. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Forks adopt the parent's newest instruction values even when copied message history ends at an earlier boundary. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
- `session.instructions.updated` stores only changed source keys and content hashes. Blob values live once in `instruction_blob`; `instruction_state` is a rebuildable fold cache, never primary state. Render initial instructions and chronological updates from values during request assembly. Completed compaction moves the instruction epoch; Session movement retains it so destination instruction changes are chronological, while committed revert clears it. Unavailable sources retain the last value and block only the initial complete delta.
|
||||
|
||||
@@ -27,9 +27,8 @@ exits before expensive server boot. The design does not require clients to
|
||||
agree on a single initiator.
|
||||
|
||||
This proposal does not introduce a supervisor process, warm candidate server,
|
||||
protocol negotiation, idle background restart, or clustered or exactly-once
|
||||
execution recovery. Session execution separately provides bounded local recovery
|
||||
through durable write-ahead claims.
|
||||
protocol negotiation, idle background restart, or general execution-recovery
|
||||
framework.
|
||||
|
||||
## Architecture at a Glance
|
||||
|
||||
@@ -177,9 +176,9 @@ This design gives each concept one authority.
|
||||
- Adding a permanent steward, proxy, or supervisor process.
|
||||
- Zero-downtime worker handoff or automatic rollback.
|
||||
- Application protocol negotiation or automatic TUI self-restart.
|
||||
- Exactly-once recovery for provider attempts, tools, shells, sub-agents,
|
||||
permissions, questions, or background jobs. Top-level Session continuation
|
||||
after process death is handled separately through durable execution claims.
|
||||
- General hard-crash recovery for active Sessions.
|
||||
- Defining recovery semantics for provider attempts, tools, shells, sub-agents,
|
||||
permissions, questions, or background jobs.
|
||||
- Automatically killing a frozen owner.
|
||||
- Bounding concurrent location cold boots after clients reconnect.
|
||||
- Multi-machine or clustered service placement.
|
||||
@@ -203,10 +202,9 @@ This design gives each concept one authority.
|
||||
diagnosed, non-retryable cause.
|
||||
8. **Clients do not kill an unresponsive owner automatically.** Destructive
|
||||
recovery requires the explicit `service restart` command.
|
||||
9. **Lifecycle does not promise exactly-once execution.** A successor invokes
|
||||
the Session execution-claim sweep, which resumes from durable history.
|
||||
Provider-attempt identity and tool-side-effect fencing belong to separate
|
||||
designs.
|
||||
9. **Lifecycle does not promise execution semantics.** Graceful replacement
|
||||
invokes Session suspension and resumption hooks, but tool-level continuity
|
||||
belongs to a separate design.
|
||||
|
||||
## System Model
|
||||
|
||||
@@ -487,20 +485,23 @@ The UI derives text from status:
|
||||
| `failed` | Actionable failure message |
|
||||
| `ready` | Normal TUI |
|
||||
|
||||
## Session Continuity
|
||||
## Graceful Session Continuity
|
||||
|
||||
Every process-local Session busy period writes a durable execution claim before
|
||||
its runner starts. Success, failure, and user interruption release the claim;
|
||||
shutdown interruption and process death leave it intact. The
|
||||
successor sweeps claimed top-level Sessions, durably counts a recovery attempt,
|
||||
appends a continuation instruction, and resumes from projected history. The same
|
||||
mechanism covers graceful replacement, crash, SIGKILL, and runtime eviction.
|
||||
Version-mismatch replacement uses the existing graceful Session suspension and
|
||||
resumption hooks:
|
||||
|
||||
Recovery fails stale running tool projections before further model work, but it
|
||||
does not prove whether an interrupted provider request or external operation
|
||||
already took effect. It does not replay the exact interrupted tool, preserve an
|
||||
in-memory form, recover process-local background work, or guarantee exactly-once
|
||||
provider or tool behavior.
|
||||
1. The old server snapshots active Session IDs during graceful teardown.
|
||||
2. The successor schedules those Sessions for continuation.
|
||||
3. The runner reloads durable Session history before continuing.
|
||||
|
||||
This lifecycle design does not define what an interrupted physical provider
|
||||
attempt or tool invocation means. It does not promise that external side effects
|
||||
did not occur, replay the exact interrupted tool, preserve an in-memory form, or
|
||||
recover process-local background work.
|
||||
|
||||
Those concerns require a separate execution-continuity design covering tools,
|
||||
shells, sub-agents, permissions, questions, provider attempts, and hard-crash
|
||||
recovery.
|
||||
|
||||
## Unresponsive Owner
|
||||
|
||||
@@ -530,15 +531,13 @@ Automatic frozen-owner recovery is deferred.
|
||||
|
||||
1. The old service installs vNext but keeps running.
|
||||
2. A fresh vNext TUI finds the healthy vOld service and requests graceful stop.
|
||||
3. The old service reports `stopping` and exits. Shutdown interruption preserves
|
||||
the execution claims already written by active Sessions.
|
||||
3. The old service reports `stopping`, suspends active Sessions, and exits.
|
||||
4. Open TUIs enter their indefinite status loops.
|
||||
5. One or more clients spawn contenders.
|
||||
6. One contender acquires the service lock. Losers exit before heavy boot.
|
||||
7. The winner binds and registers the lifecycle shell as `starting`.
|
||||
8. Clients stop spawning and wait on the observable winner.
|
||||
9. The winner initializes the application, sweeps orphaned execution claims,
|
||||
and reports `ready`.
|
||||
9. The winner initializes the application and reports `ready`.
|
||||
10. TUIs rebuild clients, reconcile state, and resume.
|
||||
|
||||
### Server crashes while ready
|
||||
@@ -547,9 +546,7 @@ Automatic frozen-owner recovery is deferred.
|
||||
2. Clients call `ensureRunning`.
|
||||
3. Process death has released the service lock.
|
||||
4. One contender wins, replaces registration, and starts normally.
|
||||
5. Application startup sweeps orphaned top-level execution claims and resumes
|
||||
them with bounded attempt accounting. External side effects remain
|
||||
potentially ambiguous.
|
||||
5. Detailed active-execution recovery is outside this design.
|
||||
|
||||
### Winner crashes during startup
|
||||
|
||||
@@ -653,9 +650,8 @@ was the observed incident cost.
|
||||
`packages/client` with `bun run generate`.
|
||||
6. **Codify launch versus reconnect.** Fresh launch enforces installed version;
|
||||
reconnect never activates replacement.
|
||||
7. **Integrate Session continuity.** Preserve current background-install and
|
||||
fresh-launch activation behavior while invoking startup execution-claim
|
||||
recovery.
|
||||
7. **Integrate graceful replacement.** Preserve current background-install and
|
||||
fresh-launch activation behavior while invoking Session continuity hooks.
|
||||
8. **Harden explicit recovery.** Verify exact process identity during explicit
|
||||
`service restart`; never automatically kill an unresponsive owner.
|
||||
9. **Run the full multi-process suite.** Include repeated restart cycles and
|
||||
@@ -681,8 +677,7 @@ was the observed incident cost.
|
||||
|
||||
- Idle background update activation with an admission fence.
|
||||
- Application protocol compatibility and automatic local TUI re-exec.
|
||||
- Stronger execution recovery with provider-attempt identity, tool-side-effect
|
||||
idempotency or fencing, and clustered ownership.
|
||||
- Durable execution recovery for provider attempts and tools.
|
||||
- Shell, sub-agent, permission, question, and background-job continuity.
|
||||
- Automatic recovery for a positively identified frozen owner.
|
||||
- Cold-boot concurrency limits and interaction-prioritized location loading.
|
||||
|
||||
+5
-7
@@ -33,6 +33,7 @@
|
||||
"packages": [
|
||||
"packages/*",
|
||||
"packages/console/*",
|
||||
"packages/lab/*",
|
||||
"packages/stats/*",
|
||||
"packages/slack"
|
||||
],
|
||||
@@ -46,9 +47,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.3",
|
||||
"@opentui/keymap": "0.5.3",
|
||||
"@opentui/solid": "0.5.3",
|
||||
"@opentui/core": "0.5.2",
|
||||
"@opentui/keymap": "0.5.2",
|
||||
"@opentui/solid": "0.5.2",
|
||||
"@tanstack/solid-virtual": "3.13.32",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
@@ -144,10 +145,6 @@
|
||||
"esbuild",
|
||||
"node-pty",
|
||||
"protobufjs",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tree-sitter-powershell",
|
||||
"web-tree-sitter",
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
@@ -173,6 +170,7 @@
|
||||
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
|
||||
"effect@4.0.0-beta.101": "patches/effect@4.0.0-beta.101.patch",
|
||||
"@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch",
|
||||
"@cloudflare/vitest-pool-workers@0.12.6": "patches/@cloudflare%2Fvitest-pool-workers@0.12.6.patch",
|
||||
"@ff-labs/fff-bun@0.10.1": "patches/@ff-labs%2Ffff-bun@0.10.1.patch"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ Route defaults are request-shaping defaults such as `headers`, `limits`, `genera
|
||||
|
||||
The four-axis decomposition is the reason DeepSeek, TogetherAI, Cerebras, Baseten, Fireworks, and DeepInfra all reuse `OpenAIChat.protocol` verbatim — each provider deployment is a 5-15 line `Route.make(...)` call instead of a 300-400 line route clone. Bug fixes in one protocol propagate to every consumer of that protocol in a single commit.
|
||||
|
||||
When a provider 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
|
||||
|
||||
@@ -106,7 +106,7 @@ const proxied = gateway.model("openai/gpt-4o-mini")
|
||||
Keep provider facades small and explicit:
|
||||
|
||||
- Use branded `ProviderID.make(...)` and `ModelID.make(...)` where ids are constructed directly.
|
||||
- Use `model` for the default API path and named methods for provider-native alternatives such as OpenAI `responses` 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.
|
||||
- Export lower-level `routes` arrays separately only when advanced internal wiring needs them.
|
||||
- Prefer `apiKey` as provider-specific sugar and `auth` as the explicit override; keep them mutually exclusive in provider option types with `ProviderAuthOption`.
|
||||
@@ -124,10 +124,11 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey,
|
||||
transport: "websocket",
|
||||
})
|
||||
```
|
||||
|
||||
Keep semantic APIs as separate entrypoints, such as OpenAI `chat` and `responses`. 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(...)`.
|
||||
|
||||
@@ -153,16 +154,14 @@ packages/ai/src/
|
||||
auth-options.ts ProviderAuthOption shape, AuthOptions.bearer, AtLeastOne helper
|
||||
framing.ts Framing type + Framing.sse
|
||||
transport/ transport implementations
|
||||
index.ts Transport execution types + HttpTransport / WebSocketTransport namespaces
|
||||
websocket-channel.ts generic sequential channel executor/driver contract
|
||||
index.ts Transport type + HttpTransport / WebSocketTransport namespaces
|
||||
http.ts HttpTransport.httpJson — POST + framing
|
||||
websocket.ts direct one-request channel executor + raw socket adapter
|
||||
websocket.ts WebSocketTransport.json + WebSocketExecutor service
|
||||
protocols/
|
||||
shared.ts ProviderShared toolkit used inside protocol impls
|
||||
openai-chat.ts protocol + route (compose OpenAIChat.protocol)
|
||||
open-responses.ts provider-neutral Responses protocol baseline
|
||||
open-responses-channel.ts provider-neutral Responses WebSocket transport factory
|
||||
openai-responses.ts OpenAI tools/events and channel policy composed over OpenResponses
|
||||
openai-responses.ts OpenAI tools/events/transports composed over OpenResponses
|
||||
anthropic-messages.ts
|
||||
gemini.ts
|
||||
bedrock-converse.ts
|
||||
|
||||
@@ -315,6 +315,7 @@ import { model } from "@opencode-ai/ai/providers/openai/responses"
|
||||
|
||||
const selected = model("gpt-5", {
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
transport: "websocket",
|
||||
headers: { "x-application": "opencode" },
|
||||
limits: { context: 200_000, output: 64_000 },
|
||||
})
|
||||
@@ -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/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`.
|
||||
|
||||
|
||||
+34
-33
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -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 |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 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. |
|
||||
| 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. |
|
||||
@@ -47,19 +48,19 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
||||
|
||||
## AI SDK Package Parity Matrix
|
||||
|
||||
| AI SDK package | Intended native target | Status | Biggest gaps |
|
||||
| --------------------------------- | --------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner execution policy for optional WebSocket channels. |
|
||||
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
|
||||
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
|
||||
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||
| AI SDK package | Intended native target | Status | Biggest gaps |
|
||||
| --------------------------------- | -------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `@ai-sdk/openai` | `OpenAI.chat`, `OpenAI.responses`, `OpenAI.responsesWebSocket` | Partial / usable | Add complete typed option coverage, structured output strategy, explicit Responses continuation support, and runner route selection between Chat/Responses/WebSocket. |
|
||||
| `@ai-sdk/openai-compatible` | Generic OpenAI-compatible Chat and Responses | Partial / usable | Decide per-family namespace/profile behavior and runner API selection for providers that support Responses versus Chat only. |
|
||||
| `@ai-sdk/anthropic` | `AnthropicMessages` | Partial / usable | Finish Messages API parity for headers/betas/metadata/newer fields and document hosted-tool continuation expectations. |
|
||||
| `@ai-sdk/google` | Gemini Developer API | Partial / usable | Add typed options for safety, response schema/modalities, cached content, grounding/search/code execution, and non-text output modes where supported. |
|
||||
| `@ai-sdk/google-vertex` | Vertex Gemini namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and broader provider-option parity. |
|
||||
| `@ai-sdk/google-vertex/anthropic` | Anthropic Messages over Vertex namespace/facade | Partial / usable | Add runner/catalog mapping, recorded coverage, and Vertex-specific hosted-tool parity. |
|
||||
| `@ai-sdk/google-vertex/maas` | Vertex Chat | Partial / usable | Add runner/catalog mapping, recorded coverage, and MaaS family-specific request parity. |
|
||||
| `@ai-sdk/google-vertex/xai` | Vertex Chat / Responses | Partial / usable | Decide Chat/Responses selection for catalog models, add runner mapping and recorded coverage, and review xAI-specific request options. |
|
||||
| `@ai-sdk/azure` | Azure OpenAI Chat/Responses facade | Partial | Map runner/catalog metadata to native Azure, handle resourceName/baseURL/apiVersion variants, add AAD/token auth story, and verify Chat vs Responses deployment selection. |
|
||||
| `@ai-sdk/amazon-bedrock` | Bedrock Converse | Partial | Add default AWS credential chain/profile support, region/inference-profile model ID handling, provider option parity via `additionalModelRequestFields`, guardrails/performance config, and runner/catalog mapping. |
|
||||
| `@ai-sdk/amazon-bedrock/mantle` | Bedrock Mantle OpenAI-compatible Chat/Responses namespace | Partial / usable | Add default AWS credential chain/profile support; native catalog mapping currently requires bearer auth or explicit static credentials. |
|
||||
|
||||
## Highest-Risk Gaps
|
||||
|
||||
@@ -77,24 +78,24 @@ Other `aisdk:` packages, including Google Vertex, Azure, and Bedrock, currently
|
||||
|
||||
These are implementation/API slices, not separate npm packages.
|
||||
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP default and optional per-call WebSocket execution. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
||||
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
| API slice | Package-like entrypoint | Purpose |
|
||||
| ----------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| OpenAI Chat | `@opencode-ai/ai/providers/openai/chat` | OpenAI `/chat/completions` semantics. |
|
||||
| OpenAI Responses | `@opencode-ai/ai/providers/openai/responses` | OpenAI `/responses` semantics with HTTP/WebSocket selected through settings. |
|
||||
| OpenAI-compatible Chat | `@opencode-ai/ai/providers/openai-compatible` | Generic OpenAI-compatible `/chat/completions`. |
|
||||
| Open Responses-compatible | `@opencode-ai/ai/providers/openai-compatible/responses` | Generic provider-neutral `/responses`. |
|
||||
| Anthropic-compatible Messages | `@opencode-ai/ai/providers/anthropic-compatible` | Generic Anthropic-compatible `/messages`. |
|
||||
| Anthropic Messages | `@opencode-ai/ai/providers/anthropic` | Anthropic Messages API. |
|
||||
| Gemini Developer API | `@opencode-ai/ai/providers/google` | Google AI Studio Gemini API. |
|
||||
| Vertex Gemini | `@opencode-ai/ai/providers/google-vertex/gemini` | Vertex Gemini API; `providers/google-vertex` is the default alias. |
|
||||
| Vertex Chat | `@opencode-ai/ai/providers/google-vertex/chat` | Vertex OpenAI-compatible Chat Completions for MaaS models. |
|
||||
| Vertex Responses | `@opencode-ai/ai/providers/google-vertex/responses` | Vertex Open Responses for Grok models. |
|
||||
| Vertex Messages | `@opencode-ai/ai/providers/google-vertex/messages` | Vertex-hosted Anthropic Messages API. |
|
||||
| Bedrock Converse | `@opencode-ai/ai/providers/amazon-bedrock` | AWS Bedrock Converse API. |
|
||||
| Bedrock Mantle Chat | `@opencode-ai/ai/providers/amazon-bedrock/mantle/chat` | AWS Bedrock Mantle OpenAI-compatible Chat API. |
|
||||
| Bedrock Mantle Responses | `@opencode-ai/ai/providers/amazon-bedrock/mantle/responses` | AWS Bedrock Mantle OpenAI-compatible Responses API. |
|
||||
| Azure OpenAI Chat | `@opencode-ai/ai/providers/azure/chat` | Azure specialization of OpenAI Chat. |
|
||||
| Azure OpenAI Responses | `@opencode-ai/ai/providers/azure/responses` | Azure specialization of OpenAI Responses. |
|
||||
|
||||
## Suggested Next Work Slices
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ Examples:
|
||||
```ts
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.chat("gpt-4o")
|
||||
OpenAI.responsesWebSocket("gpt-4o")
|
||||
|
||||
Azure.configure({ resourceName, apiKey }).responses("my-deployment")
|
||||
AmazonBedrock.configure({ region, credentials }).model("anthropic.claude-3-5-sonnet-20241022-v2:0")
|
||||
@@ -249,6 +250,11 @@ const openAIChat = Route.make({
|
||||
auth: Auth.envBearer("OPENAI_API_KEY"),
|
||||
})
|
||||
|
||||
const openAIResponsesWebSocket = openAIResponses.with({
|
||||
id: "openai-responses-websocket",
|
||||
transport: WebSocketTransport.json,
|
||||
})
|
||||
|
||||
const openAIConfig = (input: OpenAIConfig) => ({
|
||||
endpoint: input.endpoint,
|
||||
auth: input.auth ?? (input.apiKey ? Auth.bearer(input.apiKey) : undefined),
|
||||
@@ -260,11 +266,13 @@ const openAIConfig = (input: OpenAIConfig) => ({
|
||||
|
||||
const configureOpenAI = (input: OpenAIConfig = {}) => {
|
||||
const responses = openAIResponses.with(openAIConfig(input))
|
||||
const responsesWebSocket = openAIResponsesWebSocket.with(openAIConfig(input))
|
||||
const chat = openAIChat.with(openAIConfig(input))
|
||||
|
||||
return {
|
||||
id: openAIProvider,
|
||||
responses: responses.model,
|
||||
responsesWebSocket: responsesWebSocket.model,
|
||||
chat: chat.model,
|
||||
model: responses.model,
|
||||
configure: configureOpenAI,
|
||||
@@ -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
|
||||
OpenAI.responses("gpt-4o")
|
||||
OpenAI.responsesWebSocket("gpt-4o")
|
||||
```
|
||||
|
||||
The package-like OpenAI Responses entrypoint has the same transport-neutral
|
||||
`model(...)` contract:
|
||||
The package-like OpenAI Responses entrypoint instead keeps transport scoped to
|
||||
Responses settings while preserving the same `model(...)` contract:
|
||||
|
||||
```ts
|
||||
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,
|
||||
@@ -376,9 +387,11 @@ import { model } from "@opencode-ai/ai/providers/google-vertex/messages"
|
||||
model("claude-sonnet-4-6", { project, location: "global" })
|
||||
```
|
||||
|
||||
The client does not require a different public layer for WebSocket execution.
|
||||
Responses routes use HTTP by default, and callers may pass a channel executor per
|
||||
call. Routes without channel support simply ignore that execution capability.
|
||||
The client should not require a different public layer just because a selected
|
||||
route uses WebSocket. Use one `LLMClient.layer` with HTTP and WebSocket runtime
|
||||
capabilities available; routes that do not need WebSocket simply never touch it.
|
||||
If a WebSocket route is selected in an environment without WebSocket support,
|
||||
fail with a typed transport configuration error.
|
||||
|
||||
Azure is a route specialization with auth/path/default changes plus input
|
||||
mapping. The public API configures the Azure resource once, then selects
|
||||
@@ -484,13 +497,18 @@ generic dynamic resolver:
|
||||
|
||||
```ts
|
||||
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
|
||||
provider APIs directly. Transport selection remains execution policy: a Session
|
||||
or other caller may pass a WebSocket channel executor per call without changing
|
||||
the model constructed by this boundary.
|
||||
provider APIs directly. A direct provider-facade boundary maps metadata like
|
||||
`endpoint.websocket` to `OpenAI.responsesWebSocket(apiModelID)`. A package-loading
|
||||
boundary passes `transport: "websocket"` to the OpenAI Responses entrypoint.
|
||||
The client runtime only executes the route carried by the resulting model.
|
||||
|
||||
## Competitive Shape
|
||||
|
||||
@@ -526,8 +544,9 @@ App boundary = explicit durable-config -> typed-provider call
|
||||
id.
|
||||
- No `model(id, overrides)` escape hatch. Model selection takes the model id;
|
||||
endpoint/auth/deployment customization happens by configuring the route first.
|
||||
- No transport setting on a provider or executable model. OpenAI Responses uses
|
||||
HTTP by default and accepts an optional per-call channel executor as execution policy.
|
||||
- No transport override on an executable model or request. Direct provider
|
||||
facades use `responses` versus `responsesWebSocket`; the package-like Responses
|
||||
entrypoint maps its scoped `transport` setting before constructing the model.
|
||||
- No separate public `LLMClient.layerWithWebSocket`. The runtime should expose one
|
||||
client layer with the available transport capabilities.
|
||||
- No executable `ModelRef`. The executable handle is `LanguageModel`; durable model
|
||||
@@ -561,10 +580,12 @@ App boundary = explicit durable-config -> typed-provider call
|
||||
- [x] Make unconfigured transports reusable constants such as
|
||||
`HttpTransport.sseJson`; keep transport functions only for configured/fresh
|
||||
state construction.
|
||||
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer` accepts
|
||||
optional per-call channel execution without changing route identity.
|
||||
- [x] Collapse the public WebSocket runtime split so one `LLMClient.layer`
|
||||
exposes available transport capabilities and selected routes fail with typed
|
||||
transport config errors when a required capability is missing.
|
||||
- [x] 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
|
||||
setup happens before selecting deployment ids.
|
||||
- [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
|
||||
or three provider conversions; start with plain objects if duplication is not
|
||||
yet painful.
|
||||
- [x] Keep executable model construction transport-neutral at the Session boundary;
|
||||
Session-scoped execution policy supplies channel capability separately.
|
||||
- [x] Update `packages/opencode/src/session/llm/native-request.ts` to construct
|
||||
executable models at the session boundary with explicit provider facade
|
||||
calls, mapping catalog metadata such as `endpoint.websocket` to the correct
|
||||
named route selector.
|
||||
- [ ] Update tests so direct route/provider tests assert route values are carried
|
||||
by executable models, and opencode/native tests assert boundary-based route
|
||||
selection.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest, Message, ProviderID, Tool, ToolRuntime } from "@opencode-ai/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } 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"
|
||||
|
||||
/**
|
||||
@@ -213,7 +213,8 @@ const FakeEcho = {
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||
const 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* () {
|
||||
// yield* generateOnce
|
||||
@@ -221,6 +222,6 @@ const program = Effect.gen(function* () {
|
||||
// yield* generateStructuredObject
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer)))
|
||||
}).pipe(Effect.provide(Layer.mergeAll(llmDeps, llmClientLayer)))
|
||||
|
||||
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 OpenAIResponses from "./openai-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.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("error"),
|
||||
status: Schema.optional(Schema.Number),
|
||||
status_code: Schema.optional(Schema.Number),
|
||||
code: optionalNull(Schema.String),
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
headers: Schema.optional(Schema.Record(Schema.String, WebSocketErrorHeader)),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
const decodeWebSocketErrorEvent = Schema.decodeUnknownEffect(WebSocketErrorEvent)
|
||||
|
||||
export const decodeKnownErrorEvent = (event: Event) =>
|
||||
decodeWebSocketErrorEvent({
|
||||
...event,
|
||||
status: typeof event.status === "number" ? event.status : undefined,
|
||||
status_code: typeof event.status_code === "number" ? event.status_code : undefined,
|
||||
headers: ProviderShared.isRecord(event.headers)
|
||||
? Object.fromEntries(
|
||||
Object.entries(event.headers).filter(
|
||||
(entry): entry is [string, string | number | boolean] =>
|
||||
typeof entry[1] === "string" || typeof entry[1] === "number" || typeof entry[1] === "boolean",
|
||||
),
|
||||
)
|
||||
: undefined,
|
||||
})
|
||||
|
||||
export const Event = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.String,
|
||||
@@ -272,9 +240,6 @@ export const Event = Schema.StructWithRest(
|
||||
message: Schema.optional(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
error: optionalNull(OpenResponsesErrorPayload),
|
||||
status: Schema.optional(Schema.Unknown),
|
||||
status_code: Schema.optional(Schema.Unknown),
|
||||
headers: Schema.optional(Schema.Unknown),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
@@ -667,9 +632,9 @@ export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
|
||||
const NO_EVENTS: StepResult["1"] = []
|
||||
|
||||
// `response.completed` / `response.incomplete` are clean finishes that emit a
|
||||
// `finish` event; `response.failed` and `error` are hard failures. All four end
|
||||
// the stream, so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||
const TERMINAL_TYPES = new Set(["error", "response.completed", "response.incomplete", "response.failed"])
|
||||
// `finish` event; `response.failed` is a hard failure. All three end the stream,
|
||||
// so keep this set aligned with `step` and the protocol's terminal predicate.
|
||||
const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"])
|
||||
export const terminal = (event: Event) => TERMINAL_TYPES.has(event.type)
|
||||
|
||||
const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepResult => {
|
||||
@@ -1001,24 +966,16 @@ const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
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 message = providerErrorMessage(event, fallback)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
? event.status
|
||||
: typeof event.status_code === "number"
|
||||
? event.status_code
|
||||
: undefined
|
||||
return new AIError({
|
||||
module: id,
|
||||
module: state.id,
|
||||
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) => {
|
||||
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`)
|
||||
@@ -1058,11 +1015,7 @@ export const step = (state: ParserState, event: Event) => {
|
||||
if (event.type === "response.completed" || event.type === "response.incomplete")
|
||||
return Effect.succeed(onResponseFinish(state, event))
|
||||
if (event.type === "response.failed") return providerError(state, event, `${state.name} response failed`)
|
||||
if (event.type === "error")
|
||||
return 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`)),
|
||||
)
|
||||
if (event.type === "error") return providerError(state, event, `${state.name} stream error`)
|
||||
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 { Headers } from "effect/unstable/http"
|
||||
import { Route } from "../route/client.js"
|
||||
import { Auth } from "../route/auth.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { HttpTransport } 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 { OpenResponses } from "./open-responses.js"
|
||||
import { optionalArray, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { OpenAIImage } from "./utils/openai-image.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { OpenResponsesChannel } from "./open-responses-channel.js"
|
||||
import { OpenAIResponsesChannel } from "./openai-responses-channel.js"
|
||||
|
||||
const ADAPTER = "openai-responses"
|
||||
const NAME = "OpenAI Responses"
|
||||
const WEBSOCKET_PROTOCOL_HEADER = "responses_websockets=2026-02-06"
|
||||
const WEBSOCKET_ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
|
||||
export const PATH = OpenResponses.PATH
|
||||
|
||||
@@ -62,6 +57,16 @@ const OpenAIResponsesBody = Schema.Struct({
|
||||
})
|
||||
export type OpenAIResponsesBody = Schema.Schema.Type<typeof OpenAIResponsesBody>
|
||||
|
||||
const OpenAIResponsesWebSocketMessage = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.create"),
|
||||
...OpenAIResponsesCoreFields,
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Unknown)],
|
||||
)
|
||||
type OpenAIResponsesWebSocketMessage = Schema.Schema.Type<typeof OpenAIResponsesWebSocketMessage>
|
||||
const encodeWebSocketMessage = Schema.encodeSync(Schema.fromJsonString(OpenAIResponsesWebSocketMessage))
|
||||
|
||||
const extension = {
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
@@ -244,13 +249,6 @@ const endpoint = Endpoint.path<OpenAIResponsesBody>(PATH, { baseURL: DEFAULT_BAS
|
||||
const auth = Auth.none
|
||||
|
||||
export const httpTransport = HttpTransport.sseJson.with<OpenAIResponsesBody>()
|
||||
export const transport = OpenResponsesChannel.transport<OpenAIResponsesBody>({
|
||||
id: ADAPTER,
|
||||
name: NAME,
|
||||
rotateAfterMs: WEBSOCKET_ROTATE_AFTER_MS,
|
||||
headers: (headers) => Headers.set(headers, "openai-beta", headers["openai-beta"] ?? WEBSOCKET_PROTOCOL_HEADER),
|
||||
driver: (input) => OpenAIResponsesChannel.driver({ id: ADAPTER, name: NAME, ...input }),
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: ADAPTER,
|
||||
@@ -259,7 +257,36 @@ export const route = Route.make({
|
||||
protocol,
|
||||
endpoint,
|
||||
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 } } },
|
||||
})
|
||||
|
||||
|
||||
@@ -67,7 +67,6 @@ const SERVER_CODES = new Set([
|
||||
"overloaded_error",
|
||||
"server_error",
|
||||
"server_is_overloaded",
|
||||
"slow_down",
|
||||
"serviceunavailableexception",
|
||||
])
|
||||
const INVALID_REQUEST_CODES = new Set(["invalid_prompt", "invalid_request_error", "validationexception"])
|
||||
|
||||
@@ -12,7 +12,7 @@ export type { OpenAIImageOptions } from "../protocols/openai-images.js"
|
||||
|
||||
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
|
||||
// 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 project?: string
|
||||
readonly queryParams?: Readonly<Record<string, string>>
|
||||
readonly transport?: "http" | "websocket"
|
||||
readonly providerOptions?: OpenAIProviderOptionsInput
|
||||
}
|
||||
|
||||
@@ -81,12 +82,17 @@ const configuredRoute = <Body, Prepared>(route: Route<Body, Prepared>, input: Co
|
||||
|
||||
export const configure = (input: Config = {}) => {
|
||||
const responsesRoute = configuredRoute(OpenAIResponses.route, input)
|
||||
const responsesWebSocketRoute = configuredRoute(OpenAIResponses.webSocketRoute, input)
|
||||
const chatRoute = configuredRoute(OpenAIChat.route, input)
|
||||
const modelDefaults = defaults(input)
|
||||
const responses = (id: string | ModelID) =>
|
||||
responsesRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const responsesWebSocket = (id: string | ModelID) =>
|
||||
responsesWebSocketRoute
|
||||
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
|
||||
.model<OpenAIProviderOptionsInput>({ id })
|
||||
const chat = (id: string | ModelID) =>
|
||||
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
|
||||
const image = (modelID: string | ModelID) =>
|
||||
@@ -105,6 +111,7 @@ export const configure = (input: Config = {}) => {
|
||||
id,
|
||||
model: responses,
|
||||
responses,
|
||||
responsesWebSocket,
|
||||
chat,
|
||||
image,
|
||||
configure,
|
||||
@@ -131,7 +138,10 @@ const config = (settings: Settings): Config => {
|
||||
}
|
||||
|
||||
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"] = (
|
||||
@@ -139,5 +149,6 @@ export const chatModel: ProviderPackage.Definition<Settings, OpenAIProviderOptio
|
||||
settings,
|
||||
) => configure(config(settings)).chat(modelID)
|
||||
export const responses = provider.responses
|
||||
export const responsesWebSocket = provider.responsesWebSocket
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { Cause, Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import * as Option from "effect/Option"
|
||||
import { Auth } from "./auth.js"
|
||||
import { Endpoint, type EndpointPatch } from "./endpoint.js"
|
||||
import { RequestExecutor } from "./executor.js"
|
||||
import { Framing } from "./framing.js"
|
||||
import { HttpTransport } from "./transport/index.js"
|
||||
import type { HttpMiddleware, Transport, TransportRuntime, 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 { applyCachePolicy } from "../cache-policy.js"
|
||||
import * as ProviderShared from "../protocols/shared.js"
|
||||
@@ -56,7 +58,6 @@ export interface Route<Body, Prepared = unknown> {
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
runtime: TransportRuntime,
|
||||
options?: StreamOptions,
|
||||
) => Stream.Stream<LLMEvent, AIError>
|
||||
}
|
||||
|
||||
@@ -156,7 +157,6 @@ export interface Interface {
|
||||
|
||||
export interface StreamOptions {
|
||||
readonly http?: HttpMiddleware
|
||||
readonly webSocket?: WebSocketChannelExecutor
|
||||
}
|
||||
|
||||
export interface StreamMethod {
|
||||
@@ -314,29 +314,23 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
encodeBody,
|
||||
headers: routeInput.headers,
|
||||
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}`
|
||||
return Stream.unwrap(
|
||||
routeInput.transport.execute(prepared, request, runtime, options).pipe(
|
||||
Effect.map((execution) => {
|
||||
const events = execution.frames.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
const stream = events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
return execution.complete ? stream.pipe(Stream.onEnd(execution.complete)) : stream
|
||||
}),
|
||||
const events = routeInput.transport
|
||||
.frames(prepared, request, runtime)
|
||||
.pipe(
|
||||
Stream.mapEffect(decodeEvent(route)),
|
||||
protocol.stream.terminal ? Stream.takeUntil(protocol.stream.terminal) : (stream) => stream,
|
||||
)
|
||||
return events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
Stream.catchCause((cause) => Stream.fail(streamError(route, `Failed to read ${route} stream`, cause))),
|
||||
requireTerminalEvent(route),
|
||||
)
|
||||
},
|
||||
} satisfies Route<Body, Prepared>
|
||||
@@ -419,7 +413,7 @@ const streamRequestWith = (runtime: TransportRuntime) => (request: LLMRequest, o
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compile(request, options)
|
||||
return compiled.route.streamPrepared(compiled.prepared, compiled.request, runtime, 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* () {
|
||||
const stream = streamRequestWith({
|
||||
http: yield* RequestExecutor.Service,
|
||||
webSocket: Option.getOrUndefined(yield* Effect.serviceOption(WebSocketExecutor.Service)),
|
||||
})
|
||||
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") {}
|
||||
|
||||
const headerDetails = (headers: Headers.Headers) =>
|
||||
Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, String(value)]))
|
||||
const BODY_LIMIT = 16_384
|
||||
const REDACTED = "<redacted>"
|
||||
|
||||
// One source of truth for what counts as a sensitive name across headers,
|
||||
// URL query keys, and field names embedded inside request/response bodies.
|
||||
//
|
||||
// `SENSITIVE_NAME` is used as both a substring matcher (for free-form header
|
||||
// names like `Authorization` / `X-API-Key`) and as the body-field alternation
|
||||
// list. `SHORT_QUERY_NAME` covers anchored short keys like `?key=…` / `?sig=…`
|
||||
// that are too generic to redact substring-style without false positives.
|
||||
const SENSITIVE_NAME_SOURCE =
|
||||
"authorization|api[-_]?key|access[-_]?token|refresh[-_]?token|id[-_]?token|token|secret|credential|signature|x-amz-signature"
|
||||
const SENSITIVE_NAME = new RegExp(SENSITIVE_NAME_SOURCE, "i")
|
||||
const SHORT_QUERY_NAME = /^(key|sig)$/i
|
||||
const SENSITIVE_BODY_FIELD = new RegExp(`(?:${SENSITIVE_NAME_SOURCE}|key)`, "i")
|
||||
const REDACT_JSON_FIELD = new RegExp(`("(?:${SENSITIVE_BODY_FIELD.source})"\\s*:\\s*)"[^"]*"`, "gi")
|
||||
const REDACT_QUERY_FIELD = new RegExp(`((?:${SENSITIVE_BODY_FIELD.source})=)[^&\\s"]+`, "gi")
|
||||
|
||||
const isSensitiveHeaderName = (name: string) => SENSITIVE_NAME.test(name)
|
||||
|
||||
const isSensitiveQueryName = (name: string) => isSensitiveHeaderName(name) || SHORT_QUERY_NAME.test(name)
|
||||
|
||||
const redactHeaders = (headers: Headers.Headers, redactedNames: ReadonlyArray<string | RegExp>) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(Headers.redact(headers, [...redactedNames, SENSITIVE_NAME])).map(([name, value]) => [
|
||||
name,
|
||||
String(value),
|
||||
]),
|
||||
)
|
||||
|
||||
const redactUrl = (value: string) => {
|
||||
if (!URL.canParse(value)) return REDACTED
|
||||
const url = new URL(value)
|
||||
url.searchParams.forEach((_, key) => {
|
||||
if (isSensitiveQueryName(key)) url.searchParams.set(key, REDACTED)
|
||||
})
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
const normalizedHeaders = (headers: Headers.Headers) =>
|
||||
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({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
headers: headerDetails(request.headers),
|
||||
url: redactUrl(request.url),
|
||||
headers: redactHeaders(request.headers, redactedNames),
|
||||
})
|
||||
|
||||
const responseDetails = (response: HttpClientResponse.HttpClientResponse) =>
|
||||
const responseDetails = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
redactedNames: ReadonlyArray<string | RegExp>,
|
||||
) =>
|
||||
new HttpResponseDetails({
|
||||
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 {}
|
||||
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(
|
||||
@@ -135,49 +207,52 @@ const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
),
|
||||
)
|
||||
|
||||
const providerMessage = (status: number, body: string | void) => {
|
||||
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
|
||||
return (
|
||||
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
|
||||
`Provider request failed with HTTP ${status}`
|
||||
)
|
||||
const providerMessage = (status: number, body: { readonly body?: string }) => {
|
||||
if (body.body && body.body.length <= 500) {
|
||||
const decoded = Option.getOrUndefined(decodeProviderBody(body.body))
|
||||
return `Provider request failed with HTTP ${status}: ${decoded?.error?.message ?? decoded?.message ?? body.body}`
|
||||
}
|
||||
return `Provider request failed with HTTP ${status}`
|
||||
}
|
||||
|
||||
const responseHttp = (input: {
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly response: HttpClientResponse.HttpClientResponse
|
||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
||||
readonly body: ReturnType<typeof responseBody>
|
||||
readonly requestId?: string | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
}) =>
|
||||
new HttpContext({
|
||||
request: requestDetails(input.request),
|
||||
response: responseDetails(input.response),
|
||||
request: requestDetails(input.request, input.redactedNames),
|
||||
response: responseDetails(input.response, input.redactedNames),
|
||||
...input.body,
|
||||
requestId: input.requestId,
|
||||
rateLimit: input.rateLimit,
|
||||
})
|
||||
|
||||
const statusError =
|
||||
(request: HttpClientRequest.HttpClientRequest) => (response: HttpClientResponse.HttpClientResponse) =>
|
||||
(request: HttpClientRequest.HttpClientRequest, redactedNames: ReadonlyArray<string | RegExp>) =>
|
||||
(response: HttpClientResponse.HttpClientResponse) =>
|
||||
Effect.gen(function* () {
|
||||
if (response.status < 400) return response
|
||||
const body = yield* response.text.pipe(Effect.catch(() => Effect.void))
|
||||
const headers = normalizedHeaders(response.headers)
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(body)
|
||||
const details = responseBody(body, secretValues(request))
|
||||
return yield* new AIError({
|
||||
module: "RequestExecutor",
|
||||
method: "execute",
|
||||
reason: classifyProviderFailure({
|
||||
status: response.status,
|
||||
message: providerMessage(response.status, body),
|
||||
message: providerMessage(response.status, details),
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: responseHttp({
|
||||
request,
|
||||
response,
|
||||
redactedNames,
|
||||
body: details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
@@ -187,10 +262,10 @@ const statusError =
|
||||
})
|
||||
|
||||
// Classifies an HTTP failure captured outside the executor (for example by the
|
||||
// AI SDK's own fetch) onto the same reason types and 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
|
||||
// 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: {
|
||||
readonly message: string
|
||||
readonly url: string
|
||||
@@ -202,7 +277,7 @@ export const classifyHttpFailure = (input: {
|
||||
const headers = normalizedHeaders(Headers.fromInput(input.responseHeaders))
|
||||
const retryAfter = retryAfterMs(headers)
|
||||
const rateLimit = rateLimitDetails(headers, retryAfter)
|
||||
const details = responseBody(input.responseBody)
|
||||
const details = responseBody(input.responseBody ?? undefined, new Set<string>())
|
||||
return classifyProviderFailure({
|
||||
message: input.message,
|
||||
status: input.status,
|
||||
@@ -210,11 +285,11 @@ export const classifyHttpFailure = (input: {
|
||||
retryAfterMs: retryAfter,
|
||||
rateLimit,
|
||||
http: new HttpContext({
|
||||
request: new HttpRequestDetails({ method: "POST", url: input.url, headers: {} }),
|
||||
request: new HttpRequestDetails({ method: "POST", url: redactUrl(input.url), headers: {} }),
|
||||
response:
|
||||
input.status === undefined
|
||||
? undefined
|
||||
: new HttpResponseDetails({ status: input.status, headers: headerDetails(Headers.fromInput(headers)) }),
|
||||
: new HttpResponseDetails({ status: input.status, headers: redactHeaders(Headers.fromInput(headers), []) }),
|
||||
...details,
|
||||
requestId: requestId(headers),
|
||||
rateLimit,
|
||||
@@ -244,6 +319,7 @@ const httpError = (input: {
|
||||
readonly error: unknown
|
||||
readonly request: HttpClientRequest.HttpClientRequest
|
||||
readonly operation: HttpOperation
|
||||
readonly redactedNames: ReadonlyArray<string | RegExp>
|
||||
}) => {
|
||||
const request = HttpClientError.isHttpClientError(input.error) ? input.error.request : input.request
|
||||
const transportError = (failure: { readonly message: string; readonly code?: string | undefined }) =>
|
||||
@@ -255,8 +331,8 @@ const httpError = (input: {
|
||||
transport: "http",
|
||||
operation: input.operation,
|
||||
code: failure.code,
|
||||
url: request.url,
|
||||
http: new HttpContext({ request: requestDetails(request) }),
|
||||
url: redactUrl(request.url),
|
||||
http: new HttpContext({ request: requestDetails(request, input.redactedNames) }),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -267,7 +343,7 @@ const httpError = (input: {
|
||||
const native = nativeTransportFailure(source)
|
||||
const code = native?.code
|
||||
const raw = native?.message ?? (input.error instanceof Error ? input.error.message : undefined)
|
||||
const detail = raw
|
||||
const detail = raw ? redactBody(raw, secretValues(request)) : undefined
|
||||
const message = code && detail && !detail.includes(code) ? `${code}: ${detail}` : detail
|
||||
|
||||
if (Cause.isTimeoutError(input.error) || Cause.isTimeoutError(source))
|
||||
@@ -293,9 +369,10 @@ export const stream = (
|
||||
): Stream.Stream<Uint8Array, AIError> =>
|
||||
Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
const response = yield* executor.execute(request, middleware)
|
||||
return response.stream.pipe(
|
||||
Stream.mapError((error) => httpError({ error, request: response.request, operation: "read" })),
|
||||
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 executeOnce = (request: HttpClientRequest.HttpClientRequest, middleware?: HttpMiddleware) =>
|
||||
Effect.gen(function* () {
|
||||
const redactedNames = yield* Headers.CurrentRedactedNames
|
||||
if (!middleware)
|
||||
return yield* http.execute(request).pipe(
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request" })),
|
||||
Effect.flatMap(statusError(request)),
|
||||
Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })),
|
||||
Effect.flatMap(statusError(request, redactedNames)),
|
||||
)
|
||||
|
||||
const response = yield* middleware(request, (input) =>
|
||||
http
|
||||
.execute(input)
|
||||
.pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))),
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request" })))
|
||||
return yield* statusError(response.request)(response)
|
||||
).pipe(Effect.mapError((error) => httpError({ error, request, operation: "request", redactedNames })))
|
||||
return yield* statusError(response.request, redactedNames)(response)
|
||||
})
|
||||
return Service.of({
|
||||
execute: executeOnce,
|
||||
|
||||
@@ -16,28 +16,11 @@ export { AuthOptions } from "./auth-options.js"
|
||||
export { Endpoint } from "./endpoint.js"
|
||||
export { Framing } from "./framing.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 type { Definition as AuthShape, AuthInput, Credential, CredentialError } from "./auth.js"
|
||||
export type { ApiKeyMode, AuthOverride, ProviderAuthOption } from "./auth-options.js"
|
||||
export type { Definition as EndpointFn, EndpointInput } from "./endpoint.js"
|
||||
export type { Definition as FramingDef } from "./framing.js"
|
||||
export type { Protocol as ProtocolDef } from "./protocol.js"
|
||||
export type {
|
||||
ChannelCheckpoint,
|
||||
ChannelCreate,
|
||||
ChannelObservation,
|
||||
HttpHandler,
|
||||
HttpMiddleware,
|
||||
Transport as TransportDef,
|
||||
TransportExecuteOptions,
|
||||
TransportExecution,
|
||||
TransportRuntime,
|
||||
WebSocketConnection,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecution,
|
||||
WebSocketChannelExecutor,
|
||||
WebSocketConnector,
|
||||
WebSocketRequest,
|
||||
} from "./transport/index.js"
|
||||
export type { HttpHandler, HttpMiddleware, Transport as TransportDef, TransportRuntime } from "./transport/index.js"
|
||||
|
||||
@@ -87,10 +87,8 @@ export const httpJson = <Body, Frame>(input: HttpJsonInput<Body, Frame>): HttpJs
|
||||
middleware: prepareInput.middleware,
|
||||
}
|
||||
}),
|
||||
execute: (prepared, _request, runtime) =>
|
||||
Effect.succeed({
|
||||
frames: prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
}),
|
||||
frames: (prepared, _request, runtime) =>
|
||||
prepared.framing.frame(RequestExecutor.stream(runtime.http, prepared.request, prepared.middleware)),
|
||||
})
|
||||
|
||||
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 { Auth } from "../auth.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"
|
||||
|
||||
export interface TransportRuntime {
|
||||
readonly http: RequestExecutorInterface
|
||||
}
|
||||
|
||||
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
|
||||
readonly webSocket?: WebSocketExecutorInterface
|
||||
}
|
||||
|
||||
export interface Transport<Body, Prepared, Frame> {
|
||||
readonly id: string
|
||||
readonly prepare: (input: TransportPrepareInput<Body>) => Effect.Effect<Prepared, AIError>
|
||||
readonly execute: (
|
||||
prepared: Prepared,
|
||||
request: LLMRequest,
|
||||
runtime: TransportRuntime,
|
||||
options?: TransportExecuteOptions,
|
||||
) => Effect.Effect<TransportExecution<Frame>, AIError, Scope.Scope>
|
||||
readonly frames: (prepared: Prepared, request: LLMRequest, runtime: TransportRuntime) => Stream.Stream<Frame, AIError>
|
||||
}
|
||||
|
||||
export interface TransportPrepareInput<Body> {
|
||||
@@ -38,19 +24,8 @@ export interface TransportPrepareInput<Body> {
|
||||
readonly encodeBody: (body: Body) => string
|
||||
readonly headers?: (input: { readonly request: LLMRequest }) => Record<string, string>
|
||||
readonly middleware?: HttpMiddleware
|
||||
readonly webSocket?: WebSocketChannelExecutor
|
||||
}
|
||||
|
||||
export * as HttpTransport from "./http.js"
|
||||
export type { HttpHandler, HttpMiddleware } from "../executor.js"
|
||||
export type {
|
||||
ChannelCheckpoint,
|
||||
ChannelCreate,
|
||||
ChannelObservation,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecution,
|
||||
WebSocketChannelExecutor,
|
||||
} from "./websocket-channel.js"
|
||||
export type { WebSocketConnection, WebSocketConnector, WebSocketRequest } from "./websocket.js"
|
||||
export { WebSocketTransport } from "./websocket.js"
|
||||
export { WebSocketExecutor, 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 { Socket } from "effect/unstable/socket"
|
||||
import { AIError, TransportReason, type TransportOperation } from "../../schema/index.js"
|
||||
import * as HttpTransport from "./http.js"
|
||||
import type { Transport } from "./index.js"
|
||||
import type {
|
||||
ChannelObservation,
|
||||
WebSocketChannelDriver,
|
||||
WebSocketChannelExchange,
|
||||
WebSocketChannelExecutor,
|
||||
} from "./websocket-channel.js"
|
||||
|
||||
export interface WebSocketRequest {
|
||||
readonly url: string
|
||||
@@ -22,29 +15,24 @@ export interface WebSocketConnection {
|
||||
readonly close: Effect.Effect<void, never>
|
||||
}
|
||||
|
||||
export interface WebSocketConnector {
|
||||
export interface Interface {
|
||||
readonly open: (input: WebSocketRequest) => Effect.Effect<WebSocketConnection, AIError>
|
||||
}
|
||||
|
||||
type WebSocketConstructorWithHeaders = (
|
||||
type WebSocketConstructorWithHeaders = new (
|
||||
url: string,
|
||||
options?: { readonly headers?: Headers.Headers },
|
||||
) => globalThis.WebSocket
|
||||
|
||||
const MAX_FRAME_BYTES = 16 * 1024 * 1024
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/WebSocketExecutor") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly operation: TransportOperation
|
||||
readonly url?: string
|
||||
readonly code?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
},
|
||||
input: { readonly operation: TransportOperation; readonly url?: string; readonly code?: string },
|
||||
) =>
|
||||
new AIError({
|
||||
module: "WebSocketConnector",
|
||||
module: "WebSocketExecutor",
|
||||
method,
|
||||
reason: new TransportReason({
|
||||
message,
|
||||
@@ -52,33 +40,9 @@ const transportError = (
|
||||
operation: input.operation,
|
||||
url: input.url,
|
||||
code: input.code,
|
||||
phase: input.phase,
|
||||
delivery: input.delivery,
|
||||
}),
|
||||
})
|
||||
|
||||
const annotateTransportError = (
|
||||
error: AIError,
|
||||
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||
) =>
|
||||
error.reason._tag === "Transport"
|
||||
? new AIError({
|
||||
module: error.module,
|
||||
method: error.method,
|
||||
reason: new TransportReason({
|
||||
message: error.reason.message,
|
||||
transport: error.reason.transport,
|
||||
operation: error.reason.operation,
|
||||
code: error.reason.code,
|
||||
url: error.reason.url,
|
||||
http: error.reason.http,
|
||||
phase: input.phase,
|
||||
delivery: input.delivery,
|
||||
recovery: error.reason.recovery,
|
||||
}),
|
||||
})
|
||||
: error
|
||||
|
||||
const eventMessage = (event: Event) => {
|
||||
if ("message" in event && typeof event.message === "string") return event.message
|
||||
return event.type
|
||||
@@ -99,8 +63,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
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)}`, {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -141,8 +101,6 @@ const waitOpen = (ws: globalThis.WebSocket, input: WebSocketRequest) => {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
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({
|
||||
try: () => {
|
||||
const url = new URL(value)
|
||||
@@ -173,31 +131,21 @@ export const toWebSocketUrl = (value: string) =>
|
||||
url: value,
|
||||
operation: "request",
|
||||
code: "invalid-url",
|
||||
phase: "prepare",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
|
||||
export const open = (input: WebSocketRequest) =>
|
||||
Effect.gen(function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
const ws = yield* Effect.try({
|
||||
try: () =>
|
||||
// Platform implementations may extend Effect's browser-compatible constructor with handshake options.
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion
|
||||
(constructor as unknown as WebSocketConstructorWithHeaders)(input.url, {
|
||||
headers: input.headers,
|
||||
}),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
return yield* fromWebSocket(ws, input)
|
||||
})
|
||||
Effect.try({
|
||||
try: () =>
|
||||
new (globalThis.WebSocket as unknown as WebSocketConstructorWithHeaders)(input.url, { headers: input.headers }),
|
||||
catch: (error) =>
|
||||
transportError("open", error instanceof Error ? error.message : "Failed to construct WebSocket", {
|
||||
url: input.url,
|
||||
operation: "request",
|
||||
}),
|
||||
}).pipe(Effect.flatMap((ws) => fromWebSocket(ws, input)))
|
||||
|
||||
export const layer: Layer.Layer<Service> = Layer.succeed(Service, Service.of({ open }))
|
||||
|
||||
export const fromWebSocket = (
|
||||
ws: globalThis.WebSocket,
|
||||
@@ -207,52 +155,16 @@ export const fromWebSocket = (
|
||||
yield* waitOpen(ws, input)
|
||||
const messages = yield* Queue.bounded<string | Uint8Array, AIError | Cause.Done<void>>(128)
|
||||
|
||||
const oversized = (message: string | Uint8Array) =>
|
||||
typeof message === "string" ? new Blob([message]).size > MAX_FRAME_BYTES : message.byteLength > MAX_FRAME_BYTES
|
||||
const rejectOversized = (message: string | Uint8Array) => {
|
||||
if (!oversized(message)) return false
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "WebSocket message exceeds the 16 MiB limit", {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message-too-large",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
if (ws.readyState === globalThis.WebSocket.OPEN) ws.close(1009, "Message too large")
|
||||
return true
|
||||
}
|
||||
const offer = (message: string | Uint8Array) => {
|
||||
if (rejectOversized(message)) return
|
||||
if (Queue.offerUnsafe(messages, message)) return
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "WebSocket inbound queue overflow", {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
if (typeof event.data === "string") return offer(event.data)
|
||||
if (typeof event.data === "string") return Queue.offerUnsafe(messages, event.data)
|
||||
const binary = binaryMessage(event.data)
|
||||
if (binary) return offer(binary)
|
||||
if (binary) return Queue.offerUnsafe(messages, binary)
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
transportError("message", "Unsupported WebSocket message payload", {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -264,13 +176,12 @@ export const fromWebSocket = (
|
||||
transportError("message", `WebSocket error: ${eventMessage(event)}`, {
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
const onClose = (event: CloseEvent) => {
|
||||
if (event.code === 1000 || event.code === 1005) return Queue.endUnsafe(messages)
|
||||
Queue.failCauseUnsafe(
|
||||
messages,
|
||||
Cause.fail(
|
||||
@@ -278,7 +189,6 @@ export const fromWebSocket = (
|
||||
url: input.url,
|
||||
operation: "read",
|
||||
code: String(event.code),
|
||||
phase: "close",
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -295,26 +205,13 @@ export const fromWebSocket = (
|
||||
|
||||
return {
|
||||
sendText: (message) =>
|
||||
Effect.suspend(() => {
|
||||
if (ws.readyState !== globalThis.WebSocket.OPEN)
|
||||
return Effect.fail(
|
||||
transportError("sendText", `WebSocket is not open (state ${ws.readyState})`, {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
)
|
||||
return Effect.try({
|
||||
try: () => ws.send(message),
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
phase: "send",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
})
|
||||
Effect.try({
|
||||
try: () => ws.send(message),
|
||||
catch: (error) =>
|
||||
transportError("sendText", error instanceof Error ? error.message : "Failed to send WebSocket message", {
|
||||
url: input.url,
|
||||
operation: "write",
|
||||
}),
|
||||
}),
|
||||
messages: Stream.fromQueue(messages),
|
||||
close: cleanup.pipe(
|
||||
@@ -331,57 +228,6 @@ export const fromWebSocket = (
|
||||
export const messageText = (message: string | Uint8Array, decoder: TextDecoder) =>
|
||||
typeof message === "string" ? message : decoder.decode(message)
|
||||
|
||||
const observationFrame = (observation: ChannelObservation) => {
|
||||
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
||||
return Effect.succeed(observation.frame)
|
||||
return Effect.fail(observation.error)
|
||||
}
|
||||
|
||||
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||
|
||||
export const makeDirect = (connector: WebSocketConnector): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* Effect.acquireRelease(
|
||||
connector
|
||||
.open(exchange.connect)
|
||||
.pipe(Effect.mapError((error) => annotateTransportError(error, { phase: "connect", delivery: "not-sent" }))),
|
||||
(connection) => connection.close,
|
||||
)
|
||||
const create = yield* exchange.driver.create(undefined)
|
||||
yield* connection.sendText(create.message)
|
||||
const decoder = new TextDecoder()
|
||||
let observed = false
|
||||
return {
|
||||
frames: connection.messages.pipe(
|
||||
Stream.map((message) => {
|
||||
observed = true
|
||||
return messageText(message, decoder)
|
||||
}),
|
||||
Stream.mapError((error) =>
|
||||
annotateTransportError(error, {
|
||||
phase: error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery: observed ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.takeUntil(observationTerminal),
|
||||
Stream.mapEffect(observationFrame),
|
||||
),
|
||||
complete: Effect.void,
|
||||
}
|
||||
}),
|
||||
})
|
||||
|
||||
export const direct: Effect.Effect<WebSocketChannelExecutor, never, Socket.WebSocketConstructor> = Effect.gen(
|
||||
function* () {
|
||||
const constructor = yield* Socket.WebSocketConstructor
|
||||
return makeDirect({
|
||||
open: (input) => open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
export interface JsonPrepared {
|
||||
readonly url: string
|
||||
readonly headers: Headers.Headers
|
||||
@@ -408,44 +254,33 @@ export const json = <Body, Message>(input: JsonInput<Body, Message>): JsonTransp
|
||||
...prepareInput,
|
||||
})
|
||||
return {
|
||||
url: yield* toWebSocketUrl(parts.url),
|
||||
url: yield* webSocketUrl(parts.url),
|
||||
headers: parts.headers,
|
||||
message: input.encodeMessage(yield* input.toMessage(parts.jsonBody)),
|
||||
}
|
||||
}),
|
||||
execute: (prepared, request, _runtime, options) => {
|
||||
const webSocket = options?.webSocket
|
||||
frames: (prepared, _request, runtime) => {
|
||||
const webSocket = runtime.webSocket
|
||||
if (!webSocket) {
|
||||
return Effect.fail(
|
||||
transportError("json", "WebSocket JSON transport requires StreamOptions.webSocket", {
|
||||
return Stream.fail(
|
||||
transportError("json", "WebSocket JSON transport requires WebSocketExecutor.Service", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "unavailable",
|
||||
phase: "prepare",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
)
|
||||
}
|
||||
const driver: WebSocketChannelDriver = {
|
||||
create: () => Effect.succeed({ message: prepared.message, mode: "full" }),
|
||||
observe: (_create, frame) => Effect.succeed({ type: "frame", frame }),
|
||||
}
|
||||
const exchange: WebSocketChannelExchange = {
|
||||
id: request.id ?? "request",
|
||||
connect: { url: prepared.url, headers: prepared.headers },
|
||||
fallback: () =>
|
||||
Stream.fail(
|
||||
transportError("fallback", "WebSocket JSON transport does not provide HTTP fallback", {
|
||||
url: prepared.url,
|
||||
operation: "request",
|
||||
code: "websocket",
|
||||
phase: "fallback",
|
||||
delivery: "not-sent",
|
||||
}),
|
||||
),
|
||||
driver,
|
||||
}
|
||||
return webSocket.execute(exchange)
|
||||
const decoder = new TextDecoder()
|
||||
return Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* Effect.acquireRelease(
|
||||
webSocket.open({ url: prepared.url, headers: prepared.headers }),
|
||||
(connection) => connection.close,
|
||||
)
|
||||
yield* connection.sendText(prepared.message)
|
||||
return connection.messages.pipe(Stream.map((message) => messageText(message, decoder)))
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -454,13 +289,15 @@ export const jsonTransport = {
|
||||
with: json,
|
||||
} as const
|
||||
|
||||
export const WebSocketTransport = {
|
||||
json,
|
||||
jsonTransport,
|
||||
direct,
|
||||
makeDirect,
|
||||
export const WebSocketExecutor = {
|
||||
Service,
|
||||
layer,
|
||||
open,
|
||||
fromWebSocket,
|
||||
messageText,
|
||||
toWebSocketUrl,
|
||||
} as const
|
||||
|
||||
export const WebSocketTransport = {
|
||||
json,
|
||||
jsonTransport,
|
||||
} as const
|
||||
|
||||
@@ -106,13 +106,6 @@ export class TransportReason extends Schema.Class<TransportReason>("AI.Error.Tra
|
||||
code: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
http: Schema.optional(HttpContext),
|
||||
phase: Schema.optional(
|
||||
Schema.Literals(["prepare", "queue", "connect", "send", "receive", "decode", "complete", "fallback", "close"]),
|
||||
),
|
||||
delivery: Schema.optional(Schema.Literals(["not-sent", "rejected", "ambiguous", "accepted"])),
|
||||
recovery: Schema.optional(
|
||||
Schema.Literals(["retry-connect", "retry-full", "rotate-and-retry-full", "fallback-http", "fail"]),
|
||||
),
|
||||
}) {}
|
||||
|
||||
export class InvalidProviderOutputReason extends Schema.Class<InvalidProviderOutputReason>(
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
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 { 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 OpenAI from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
|
||||
import { dynamicResponse, systemError } from "./lib/http.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"
|
||||
|
||||
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 largeProviderMessage = `Upstream request failed: ${"validation failed; ".repeat(1_000)}`
|
||||
|
||||
describe("RequestExecutor", () => {
|
||||
it.effect("parses response body failures at the executor seam", () =>
|
||||
@@ -77,11 +75,11 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: disconnected query-secret-123 header-secret-456",
|
||||
message: "ECONNRESET: disconnected <redacted> <redacted>",
|
||||
transport: "http",
|
||||
operation: "read",
|
||||
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(
|
||||
Effect.provide(
|
||||
@@ -154,12 +152,12 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "Transport",
|
||||
message: "ECONNRESET: proxy disconnected proxy-secret",
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
message: "ECONNRESET: proxy disconnected <redacted>",
|
||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||
http: {
|
||||
request: {
|
||||
url: "https://proxy.test/v1/chat?api_key=proxy-secret",
|
||||
headers: { authorization: "Bearer proxy-secret" },
|
||||
url: "https://proxy.test/v1/chat?api_key=%3Credacted%3E",
|
||||
headers: { authorization: "<redacted>" },
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -219,47 +217,9 @@ describe("RequestExecutor", () => {
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect("classification" in error.reason ? error.reason.classification : undefined).toBeUndefined()
|
||||
expect(error.reason.message).toBe("Provider request failed with HTTP 400")
|
||||
}).pipe(Effect.provide(responsesLayer([new Response("invalid parameter", { status: 400 })]))),
|
||||
)
|
||||
|
||||
it.effect("preserves structured provider messages from large error bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest", message: largeProviderMessage })
|
||||
expect(errorHttp(error)?.body).toContain(largeProviderMessage)
|
||||
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
responsesLayer([
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
model: "gpt-5.6-sol",
|
||||
error: { type: "invalid_request", message: largeProviderMessage },
|
||||
}),
|
||||
{ status: 400 },
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("falls back when structured provider messages are empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({
|
||||
_tag: "InvalidRequest",
|
||||
message: "Provider request failed with HTTP 400",
|
||||
})
|
||||
}).pipe(Effect.provide(responsesLayer([new Response('{"error":{"message":" "}}', { status: 400 })]))),
|
||||
)
|
||||
|
||||
it.effect("classifies provider rate limits hidden behind HTTP 400", () =>
|
||||
Effect.gen(function* () {
|
||||
const classify = (body: string) =>
|
||||
@@ -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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
@@ -308,15 +268,15 @@ describe("RequestExecutor", () => {
|
||||
requestId: "req_123",
|
||||
request: {
|
||||
method: "POST",
|
||||
url: "https://provider.test/v1/chat?api_key=secret&key=secret&debug=1",
|
||||
headers: { authorization: "Bearer secret", "x-safe": "visible" },
|
||||
url: "https://provider.test/v1/chat?api_key=%3Credacted%3E&key=%3Credacted%3E&debug=1",
|
||||
headers: { authorization: "<redacted>", "x-safe": "visible" },
|
||||
},
|
||||
response: {
|
||||
status: 429,
|
||||
headers: {
|
||||
"retry-after-ms": "0",
|
||||
"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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("visible")
|
||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("response-secret")
|
||||
expect(errorHttp(error)?.request.headers["x-safe"]).toBe("<redacted>")
|
||||
expect(errorHttp(error)?.response?.headers["x-safe"]).toBe("<redacted>")
|
||||
}).pipe(
|
||||
Effect.provide(responsesLayer([new Response("bad", { status: 400, headers: { "x-safe": "response-secret" } })])),
|
||||
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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.reason).toMatchObject({ _tag: "Authentication" })
|
||||
expect(errorHttp(error)?.bodyTruncated).toBeUndefined()
|
||||
expect(errorHttp(error)?.body).toHaveLength(20_000)
|
||||
expect(errorHttp(error)?.bodyTruncated).toBe(true)
|
||||
expect(errorHttp(error)?.body).toHaveLength(16_384)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(errorHttp(error)?.body).toBe(
|
||||
'{"error":{"message":"bad","key":"body-secret","detail":"api_key=query-secret"}}',
|
||||
)
|
||||
expect(errorHttp(error)?.body).toContain('"key":"<redacted>"')
|
||||
expect(errorHttp(error)?.body).toContain("api_key=<redacted>")
|
||||
expect(errorHttp(error)?.body).not.toContain("body-secret")
|
||||
expect(errorHttp(error)?.body).not.toContain("query-secret")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
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* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(secretRequest).pipe(Effect.flip)
|
||||
|
||||
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(
|
||||
Effect.provide(
|
||||
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 { 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 {
|
||||
CloudflareAIGateway,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
OpenAICompatibleResponses,
|
||||
OpenAIResponses,
|
||||
OpenResponses,
|
||||
OpenResponsesChannel,
|
||||
} from "@opencode-ai/ai/protocols"
|
||||
import * as AnthropicMessages from "@opencode-ai/ai/protocols/anthropic-messages"
|
||||
import { TestLLM } from "@opencode-ai/ai/testing"
|
||||
@@ -37,7 +36,6 @@ describe("public exports", () => {
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
expect(Route.make).toBeFunction()
|
||||
expect(Protocol.make).toBeFunction()
|
||||
expect(WebSocketTransport.makeDirect).toBeFunction()
|
||||
})
|
||||
|
||||
test("provider barrels expose user-facing facades", async () => {
|
||||
@@ -45,6 +43,7 @@ describe("public exports", () => {
|
||||
|
||||
expect(OpenAI.model).toBeFunction()
|
||||
expect(OpenAI.provider.responses).toBe(OpenAI.responses)
|
||||
expect(OpenAI.provider.responsesWebSocket).toBe(OpenAI.responsesWebSocket)
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).responses).toBeFunction()
|
||||
expect(OpenAICompatible.deepseek.model).toBeFunction()
|
||||
expect(
|
||||
@@ -66,10 +65,10 @@ describe("public exports", () => {
|
||||
expect(OpenAIChat.route.id).toBe("openai-chat")
|
||||
expect(OpenAICompatibleChat.route.id).toBe("openai-compatible-chat")
|
||||
expect(OpenResponses.protocol.id).toBe("open-responses")
|
||||
expect(OpenResponsesChannel.transport).toBeFunction()
|
||||
expect(OpenAICompatibleResponses.route.id).toBe("openai-compatible-responses")
|
||||
expect(OpenAICompatibleResponses.route.protocol).toBe("open-responses")
|
||||
expect(OpenAIResponses.route.id).toBe("openai-responses")
|
||||
expect(OpenAIResponses.webSocketRoute.id).toBe("openai-responses-websocket")
|
||||
expect(AnthropicMessages.route.id).toBe("anthropic-messages")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect, Layer, Ref } from "effect"
|
||||
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 RequestExecutorService } from "../../src/route/executor.js"
|
||||
import type { Service as WebSocketExecutorService } from "../../src/route/transport/websocket.js"
|
||||
|
||||
export type HandlerInput = {
|
||||
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 {
|
||||
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> => {
|
||||
const requestExecutorLayer = RequestExecutor.layer.pipe(Layer.provide(layer))
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
return Layer.mergeAll(requestExecutorLayer, llmClientLayer)
|
||||
const deps = Layer.mergeAll(requestExecutorLayer, WebSocketExecutor.layer)
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(deps))
|
||||
return Layer.mergeAll(deps, llmClientLayer)
|
||||
}
|
||||
|
||||
const SSE_HEADERS = { "content-type": "text/event-stream" } as const
|
||||
|
||||
@@ -69,10 +69,10 @@ describe("provider error classification", () => {
|
||||
|
||||
test("classifies V1 overloaded provider codes", () => {
|
||||
expect(
|
||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}', '{"code":"slow_down"}'].map(
|
||||
['{"code":"resource_exhausted"}', '{"code":"service_unavailable"}'].map(
|
||||
(message) => classifyProviderFailure({ message })._tag,
|
||||
),
|
||||
).toEqual(["ProviderInternal", "ProviderInternal", "ProviderInternal"])
|
||||
).toEqual(["ProviderInternal", "ProviderInternal"])
|
||||
})
|
||||
|
||||
test("classifies transient client statuses as provider internal", () => {
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { LLM } from "../../src/index.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({
|
||||
model: selected,
|
||||
model,
|
||||
prompt: "Hello",
|
||||
// @ts-expect-error OpenAI reasoning effort must be a string.
|
||||
providerOptions: { openai: { reasoningEffort: 1 } },
|
||||
})
|
||||
|
||||
OpenAI.configure({
|
||||
// @ts-expect-error Transport is execution policy, not provider configuration.
|
||||
transport: "websocket",
|
||||
})
|
||||
|
||||
@@ -80,6 +80,11 @@ describe("provider package entrypoints", () => {
|
||||
expect(selected.route.defaults.limits).toEqual({ context: 200_000, output: 64_000 })
|
||||
})
|
||||
|
||||
test("selects transport without changing the semantic API", () => {
|
||||
expect(model("gpt-5", { apiKey: "fixture" }).route.id).toBe("openai-responses")
|
||||
expect(model("gpt-5", { apiKey: "fixture", transport: "websocket" }).route.id).toBe("openai-responses-websocket")
|
||||
})
|
||||
|
||||
test("maps OpenAI-compatible Responses settings onto the executable model", async () => {
|
||||
const OpenAICompatibleResponses = await import("@opencode-ai/ai/providers/openai-compatible/responses")
|
||||
const selected = OpenAICompatibleResponses.model("custom-model", {
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("Anthropic Messages sad-path recorded", () => {
|
||||
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
|
||||
expect(error.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.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.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 { ConfigProvider, Effect, Layer, Ref, Stream } from "effect"
|
||||
import { ConfigProvider, Effect, Layer, Stream } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
LLM,
|
||||
AIError,
|
||||
HttpOptions,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
@@ -12,23 +11,14 @@ import {
|
||||
ToolCallPart,
|
||||
ToolDefinition,
|
||||
ToolResultPart,
|
||||
TransportReason,
|
||||
Usage,
|
||||
} from "../../src/index.js"
|
||||
import {
|
||||
Auth,
|
||||
LLMClient,
|
||||
RequestExecutor,
|
||||
WebSocketTransport,
|
||||
type ChannelObservation,
|
||||
type WebSocketChannelDriver,
|
||||
} from "../../src/route.js"
|
||||
import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import * as Azure from "../../src/providers/azure.js"
|
||||
import * as OpenAI from "../../src/providers/openai.js"
|
||||
import * as XAI from "../../src/providers/xai.js"
|
||||
import * as OpenAIResponses from "../../src/protocols/openai-responses.js"
|
||||
import { OpenAIResponsesChannel } from "../../src/protocols/openai-responses-channel.js"
|
||||
import * as ProviderShared from "../../src/protocols/shared.js"
|
||||
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
@@ -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 baseChannelDriver = (message: string): WebSocketChannelDriver => ({
|
||||
create: () => Effect.succeed({ message, mode: "full" }),
|
||||
observe: (_create, frame): Effect.Effect<ChannelObservation, AIError> => {
|
||||
const event = ProviderShared.decodeJson(frame)
|
||||
if (!ProviderShared.isRecord(event)) return Effect.die("Expected event")
|
||||
if (event.type === "response.completed") return Effect.succeed({ type: "completed", frame })
|
||||
if (event.type === "response.incomplete") return Effect.succeed({ type: "incomplete", frame })
|
||||
if (event.type === "error" || event.type === "response.failed")
|
||||
return Effect.succeed({
|
||||
type: "provider-failure",
|
||||
error: new AIError({
|
||||
module: "test",
|
||||
method: "stream",
|
||||
reason: new TransportReason({
|
||||
message: "provider rejected request",
|
||||
transport: "websocket",
|
||||
operation: "read",
|
||||
phase: "receive",
|
||||
}),
|
||||
}),
|
||||
})
|
||||
return Effect.succeed({ type: "frame", frame })
|
||||
},
|
||||
})
|
||||
|
||||
const continuationDriver = (request: Readonly<Record<string, unknown>>) => {
|
||||
const message = ProviderShared.encodeJson(request)
|
||||
return OpenAIResponsesChannel.driver({
|
||||
id: "openai-responses",
|
||||
name: "OpenAI Responses",
|
||||
request,
|
||||
message,
|
||||
base: baseChannelDriver(message),
|
||||
})
|
||||
}
|
||||
|
||||
const checkpoint = (observation: ChannelObservation) => {
|
||||
if (observation.type !== "completed" || !observation.checkpoint) throw new Error("Expected checkpoint")
|
||||
return observation.checkpoint
|
||||
}
|
||||
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
model,
|
||||
@@ -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* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLMRequest.update(request, {
|
||||
model: OpenAIResponses.route
|
||||
model: OpenAIResponses.webSocketRoute
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.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.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 })
|
||||
}),
|
||||
)
|
||||
@@ -287,60 +236,47 @@ describe("OpenAI Responses route", () => {
|
||||
it.effect("streams OpenAI Responses over WebSocket", () =>
|
||||
Effect.gen(function* () {
|
||||
const sent: string[] = []
|
||||
const opened: Array<{
|
||||
readonly url: string
|
||||
readonly authorization: string | undefined
|
||||
readonly protocol: string | undefined
|
||||
}> = []
|
||||
const opened: Array<{ readonly url: string; readonly authorization: string | undefined }> = []
|
||||
let closed = false
|
||||
const deps = Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({
|
||||
execute: () => Effect.die("unexpected HTTP request"),
|
||||
}),
|
||||
)
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: (input) =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({
|
||||
url: input.url,
|
||||
authorization: input.headers.authorization,
|
||||
protocol: input.headers["openai-beta"],
|
||||
})
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_ws" } }),
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
close: Effect.sync(() => {
|
||||
closed = true
|
||||
}),
|
||||
const deps = Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
RequestExecutor.Service,
|
||||
RequestExecutor.Service.of({
|
||||
execute: () => Effect.die("unexpected HTTP request"),
|
||||
}),
|
||||
})
|
||||
),
|
||||
Layer.succeed(
|
||||
WebSocketExecutor.Service,
|
||||
WebSocketExecutor.Service.of({
|
||||
open: (input) =>
|
||||
Effect.succeed({
|
||||
sendText: (message) =>
|
||||
Effect.sync(() => {
|
||||
opened.push({ url: input.url, authorization: input.headers.authorization })
|
||||
sent.push(message)
|
||||
}),
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "response.output_text.delta", item_id: "msg_1", delta: "Hi" }),
|
||||
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_ws" } }),
|
||||
]),
|
||||
close: Effect.sync(() => {
|
||||
closed = true
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({
|
||||
baseURL: "https://api.openai.test/v1/",
|
||||
apiKey: "test",
|
||||
headers: { "openai-beta": "custom-protocol" },
|
||||
}).responses("gpt-4.1-mini"),
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responsesWebSocket(
|
||||
"gpt-4.1-mini",
|
||||
),
|
||||
prompt: "Say hello.",
|
||||
}),
|
||||
{ webSocket },
|
||||
).pipe(Effect.provide(LLMClient.layer.pipe(Layer.provide(deps))))
|
||||
|
||||
expect(response.text).toBe("Hi")
|
||||
expect(opened).toEqual([
|
||||
{
|
||||
url: "wss://api.openai.test/v1/responses",
|
||||
authorization: "Bearer test",
|
||||
protocol: "custom-protocol",
|
||||
},
|
||||
])
|
||||
expect(opened).toEqual([{ url: "wss://api.openai.test/v1/responses", authorization: "Bearer test" }])
|
||||
expect(closed).toBe(true)
|
||||
expect(sent).toHaveLength(1)
|
||||
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", () =>
|
||||
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.
|
||||
{ readyState: globalThis.WebSocket.CLOSED } as globalThis.WebSocket,
|
||||
{ url: "wss://api.openai.test/v1/responses", headers: Headers.empty },
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("closed before opening")
|
||||
expect(error.reason).toMatchObject({ _tag: "Transport", phase: "connect", delivery: "not-sent" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -902,7 +329,7 @@ describe("OpenAI Responses route", () => {
|
||||
yield* LLMClient.generate(
|
||||
LLMRequest.update(request, {
|
||||
model: Azure.configure({
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/",
|
||||
baseURL: "https://opencode-test.openai.azure.com/openai/v1/",
|
||||
apiKey: "azure-key",
|
||||
headers: { authorization: "Bearer stale" },
|
||||
}).responses("gpt-4.1-mini"),
|
||||
@@ -2610,7 +2037,8 @@ describe("OpenAI Responses route", () => {
|
||||
)
|
||||
|
||||
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 * as path from "node:path"
|
||||
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 type { Service as ImageClientService } from "../src/image-client.js"
|
||||
import type { Service as LLMClientService } from "../src/route/client.js"
|
||||
import type { Service as RequestExecutorService } from "../src/route/executor.js"
|
||||
import type { Service as WebSocketExecutorService } from "../src/route/transport/websocket.js"
|
||||
import {
|
||||
recordedEffectGroup,
|
||||
type RecordedCaseOptions as RunnerCaseOptions,
|
||||
@@ -16,7 +17,7 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService
|
||||
type RecordedEnv = RequestExecutorService | WebSocketExecutorService | LLMClientService | ImageClientService
|
||||
|
||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||
readonly options?: HttpRecorder.RecorderOptions
|
||||
@@ -81,10 +82,11 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
}),
|
||||
),
|
||||
)
|
||||
const deps = Layer.mergeAll(requestExecutor, WebSocketExecutor.layer)
|
||||
return Layer.mergeAll(
|
||||
requestExecutor,
|
||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
deps,
|
||||
LLMClient.layer.pipe(Layer.provide(deps)),
|
||||
ImageClient.layer.pipe(Layer.provide(deps)),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
LanguageModel,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
TransportReason,
|
||||
Usage,
|
||||
} from "../src/schema/index.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")))),
|
||||
).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",
|
||||
patterns: ["git status", "git diff"],
|
||||
metadata: {},
|
||||
always: [],
|
||||
always: ["git *"],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
ReferenceInfo,
|
||||
QuestionRequest,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
@@ -111,6 +112,7 @@ type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<Locatio
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
type PermissionApi = ServerApi["permission"]
|
||||
type QuestionApi = ServerApi["question"]
|
||||
type VcsApi = ServerApi["vcs"]
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
@@ -301,6 +303,7 @@ export async function bootstrapDirectory(input: {
|
||||
readonly mcp: McpApi
|
||||
readonly permission: PermissionApi
|
||||
readonly project: ProjectApi
|
||||
readonly question: QuestionApi
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
@@ -391,6 +394,40 @@ export async function bootstrapDirectory(input: {
|
||||
)
|
||||
}),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.question.request
|
||||
.list({ location: { directory: input.directory } })
|
||||
.then((result) => result.data)
|
||||
.then((questions) => {
|
||||
const ids = questions.map((question) => question.sessionID)
|
||||
const grouped = groupBySession(
|
||||
questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[],
|
||||
)
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session })
|
||||
return warm.then(() =>
|
||||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.location.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
)
|
||||
if (input.session) input.session.set("question", sessionID, value)
|
||||
if (!input.session) input.setStore("question", sessionID, value)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||
|
||||
@@ -250,6 +250,7 @@ export function createChildStoreManager(input: {
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
get mcp_ready() {
|
||||
return !mcpQuery.isLoading
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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 type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
@@ -45,6 +45,19 @@ const permissionRequest = (id: string, sessionID: string, title = id) =>
|
||||
save: [],
|
||||
}) 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> = {}) =>
|
||||
({
|
||||
status: "complete",
|
||||
@@ -62,6 +75,7 @@ const baseState = (input: Partial<State> = {}) =>
|
||||
session_diff: {},
|
||||
todo: {},
|
||||
permission: {},
|
||||
question: {},
|
||||
mcp: {},
|
||||
lsp: [],
|
||||
vcs: undefined,
|
||||
@@ -206,6 +220,7 @@ describe("applyDirectoryEvent", () => {
|
||||
session_diff: { ses_1: [] },
|
||||
todo: { ses_1: [] },
|
||||
permission: { ses_1: [] },
|
||||
question: { ses_1: [] },
|
||||
session_status: { ses_1: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
@@ -226,6 +241,7 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.session_diff.ses_1).toBeUndefined()
|
||||
expect(store.todo.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.session_status.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -266,6 +282,7 @@ describe("applyDirectoryEvent", () => {
|
||||
session_diff: { [item.info.id]: [] },
|
||||
todo: { [item.info.id]: [] },
|
||||
permission: { [item.info.id]: [] },
|
||||
question: { [item.info.id]: [] },
|
||||
session_status: { [item.info.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
@@ -289,6 +306,7 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.session_diff[item.info.id]).toBeUndefined()
|
||||
expect(store.todo[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()
|
||||
}
|
||||
})
|
||||
@@ -307,6 +325,7 @@ describe("applyDirectoryEvent", () => {
|
||||
session_diff: { [dropped.id]: [] },
|
||||
todo: { [dropped.id]: [] },
|
||||
permission: { [dropped.id]: [] },
|
||||
question: { [dropped.id]: [] },
|
||||
session_status: { [dropped.id]: { type: "busy" } },
|
||||
}),
|
||||
)
|
||||
@@ -330,6 +349,7 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.session_diff[dropped.id]).toBeUndefined()
|
||||
expect(store.todo[dropped.id]).toBeUndefined()
|
||||
expect(store.permission[dropped.id]).toBeUndefined()
|
||||
expect(store.question[dropped.id]).toBeUndefined()
|
||||
expect(store.session_status[dropped.id]).toBeUndefined()
|
||||
expect(todos).toEqual([dropped.id])
|
||||
})
|
||||
@@ -466,11 +486,12 @@ describe("applyDirectoryEvent", () => {
|
||||
expect(store.part[messageID]).toBeUndefined()
|
||||
})
|
||||
|
||||
test("tracks permission request lifecycles", () => {
|
||||
test("tracks permission and question request lifecycles", () => {
|
||||
const sessionID = "ses_1"
|
||||
const [store, setStore] = createStore(
|
||||
baseState({
|
||||
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() {},
|
||||
})
|
||||
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", () => {
|
||||
|
||||
@@ -2,7 +2,13 @@ import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { Message, Part, Project, Todo } from "@/types"
|
||||
import type { FileDiffInfo, PermissionRequest, SessionInfo, SessionStatus } from "@opencode-ai/client/promise"
|
||||
import type {
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { State, VcsCache } from "./types"
|
||||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
@@ -21,6 +27,9 @@ const SESSION_CONTENT_EVENTS = new Set([
|
||||
"message.part.delta",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
"question.asked",
|
||||
"question.replied",
|
||||
"question.rejected",
|
||||
])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
@@ -77,6 +86,7 @@ export function cleanupDroppedSessionCaches(
|
||||
...Object.keys(store.session_diff),
|
||||
...Object.keys(store.todo),
|
||||
...Object.keys(store.permission),
|
||||
...Object.keys(store.question),
|
||||
...Object.keys(store.session_status),
|
||||
...Object.values(store.part)
|
||||
.map((parts) => parts?.find((part) => !!part?.sessionID)?.sessionID)
|
||||
@@ -428,6 +438,43 @@ export function applyDirectoryEvent(input: {
|
||||
)
|
||||
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": {
|
||||
input.loadLsp()
|
||||
break
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
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 { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("app session cache", () => {
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
@@ -43,6 +44,7 @@ describe("app session cache", () => {
|
||||
session_message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
form: { ses_1: [] as FormInfo[] },
|
||||
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_status.ses_1).toBeUndefined()
|
||||
expect(store.permission.ses_1).toBeUndefined()
|
||||
expect(store.question.ses_1).toBeUndefined()
|
||||
expect(store.form.ses_1).toBeUndefined()
|
||||
})
|
||||
|
||||
@@ -69,6 +72,7 @@ describe("app session cache", () => {
|
||||
session_message: Record<string, never[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form: Record<string, FormInfo[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
@@ -79,6 +83,7 @@ describe("app session cache", () => {
|
||||
session_message: {},
|
||||
part: { [m.id]: [part("prt_1", "ses_1", m.id)] },
|
||||
permission: {},
|
||||
question: {},
|
||||
form: {},
|
||||
part_text_accum_delta: {},
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
|
||||
@@ -13,6 +13,7 @@ type SessionCache = {
|
||||
session_message: Record<string, SessionMessageInfo[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
form?: Record<string, FormInfo[] | 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_status[sessionID]
|
||||
delete store.permission[sessionID]
|
||||
delete store.question[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 {
|
||||
FileDiffInfo,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
SessionInfo,
|
||||
SessionStatus,
|
||||
@@ -49,6 +50,9 @@ export type State = {
|
||||
permission: {
|
||||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
[name: string]: McpServer["status"]
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
SessionMessageInfo,
|
||||
} from "@opencode-ai/client/promise"
|
||||
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 { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { rootSession } from "@/utils/session-route"
|
||||
@@ -198,6 +198,7 @@ export function createServerSession(
|
||||
session_diff: {} as Record<string, FileDiffInfo[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
form: {} as Record<string, FormInfo[]>,
|
||||
pending: {} as Record<string, SessionInboxInfo[]>,
|
||||
input: {} as Record<string, string[]>,
|
||||
@@ -280,6 +281,9 @@ export function createServerSession(
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.question)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.form)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -525,6 +529,9 @@ export function createServerSession(
|
||||
...Object.entries(data.permission)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.question)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
...Object.entries(data.form)
|
||||
.filter(([, items]) => items.length > 0)
|
||||
.map(([sessionID]) => sessionID),
|
||||
@@ -1332,6 +1339,36 @@ export function createServerSession(
|
||||
)
|
||||
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)
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,14 +37,16 @@ export function SessionPermissionDock(props: {
|
||||
<Button variant="ghost" size="normal" onClick={() => props.onDecide("reject")} disabled={props.responding}>
|
||||
{language.t("ui.permission.deny")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
<Show when={props.request.save?.length}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="normal"
|
||||
onClick={() => props.onDecide("always")}
|
||||
disabled={props.responding}
|
||||
>
|
||||
{language.t("ui.permission.allowAlways")}
|
||||
</Button>
|
||||
</Show>
|
||||
<Button variant="primary" size="normal" onClick={() => props.onDecide("once")} disabled={props.responding}>
|
||||
{language.t("ui.permission.allowOnce")}
|
||||
</Button>
|
||||
|
||||
@@ -38,10 +38,8 @@
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"semver": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"uqr": "0.1.3",
|
||||
"ws": "8.21.0"
|
||||
},
|
||||
@@ -50,6 +48,7 @@
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/semver": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"@lydell/node-pty-darwin-arm64": "1.2.0-beta.12",
|
||||
"@lydell/node-pty-darwin-x64": "1.2.0-beta.12",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { $ } from "bun"
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { brotliCompressSync, constants } from "node:zlib"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
|
||||
if (options?.skipBuild) return compress({})
|
||||
@@ -9,10 +9,8 @@ export async function buildAppArchive(channel: string, options?: { skipBuild?: b
|
||||
await $`bun run build`.cwd(root).env({ ...process.env, OPENCODE_CHANNEL: channel })
|
||||
const assets = Object.fromEntries(
|
||||
await Promise.all(
|
||||
(await collectFiles(path.join(root, "dist")))
|
||||
.map((key) => key.replaceAll(path.sep, "/"))
|
||||
(await files(path.join(root, "dist")))
|
||||
.filter((key) => !key.endsWith(".map"))
|
||||
.toSorted()
|
||||
.map(async (key) => {
|
||||
const source = path.join(root, "dist", key)
|
||||
const body = Buffer.from(await Bun.file(source).arrayBuffer())
|
||||
@@ -33,3 +31,16 @@ function compress(assets: object) {
|
||||
function isText(key: string) {
|
||||
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target).replaceAll(path.sep, "/")]
|
||||
}),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.toSorted()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { collectNodeAssets, copyNodeAssets, hashNodeAssets, seaAssetMap } from "
|
||||
import { mainConfig } from "../vite.node.config"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget } from "../src/node/target"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact } from "./verify-artifact"
|
||||
|
||||
const NODE_VERSION = "26.4.0"
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
@@ -92,7 +91,6 @@ for (const target of targets) {
|
||||
await copyNodeAssets(assets)
|
||||
await build(mainConfig(input))
|
||||
await assertTextImportsInlined("dist-node/opencode.mjs")
|
||||
if (bundleOnly) await verifyArtifact("dist-node/opencode.mjs")
|
||||
|
||||
const host = target.platform === process.platform && target.arch === process.arch
|
||||
if (host) {
|
||||
@@ -141,7 +139,6 @@ for (const target of targets) {
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
await verifyArtifact(path.join(outdir, name))
|
||||
if (host) await smoke(output)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { createSolidTransformPlugin } from "@opentui/solid/bun-plugin"
|
||||
import type { BunPlugin } from "bun"
|
||||
import pkg from "../package.json"
|
||||
import { buildAppArchive } from "./app-assets"
|
||||
import { verifyArtifact, verifySimulationGraph } from "./verify-artifact"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
const binary = "opencode2"
|
||||
@@ -77,16 +76,6 @@ const appAssetsPlugin: BunPlugin = {
|
||||
}
|
||||
|
||||
for (const item of targets) {
|
||||
const simulationInputs = new Set<string>()
|
||||
const simulationGraphPlugin: BunPlugin = {
|
||||
name: "opencode-simulation-graph",
|
||||
setup(build) {
|
||||
build.onLoad(
|
||||
{ filter: /packages[/\\]simulation[/\\]src[/\\](frontend[/\\](simulation|server)|control-server)\.ts$/ },
|
||||
(args) => void simulationInputs.add(args.path),
|
||||
)
|
||||
},
|
||||
}
|
||||
const parcelWatcherPackage = `@parcel/watcher-${item.os}-${item.arch}${item.os === "linux" ? `-${item.abi ?? "glibc"}` : ""}`
|
||||
const parcelWatcherPlugin: BunPlugin = {
|
||||
name: "parcel-watcher-binding",
|
||||
@@ -103,7 +92,7 @@ for (const item of targets) {
|
||||
const result = await Bun.build({
|
||||
entrypoints: ["./src/index.ts"],
|
||||
tsconfig: "./tsconfig.json",
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin, simulationGraphPlugin],
|
||||
plugins: [appAssetsPlugin, solidPlugin, parcelWatcherPlugin],
|
||||
external: ["node-gyp"],
|
||||
format: "esm",
|
||||
minify: true,
|
||||
@@ -134,7 +123,6 @@ for (const item of targets) {
|
||||
for (const log of result.logs) console.error(log)
|
||||
process.exit(1)
|
||||
}
|
||||
verifySimulationGraph(simulationInputs)
|
||||
|
||||
await Bun.write(
|
||||
path.join(outdir, name, "package.json"),
|
||||
@@ -151,7 +139,6 @@ for (const item of targets) {
|
||||
2,
|
||||
),
|
||||
)
|
||||
await verifyArtifact(path.join(outdir, name))
|
||||
}
|
||||
|
||||
function targetName(item: (typeof allTargets)[number]) {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { readdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export async function collectFiles(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map(async (entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? collectFiles(root, target) : [path.relative(root, target)]
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { createHash } from "node:crypto"
|
||||
import { copyFile, mkdir, readFile, stat } from "node:fs/promises"
|
||||
import { copyFile, mkdir, readdir, readFile, stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { getNodeAssets } from "@opentui/core/node-assets"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "../src/node/target"
|
||||
import { collectFiles } from "./files"
|
||||
import { attentionSoundAssets, type NodeTarget, photonWasmAsset } from "../src/node/target"
|
||||
|
||||
const dir = path.resolve(import.meta.dirname, "..")
|
||||
|
||||
@@ -17,6 +16,17 @@ export type NodeAsset = {
|
||||
readonly source: string
|
||||
}
|
||||
|
||||
async function files(root: string, current = root): Promise<string[]> {
|
||||
return (
|
||||
await Promise.all(
|
||||
(await readdir(current, { withFileTypes: true })).map((entry) => {
|
||||
const target = path.join(current, entry.name)
|
||||
return entry.isDirectory() ? files(root, target) : [path.relative(root, target)]
|
||||
}),
|
||||
)
|
||||
).flat()
|
||||
}
|
||||
|
||||
export async function collectNodeAssets(target: NodeTarget) {
|
||||
const ptyEntry = fileURLToPath(import.meta.resolve(target.nodePtyPackage))
|
||||
const ptyRoot = path.resolve(path.dirname(ptyEntry), "..")
|
||||
@@ -33,15 +43,11 @@ export async function collectNodeAssets(target: NodeTarget) {
|
||||
key: photonWasmAsset,
|
||||
source: fileURLToPath(import.meta.resolve(photonWasmAsset)),
|
||||
},
|
||||
...Object.values(shellParserWasmAssets).map((key) => ({
|
||||
key,
|
||||
source: fileURLToPath(import.meta.resolve(key)),
|
||||
})),
|
||||
...attentionSoundAssets.map((key) => ({
|
||||
key,
|
||||
source: path.resolve(dir, "../ui/src/assets/audio", path.basename(key)),
|
||||
})),
|
||||
...(await collectFiles(ptyRoot))
|
||||
...(await files(ptyRoot))
|
||||
.filter((relative) => !relative.endsWith(".map") && !relative.endsWith(".pdb"))
|
||||
.map((relative) => ({
|
||||
key: `${target.nodePtyPackage}/${relative}`,
|
||||
@@ -75,7 +81,5 @@ export async function copyNodeAssets(assets: readonly NodeAsset[]) {
|
||||
|
||||
export async function seaAssetMap() {
|
||||
const root = path.join(dir, "dist-node", "assets")
|
||||
return Object.fromEntries(
|
||||
(await collectFiles(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]),
|
||||
)
|
||||
return Object.fromEntries((await files(root)).map((key) => [key.replaceAll(path.sep, "/"), path.join(root, key)]))
|
||||
}
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { stat } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
import { collectFiles } from "./files"
|
||||
|
||||
const forbidden = [
|
||||
"@napi-rs/canvas",
|
||||
"@fontsource/commit-mono",
|
||||
"@fontsource/noto-sans",
|
||||
"SimulationPng",
|
||||
"frontend/png",
|
||||
"Failed to register screenshot font",
|
||||
"commit-mono-latin-400-normal",
|
||||
"noto-sans-symbols-symbols-400-normal",
|
||||
"noto-sans-math-math-400-normal",
|
||||
"CommitMono-400-Regular.otf",
|
||||
"NotoSansSymbols.ttf",
|
||||
"src/frontend/png.ts",
|
||||
"skia.darwin-",
|
||||
"skia.linux-",
|
||||
"skia.win32-",
|
||||
]
|
||||
const overlap = Math.max(...forbidden.map((value) => value.length)) - 1
|
||||
|
||||
export async function verifyArtifact(target: string) {
|
||||
const files = await artifactFiles(target)
|
||||
if (files.length === 0) throw new Error(`Artifact contains no published files: ${target}`)
|
||||
for (const file of files) await scan(file)
|
||||
}
|
||||
|
||||
export function verifySimulationGraph(inputs: Iterable<string>) {
|
||||
const modules = Array.from(inputs, (input) => input.replaceAll("\\", "/"))
|
||||
const required = [
|
||||
"/packages/simulation/src/frontend/simulation.ts",
|
||||
"/packages/simulation/src/frontend/server.ts",
|
||||
"/packages/simulation/src/control-server.ts",
|
||||
]
|
||||
const missing = required.filter((input) => !modules.some((module) => module.endsWith(input)))
|
||||
if (missing.length > 0) throw new Error(`Build graph is missing simulation bridge inputs: ${missing.join(", ")}`)
|
||||
const leaked = modules.find((module) => module.includes("/packages/simulation/src/frontend/png."))
|
||||
if (leaked) throw new Error(`Build graph contains Drive-only rendering input: ${leaked}`)
|
||||
}
|
||||
|
||||
async function artifactFiles(target: string): Promise<string[]> {
|
||||
if ((await stat(target)).isFile()) return [target]
|
||||
return (await collectFiles(target)).map((file) => path.join(target, file))
|
||||
}
|
||||
|
||||
async function scan(file: string) {
|
||||
let trailing = ""
|
||||
const reader = Bun.file(file).stream().getReader()
|
||||
while (true) {
|
||||
const chunk = await reader.read()
|
||||
if (chunk.done) return
|
||||
const text = trailing + Buffer.from(chunk.value).toString("latin1")
|
||||
const leaked = forbidden.find((marker) => text.includes(marker))
|
||||
if (leaked) throw new Error(`Artifact file ${file} contains forbidden simulation payload: ${leaked}`)
|
||||
trailing = text.slice(-overlap)
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.main) {
|
||||
const target = process.argv[2]
|
||||
if (!target) throw new Error("Usage: bun run script/verify-artifact.ts <file-or-directory>")
|
||||
await verifyArtifact(target)
|
||||
}
|
||||
@@ -47,7 +47,7 @@ export async function replyPermission(input: {
|
||||
locations: permissionLocations(toolName, toolInput, input.event.data.resources, input.cwd, previews),
|
||||
...(previews.length > 0 ? { content: previews } : {}),
|
||||
},
|
||||
options,
|
||||
options: input.event.data.save?.length ? options : options.filter((option) => option.optionId !== "always"),
|
||||
})
|
||||
.catch(() => undefined)
|
||||
const selected = result?.outcome.outcome === "selected" ? result.outcome.optionId : undefined
|
||||
|
||||
@@ -29,11 +29,6 @@ export function nodeTarget(platform: string, arch: string) {
|
||||
}
|
||||
|
||||
export const photonWasmAsset = "@silvia-odwyer/photon-node/photon_rs_bg.wasm"
|
||||
export const shellParserWasmAssets = {
|
||||
runtime: "web-tree-sitter/tree-sitter.wasm",
|
||||
bash: "tree-sitter-bash/tree-sitter-bash.wasm",
|
||||
powershell: "tree-sitter-powershell/tree-sitter-powershell.wasm",
|
||||
} as const
|
||||
export const nodeExecArgv = ["--experimental-ffi", "--use-system-ca", "--disable-warning=ExperimentalWarning"] as const
|
||||
|
||||
export const attentionSoundAssets = [
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import { Service, type Endpoint } from "@opencode-ai/client/effect/service"
|
||||
import { OpenCode, type OpenCodeClient, type SessionMessageAssistantTool } from "@opencode-ai/client/promise"
|
||||
import {
|
||||
OpenCode,
|
||||
type ModelRef,
|
||||
type OpenCodeClient,
|
||||
type SessionInfo,
|
||||
type SessionMessageAssistantTool,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { FSUtil } from "@opencode-ai/util/fs-util"
|
||||
import { open } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
@@ -116,8 +122,13 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
return undefined
|
||||
})
|
||||
if (!target) return
|
||||
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
|
||||
const variant = target.model?.variant
|
||||
await applyRunSelection({
|
||||
client,
|
||||
sessionID: target.session.id,
|
||||
agent: input.agent,
|
||||
model: target.model,
|
||||
explicit: explicit !== undefined || options.variant !== undefined,
|
||||
})
|
||||
if (!target.resume && input.title !== undefined) {
|
||||
await client.session.rename({
|
||||
sessionID: target.session.id,
|
||||
@@ -131,9 +142,6 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
location: target.location,
|
||||
message: prepared.message,
|
||||
files: prepared.files,
|
||||
agent: target.agent,
|
||||
model,
|
||||
variant,
|
||||
thinking: input.thinking ?? false,
|
||||
format: input.format,
|
||||
auto: input.auto ?? false,
|
||||
@@ -144,6 +152,25 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
|
||||
}).catch((error) => reportRunError(input, errorMessage(error), target.session.id))
|
||||
}
|
||||
|
||||
/** @internal Exported for CLI boundary tests. */
|
||||
export function applyRunSelection(input: {
|
||||
client: OpenCodeClient
|
||||
sessionID: SessionInfo["id"]
|
||||
agent?: string
|
||||
model?: ModelRef
|
||||
explicit: boolean
|
||||
}) {
|
||||
if (input.agent)
|
||||
return input.client.session.select({
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
model: input.explicit && input.model ? { type: "explicit", model: input.model } : { type: "configured" },
|
||||
})
|
||||
if (input.explicit && input.model)
|
||||
return input.client.session.switchModel({ sessionID: input.sessionID, model: input.model })
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
export function mergeInput(message: string | undefined, piped: string | undefined) {
|
||||
if (!message) return piped || undefined
|
||||
if (!piped) return message
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
|
||||
const maximumComponent = "9007199254740991"
|
||||
const versionPattern =
|
||||
/^v?([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
const currentVersion = parseReleaseVersion(current)
|
||||
const latestVersion = parseReleaseVersion(latest)
|
||||
if (!currentVersion || !latestVersion || sameRelease(currentVersion, latestVersion)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (currentVersion.major !== latestVersion.major) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
function parseReleaseVersion(input: string) {
|
||||
if (input.length > 256) return
|
||||
const match = input.trim().match(versionPattern)
|
||||
if (!match) return
|
||||
if ([match[1], match[2], match[3]].some(invalidComponent)) return
|
||||
if (
|
||||
match[4]
|
||||
?.split(".")
|
||||
.some((identifier) => identifier.length > 1 && identifier.startsWith("0") && /^[0-9]+$/.test(identifier))
|
||||
)
|
||||
return
|
||||
return {
|
||||
major: match[1],
|
||||
core: `${match[1]}.${match[2]}.${match[3]}`,
|
||||
prerelease: match[4]?.split(".") ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function sameRelease(current: NonNullable<ReturnType<typeof parseReleaseVersion>>, latest: typeof current) {
|
||||
if (current.core !== latest.core || current.prerelease.length !== latest.prerelease.length) return false
|
||||
return current.prerelease.every((identifier, index) => {
|
||||
const other = latest.prerelease[index]
|
||||
if (identifier === other) return true
|
||||
// semver compares oversized numeric prerelease identifiers after numeric coercion.
|
||||
return /^[0-9]+$/.test(identifier) && /^[0-9]+$/.test(other) && Number(identifier) === Number(other)
|
||||
})
|
||||
}
|
||||
|
||||
function invalidComponent(value: string) {
|
||||
if (value.length > 1 && value.startsWith("0")) return true
|
||||
if (value.length !== maximumComponent.length) return value.length > maximumComponent.length
|
||||
return value > maximumComponent
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { action } from "./updater-action"
|
||||
import { decodePolicy } from "./updater"
|
||||
import { action, decodePolicy } from "./updater"
|
||||
|
||||
describe("updater", () => {
|
||||
test("reads autoupdate from JSONC", () => {
|
||||
@@ -31,48 +30,4 @@ describe("updater", () => {
|
||||
test("upgrades when latest is lower (rollback)", () => {
|
||||
expect(action("1.2.4", "1.2.3", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("accepts strict release version variants", () => {
|
||||
expect(action("v1.2.3", " 1.2.4\n", true)).toBe("upgrade")
|
||||
expect(action("1.2.3-alpha.1", "1.2.3-alpha.2", true)).toBe("upgrade")
|
||||
expect(action("0.0.0-next-17403", "0.0.0-next-17403.2", true)).toBe("upgrade")
|
||||
expect(action("1.2.3+old", "1.2.3+new", true)).toBe("none")
|
||||
expect(action("v1.2.3+old", "1.2.3", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves strict validity", () => {
|
||||
const invalid = [
|
||||
"=1.2.3",
|
||||
"V1.2.3",
|
||||
"1.2",
|
||||
"1.2.3.4",
|
||||
"01.2.3",
|
||||
"1.02.3",
|
||||
"1.2.03",
|
||||
"1.2.3-01",
|
||||
"1.2.3-",
|
||||
"1.2.3+",
|
||||
"1.2.3-alpha..1",
|
||||
"1.2.3_alpha",
|
||||
"9007199254740992.0.0",
|
||||
"0.9007199254740992.0",
|
||||
"0.0.9007199254740992",
|
||||
]
|
||||
invalid.forEach((version) => expect(action("1.2.3", version, true), version).toBe("none"))
|
||||
})
|
||||
|
||||
test("handles numeric limits without losing precision", () => {
|
||||
expect(action("9007199254740991.0.0", "9007199254740991.0.1", true)).toBe("upgrade")
|
||||
expect(action("9007199254740990.0.0", "9007199254740991.0.0", true)).toBe("none")
|
||||
})
|
||||
|
||||
test("preserves equality for oversized numeric prerelease identifiers", () => {
|
||||
expect(action("1.0.0-9007199254740992", "1.0.0-9007199254740993", true)).toBe("none")
|
||||
expect(action("1.0.0-9007199254740991", "1.0.0-9007199254740992", true)).toBe("upgrade")
|
||||
})
|
||||
|
||||
test("rejects versions longer than semver's limit before trimming", () => {
|
||||
expect(action("1.2.3", `${" ".repeat(251)}1.2.3`, true)).toBe("none")
|
||||
expect(action("1.2.3", `1.2.4+${"a".repeat(250)}`, true)).toBe("upgrade")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -5,10 +5,12 @@ import { Context, Duration, Effect, FileSystem, Layer } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { action, type Policy } from "./updater-action"
|
||||
import semver from "semver"
|
||||
|
||||
declare const OPENCODE_CLI_NAME: string | undefined
|
||||
|
||||
export type Policy = boolean | "notify"
|
||||
export type Action = "none" | "upgrade"
|
||||
type Method = "npm" | "pnpm" | "bun" | "yarn"
|
||||
|
||||
const packageName =
|
||||
@@ -32,6 +34,14 @@ export function decodePolicy(text: string): Policy | undefined {
|
||||
if (typeof value === "boolean" || value === "notify") return value
|
||||
}
|
||||
|
||||
export function action(current: string, latest: string, policy: Policy): Action {
|
||||
if (policy === false) return "none"
|
||||
if (!semver.valid(current) || !semver.valid(latest) || semver.eq(latest, current)) return "none"
|
||||
// Major upgrades are never installed automatically.
|
||||
if (semver.major(latest) !== semver.major(current)) return "none"
|
||||
return "upgrade"
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -156,4 +166,3 @@ export const layer = Layer.effect(
|
||||
)
|
||||
|
||||
export * as Updater from "./updater"
|
||||
export { action, type Action, type Policy } from "./updater-action"
|
||||
|
||||
@@ -49,6 +49,7 @@ describe("acp permission behavior", () => {
|
||||
send(
|
||||
permissionAsked("ses_allow", "perm_always", {
|
||||
action: "read",
|
||||
save: ["/workspace/file.ts"],
|
||||
metadata: { path: "/workspace/file.ts" },
|
||||
source: { type: "tool", messageID: "msg_allow", id: "call_always" },
|
||||
}),
|
||||
@@ -84,10 +85,10 @@ describe("acp permission behavior", () => {
|
||||
},
|
||||
options: [
|
||||
{ optionId: "once", kind: "allow_once", name: "Allow once" },
|
||||
{ optionId: "always", kind: "allow_always", name: "Always allow" },
|
||||
{ optionId: "reject", kind: "reject_once", name: "Reject" },
|
||||
],
|
||||
})
|
||||
expect(permissionRequests[0]?.options.map((option) => option.optionId)).toEqual(["once", "reject"])
|
||||
expect(permissionRequests[1]).toMatchObject({
|
||||
sessionId: "ses_allow",
|
||||
toolCall: {
|
||||
@@ -557,6 +558,7 @@ function permissionAsked(
|
||||
input: {
|
||||
readonly action?: string
|
||||
readonly metadata?: Record<string, unknown>
|
||||
readonly save?: string[]
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
} = {},
|
||||
) {
|
||||
@@ -565,6 +567,7 @@ function permissionAsked(
|
||||
sessionID,
|
||||
action: input.action ?? "shell",
|
||||
resources: ["*"],
|
||||
...(input.save ? { save: input.save } : {}),
|
||||
metadata: input.metadata ?? { command: "printf hello" },
|
||||
...(input.source ? { source: input.source } : {}),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
import { Effect } from "effect"
|
||||
import { defineScript, Llm } from "opencode-drive"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
config: { autoupdate: false },
|
||||
run: ({ artifacts, llm, server }) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => configureServicePort(artifacts))
|
||||
yield* server.launch()
|
||||
|
||||
const registration = yield* Effect.promise(() => serviceRegistration(artifacts))
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const preload = Bun.resolveSync("@opentui/solid/preload", path.join(root, "packages/cli"))
|
||||
const session = `mini-stage2-${process.pid}`
|
||||
const snapshots = path.join(artifacts, "mini-stage2")
|
||||
const explicitDirectory = path.join(artifacts, "explicit-model")
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([snapshots, explicitDirectory].map((dir) => mkdir(dir, { recursive: true }))),
|
||||
)
|
||||
/** @param {string} directory @param {string | undefined} model */
|
||||
const mini = (directory, model) => [
|
||||
"env",
|
||||
`PWD=${directory}`,
|
||||
`OPENCODE_PASSWORD=${registration.password}`,
|
||||
`OPENCODE_CONFIG_DIR=${path.join(artifacts, "files/.opencode")}`,
|
||||
`OPENCODE_TEST_HOME=${artifacts}`,
|
||||
`XDG_CACHE_HOME=${path.join(artifacts, "home/.cache")}`,
|
||||
`XDG_CONFIG_HOME=${path.join(artifacts, "home/.config")}`,
|
||||
`XDG_DATA_HOME=${path.join(artifacts, "logs")}`,
|
||||
`XDG_STATE_HOME=${path.join(artifacts, "home/.local/state")}`,
|
||||
"OPENCODE_DISABLE_AUTOUPDATE=1",
|
||||
"OPENCODE_DIRECT_TRACE=1",
|
||||
process.execPath,
|
||||
"--conditions=browser",
|
||||
`--preload=${preload}`,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"mini",
|
||||
"--server",
|
||||
registration.url,
|
||||
...(model ? ["--model", model] : []),
|
||||
]
|
||||
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "mini-shell",
|
||||
name: "shell",
|
||||
input: { command: "printf 'drive-mini-tool-output\\n'" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* llm.queue(Llm.text("drive mini response complete", { delay: 5, chunkSize: 4 }))
|
||||
|
||||
const journey = Effect.gen(function* () {
|
||||
yield* Effect.uninterruptible(
|
||||
Effect.promise(() =>
|
||||
tmux([
|
||||
"new-session",
|
||||
"-d",
|
||||
"-s",
|
||||
session,
|
||||
"-x",
|
||||
"140",
|
||||
"-y",
|
||||
"30",
|
||||
"--",
|
||||
...mini(path.join(artifacts, "files"), undefined),
|
||||
]),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() => tmux(["set-option", "-t", session, "remain-on-exit", "on"]))
|
||||
|
||||
const first = yield* Effect.promise(() => waitForPane(session, "OpenCode"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "01-first-paint.txt"), first))
|
||||
if (first.includes("drive mini response complete"))
|
||||
throw new Error("response rendered before prompt submission")
|
||||
|
||||
yield* Effect.promise(() => waitForPane(session, "Default model", 15_000))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-p"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Commands"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "model"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Switch model"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Select model"))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Simulated Model", 15_000))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||
yield* Effect.promise(() => waitForVisiblePane(session, "Ask anything..."))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the mini frontend"]))
|
||||
yield* Effect.sleep(100)
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
const completed = yield* Effect.promise(() => waitForPane(session, "drive mini response complete", 20_000))
|
||||
if (!completed.includes("drive-mini-tool-output")) throw new Error("shell tool output was not rendered")
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "02-tool-and-response.txt"), completed))
|
||||
|
||||
yield* Effect.sleep(500)
|
||||
const resizeOutput = path.join(snapshots, "03-resize-output.ansi")
|
||||
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session, `cat > ${JSON.stringify(resizeOutput)}`]))
|
||||
yield* Effect.promise(() => tmux(["resize-window", "-t", session, "-x", "72", "-y", "22"]))
|
||||
yield* Effect.promise(() =>
|
||||
waitForFile(
|
||||
resizeOutput,
|
||||
(value) => value.includes("drive mini response complete") && value.includes("drive-mini-tool-output"),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() => tmux(["pipe-pane", "-t", session]))
|
||||
const resized = yield* Effect.promise(() => captureVisiblePane(session))
|
||||
if (!resized.includes("drive-mini-tool-output")) throw new Error("resize replay lost shell tool output")
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "03-resize-replay.txt"), resized))
|
||||
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "mini-question",
|
||||
name: "question",
|
||||
input: {
|
||||
questions: [
|
||||
{
|
||||
header: "Drive form",
|
||||
question: "Choose the Mini Form answer",
|
||||
options: [{ label: "Accepted", description: "Continue the run" }],
|
||||
multiple: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* llm.queue(Llm.text("drive mini form complete"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "exercise the form"]))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "Choose the Mini Form answer", 20_000))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "drive mini form complete", 20_000))
|
||||
|
||||
yield* llm.queue(
|
||||
Llm.toolCall({
|
||||
index: 0,
|
||||
id: "mini-slow-shell",
|
||||
name: "shell",
|
||||
input: { command: "sleep 10" },
|
||||
}),
|
||||
Llm.finish("tool-calls"),
|
||||
)
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "-l", "interrupt this turn"]))
|
||||
yield* Effect.sleep(100)
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-H", "-t", session, "0d"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "$ sleep 10"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||
const armed = yield* Effect.promise(() => waitForPane(session, "esc again"))
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "04-interrupt-armed.txt"), armed))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "Escape"]))
|
||||
const interrupted = yield* Effect.promise(() => waitForPane(session, "Step interrupted", 10_000))
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "05-interrupted.txt"), interrupted))
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
if (!(await paneAlive(session))) throw new Error("Mini exited while interrupting an active turn")
|
||||
})
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForDeadPane(session))
|
||||
const status = yield* Effect.promise(() => paneDeadStatus(session))
|
||||
if (status !== 0) throw new Error(`Mini exited with status ${status}`)
|
||||
const exited = yield* Effect.promise(() => capturePane(session))
|
||||
if (!exited.includes("Continue") || !exited.includes("opencode mini -s"))
|
||||
throw new Error("Mini exit splash was not rendered before teardown")
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "06-exit-teardown.txt"), exited))
|
||||
|
||||
yield* Effect.promise(() => tmux(["clear-history", "-t", session]))
|
||||
yield* Effect.promise(() =>
|
||||
tmux(["respawn-pane", "-k", "-t", session, "--", ...mini(explicitDirectory, "simulation/gpt-sim-model")]),
|
||||
)
|
||||
const explicitModel = yield* Effect.promise(() => waitForPane(session, "Simulated Model", 15_000))
|
||||
yield* Effect.promise(() => Bun.write(path.join(snapshots, "07-explicit-model.txt"), explicitModel))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForPane(session, "EXIT Press ctrl+"))
|
||||
yield* Effect.promise(() => tmux(["send-keys", "-t", session, "C-c"]))
|
||||
yield* Effect.promise(() => waitForDeadPane(session))
|
||||
if ((yield* Effect.promise(() => paneDeadStatus(session))) !== 0)
|
||||
throw new Error("Explicit-model Mini did not exit cleanly")
|
||||
|
||||
yield* Effect.promise(async () => {
|
||||
for (const failure of [
|
||||
{
|
||||
args: ["--model", "simulation/definitely-missing"],
|
||||
capture: "08-unavailable-model.txt",
|
||||
expected: "Model unavailable: simulation/definitely-missing",
|
||||
},
|
||||
{
|
||||
args: ["--agent", "definitely-missing"],
|
||||
capture: "09-unavailable-agent.txt",
|
||||
expected: 'Agent not found: "definitely-missing"',
|
||||
},
|
||||
]) {
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"run",
|
||||
"--server",
|
||||
registration.url,
|
||||
...failure.args,
|
||||
"optimistic selection check",
|
||||
],
|
||||
{
|
||||
cwd: path.join(root, "packages/cli"),
|
||||
env: {
|
||||
...process.env,
|
||||
PWD: path.join(artifacts, "files"),
|
||||
OPENCODE_PASSWORD: registration.password,
|
||||
OPENCODE_CONFIG_DIR: path.join(artifacts, "files/.opencode"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
await Bun.write(path.join(snapshots, failure.capture), stdout + stderr)
|
||||
if (exitCode !== 1) throw new Error(`${failure.expected} run exited with status ${exitCode}`)
|
||||
if (!stderr.includes(failure.expected))
|
||||
throw new Error(`Selection failure was not diagnosed by execution: ${stderr}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
yield* journey.pipe(Effect.ensuring(Effect.promise(() => tmux(["kill-session", "-t", session], true))))
|
||||
}),
|
||||
})
|
||||
|
||||
/** @param {string[]} args */
|
||||
async function tmux(args, allowFailure = false) {
|
||||
const child = Bun.spawn(["tmux", ...args], { stdout: "pipe", stderr: "pipe" })
|
||||
let timedOut = false
|
||||
const timeout = setTimeout(() => {
|
||||
timedOut = true
|
||||
child.kill("SIGKILL")
|
||||
}, 5_000)
|
||||
const [status, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
clearTimeout(timeout)
|
||||
if (timedOut) throw new Error(`tmux ${args[0]} timed out`)
|
||||
if (status !== 0 && !allowFailure) throw new Error(`tmux ${args[0]} failed: ${stderr || stdout}`)
|
||||
return stdout
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
function capturePane(session) {
|
||||
return tmux(["capture-pane", "-p", "-t", session, "-S", "-"])
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
function captureVisiblePane(session) {
|
||||
return tmux(["capture-pane", "-p", "-t", session])
|
||||
}
|
||||
|
||||
/** @param {string} session @param {string} text @param {number} [timeout] */
|
||||
async function waitForVisiblePane(session, text, timeout = 5_000) {
|
||||
const deadline = Date.now() + timeout
|
||||
let last = ""
|
||||
while (Date.now() < deadline) {
|
||||
last = await captureVisiblePane(session)
|
||||
if (last.includes(text)) return last
|
||||
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error(`Timed out waiting for visible ${JSON.stringify(text)}:\n${last}`)
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
async function paneAlive(session) {
|
||||
return (await tmux(["display-message", "-p", "-t", session, "#{pane_dead}"], true)).trim() === "0"
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
async function paneDeadStatus(session) {
|
||||
return Number((await tmux(["display-message", "-p", "-t", session, "#{pane_dead_status}"])).trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} session
|
||||
* @param {string} text
|
||||
* @param {number} [timeout]
|
||||
* @param {(() => Promise<void>) | undefined} [trigger]
|
||||
*/
|
||||
async function waitForPane(session, text, timeout = 5_000, trigger) {
|
||||
const deadline = Date.now() + timeout
|
||||
let last = ""
|
||||
while (Date.now() < deadline) {
|
||||
await trigger?.()
|
||||
last = await capturePane(session)
|
||||
if (last.includes(text)) return last
|
||||
if (!(await paneAlive(session))) throw new Error(`Mini exited before rendering ${JSON.stringify(text)}:\n${last}`)
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${JSON.stringify(text)}:\n${last}`)
|
||||
}
|
||||
|
||||
/** @param {string} session */
|
||||
async function waitForDeadPane(session) {
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
if (!(await paneAlive(session))) return
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Mini did not tear down after the exit sequence")
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} file
|
||||
* @param {(value: string) => boolean} accept
|
||||
*/
|
||||
async function waitForFile(file, accept) {
|
||||
let value = ""
|
||||
for (let attempt = 0; attempt < 100; attempt++) {
|
||||
value = await Bun.file(file)
|
||||
.text()
|
||||
.catch(() => "")
|
||||
if (accept(value)) return value
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("resize did not replay committed transcript output")
|
||||
}
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function configureServicePort(artifacts) {
|
||||
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||
const port = probe.port
|
||||
await probe.stop(true)
|
||||
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await Bun.write(file, JSON.stringify({ port }))
|
||||
}
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function serviceRegistration(artifacts) {
|
||||
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
for (const name of ["service-local.json", "service.json"]) {
|
||||
const value = await Bun.file(path.join(directory, name))
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
if (isRegistration(value)) return value
|
||||
}
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Drive service registration was not written")
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function isRegistration(value) {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"url" in value &&
|
||||
typeof value.url === "string" &&
|
||||
"password" in value &&
|
||||
typeof value.password === "string"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { defineScript } from "opencode-drive"
|
||||
import { mkdir } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
export default defineScript({
|
||||
launch: "manual",
|
||||
setup({ config }) {
|
||||
config.autoupdate = false
|
||||
},
|
||||
async run({ artifacts, llm, server }) {
|
||||
await configureServicePort(artifacts)
|
||||
llm.queue(llm.text("drive noninteractive smoke ok"))
|
||||
await server.launch()
|
||||
|
||||
const registration = await serviceRegistration(artifacts)
|
||||
const root = path.resolve(import.meta.dir, "../../../..")
|
||||
const directory = path.join(artifacts, "files")
|
||||
const child = Bun.spawn(
|
||||
[
|
||||
process.execPath,
|
||||
path.join(root, "packages/cli/src/index.ts"),
|
||||
"run",
|
||||
"--server",
|
||||
registration.url,
|
||||
"drive smoke",
|
||||
],
|
||||
{
|
||||
cwd: path.join(root, "packages/cli"),
|
||||
env: {
|
||||
...process.env,
|
||||
PWD: directory,
|
||||
OPENCODE_PASSWORD: registration.password,
|
||||
OPENCODE_CONFIG_DIR: path.join(directory, ".opencode"),
|
||||
OPENCODE_DISABLE_AUTOUPDATE: "1",
|
||||
},
|
||||
stdin: "ignore",
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
)
|
||||
const [exitCode, stdout, stderr] = await Promise.all([
|
||||
child.exited,
|
||||
new Response(child.stdout).text(),
|
||||
new Response(child.stderr).text(),
|
||||
])
|
||||
if (exitCode !== 0) throw new Error(`run exited ${exitCode}: ${stderr}`)
|
||||
if (stdout !== "drive noninteractive smoke ok\n") throw new Error(`unexpected run output: ${stdout}`)
|
||||
},
|
||||
})
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function configureServicePort(artifacts) {
|
||||
const probe = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() })
|
||||
const port = probe.port
|
||||
await probe.stop(true)
|
||||
if (!port) throw new Error("Failed to allocate a Drive service port")
|
||||
const file = path.join(artifacts, "files/.opencode/service-local.json")
|
||||
await mkdir(path.dirname(file), { recursive: true })
|
||||
await Bun.write(file, JSON.stringify({ port }))
|
||||
}
|
||||
|
||||
/** @param {string} artifacts */
|
||||
async function serviceRegistration(artifacts) {
|
||||
const directory = path.join(artifacts, "home/.local/state/opencode")
|
||||
for (let attempt = 0; attempt < 200; attempt++) {
|
||||
for (const name of ["service-local.json", "service.json"]) {
|
||||
const value = await Bun.file(path.join(directory, name))
|
||||
.json()
|
||||
.catch(() => undefined)
|
||||
if (isRegistration(value)) return value
|
||||
}
|
||||
await Bun.sleep(50)
|
||||
}
|
||||
throw new Error("Drive service registration was not written")
|
||||
}
|
||||
|
||||
/** @param {unknown} value */
|
||||
function isRegistration(value) {
|
||||
return (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
"url" in value &&
|
||||
typeof value.url === "string" &&
|
||||
"password" in value &&
|
||||
typeof value.password === "string"
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,10 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { collectNodeAssets } from "../script/node-assets"
|
||||
import { nodeTarget, shellParserWasmAssets } from "../src/node/target"
|
||||
import { nodeTarget } from "../src/node/target"
|
||||
|
||||
test("collects each SEA asset key once", async () => {
|
||||
const assets = await collectNodeAssets(nodeTarget(process.platform, process.arch))
|
||||
const keys = assets.map((asset) => asset.key)
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length)
|
||||
expect(assets.filter((asset) => asset.key === shellParserWasmAssets.runtime)).toEqual([
|
||||
{
|
||||
key: shellParserWasmAssets.runtime,
|
||||
source: fileURLToPath(import.meta.resolve(shellParserWasmAssets.runtime)),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -240,6 +240,8 @@ async function run(input: {
|
||||
})()
|
||||
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
||||
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
||||
spyOn(sdk.question, "list").mockImplementation(() => ok([]) as never)
|
||||
spyOn(sdk.question, "reject").mockImplementation(() => ok(undefined) as never)
|
||||
spyOn(sdk.form, "list").mockImplementation(
|
||||
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
||||
)
|
||||
@@ -433,6 +435,8 @@ describe("runNonInteractivePrompt", () => {
|
||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||
location: { directory: "/work tree", workspace: "wrk_1" },
|
||||
})
|
||||
expect(sdk.question.list).not.toHaveBeenCalled()
|
||||
expect(sdk.question.reject).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
test("attach mode cancels only session-owned forms", async () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { OpenCode } from "@opencode-ai/client/promise"
|
||||
import { applyRunSelection } from "../../src/run/run"
|
||||
|
||||
afterEach(() => mock.restore())
|
||||
|
||||
describe("run selection", () => {
|
||||
test("resolves the configured model when an agent is explicit", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const select = spyOn(client.session, "select").mockResolvedValue(undefined)
|
||||
|
||||
await applyRunSelection({ client, sessionID: "ses_test", agent: "modelprobe", explicit: false })
|
||||
|
||||
expect(select).toHaveBeenCalledWith({
|
||||
sessionID: "ses_test",
|
||||
agent: "modelprobe",
|
||||
model: { type: "configured" },
|
||||
})
|
||||
})
|
||||
|
||||
test("selects an explicit agent and model atomically", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const select = spyOn(client.session, "select").mockResolvedValue(undefined)
|
||||
|
||||
await applyRunSelection({
|
||||
client,
|
||||
sessionID: "ses_test",
|
||||
agent: "modelprobe",
|
||||
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
|
||||
explicit: true,
|
||||
})
|
||||
|
||||
expect(select).toHaveBeenCalledWith({
|
||||
sessionID: "ses_test",
|
||||
agent: "modelprobe",
|
||||
model: {
|
||||
type: "explicit",
|
||||
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("switches only the model when no agent is explicit", async () => {
|
||||
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
|
||||
const switchModel = spyOn(client.session, "switchModel").mockResolvedValue(undefined)
|
||||
|
||||
await applyRunSelection({
|
||||
client,
|
||||
sessionID: "ses_test",
|
||||
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
|
||||
explicit: true,
|
||||
})
|
||||
|
||||
expect(switchModel).toHaveBeenCalledWith({
|
||||
sessionID: "ses_test",
|
||||
model: { providerID: "opencode", id: "deepseek-v4-flash-free" },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,8 +3,7 @@ import { readFile } from "node:fs/promises"
|
||||
import { createRequire } from "node:module"
|
||||
import { defineConfig, type Plugin, type UserConfig } from "vite"
|
||||
import solid from "vite-plugin-solid"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset, shellParserWasmAssets } from "./src/node/target"
|
||||
import { verifySimulationGraph } from "./script/verify-artifact"
|
||||
import { nodeExecArgv, nodeTarget, type NodeTarget, photonWasmAsset } from "./src/node/target"
|
||||
|
||||
const dir = import.meta.dirname
|
||||
|
||||
@@ -49,15 +48,6 @@ function runtimeRequirePlugin(): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
function simulationGraphPlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:simulation-graph",
|
||||
generateBundle() {
|
||||
verifySimulationGraph(this.getModuleIds())
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function fffNodePlugin(): Plugin {
|
||||
return {
|
||||
name: "opencode:fff-node",
|
||||
@@ -222,9 +212,6 @@ process.env.OTUI_ASSET_ROOT = __ocAssetRoot
|
||||
process.env.OPENCODE_NODE_PTY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.nodePtyEntryAsset)})
|
||||
process.env.OPENCODE_PARCEL_WATCHER_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.parcelWatcherAsset)})
|
||||
process.env.OPENCODE_PHOTON_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(photonWasmAsset)})
|
||||
process.env.OPENCODE_TREE_SITTER_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.runtime)})
|
||||
process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.bash)})
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(shellParserWasmAssets.powershell)})
|
||||
process.env.FFF_BINARY_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffAsset)})
|
||||
process.env.OPENCODE_FFF_FFI_PATH = __ocPath.join(__ocAssetRoot, ${JSON.stringify(input.target.fffFfiAsset)})
|
||||
try {
|
||||
@@ -250,7 +237,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
rawTextPlugin(),
|
||||
runtimeRequirePlugin(),
|
||||
fffNodePlugin(),
|
||||
simulationGraphPlugin(),
|
||||
solid({
|
||||
solid: {
|
||||
generate: "universal",
|
||||
@@ -266,7 +252,6 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
OPENCODE_CHANNEL: JSON.stringify(input.channel),
|
||||
OPENCODE_LIBC: input.target.platform === "linux" ? JSON.stringify("glibc") : "undefined",
|
||||
FFF_LIBC: input.target.platform === "linux" ? JSON.stringify("gnu") : "undefined",
|
||||
"process.env.WS_NO_BUFFER_UTIL": JSON.stringify("1"),
|
||||
},
|
||||
ssr: { noExternal: true },
|
||||
build: {
|
||||
@@ -276,6 +261,7 @@ export function mainConfig(input: NodeBuildInput): UserConfig {
|
||||
emptyOutDir: false,
|
||||
minify: true,
|
||||
rollupOptions: {
|
||||
external: [/^@opencode-ai\/simulation(?:\/|$)/],
|
||||
output: output("opencode.mjs", nodePrelude(input)),
|
||||
},
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ import type { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
import type { Command } from "@opencode-ai/schema/command"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/protocol/groups/event"
|
||||
import type { Pty } from "@opencode-ai/schema/pty"
|
||||
import type { Question } from "@opencode-ai/schema/question"
|
||||
import type { Reference } from "@opencode-ai/schema/reference"
|
||||
import type { Worktree } from "@opencode-ai/schema/worktree"
|
||||
import type { Vcs } from "@opencode-ai/schema/vcs"
|
||||
@@ -153,28 +154,39 @@ export type Endpoint5_7Input = { readonly sessionID: Session.ID; readonly bounda
|
||||
export type Endpoint5_7Output = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: Endpoint5_7Input) => Effect.Effect<Endpoint5_7Output, E>
|
||||
|
||||
export type Endpoint5_8Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_8Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly agent: Agent.ID
|
||||
readonly model:
|
||||
| { readonly type: "preserve" }
|
||||
| { readonly type: "configured" }
|
||||
| { readonly type: "explicit"; readonly model: Model.Ref }
|
||||
}
|
||||
export type Endpoint5_8Output = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
export type SessionSelectOperation<E = never> = (input: Endpoint5_8Input) => Effect.Effect<Endpoint5_8Output, E>
|
||||
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_9Input = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type Endpoint5_9Output = void
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
export type SessionSwitchAgentOperation<E = never> = (input: Endpoint5_9Input) => Effect.Effect<Endpoint5_9Output, E>
|
||||
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_10Input = { readonly sessionID: Session.ID; readonly model: Model.Ref }
|
||||
export type Endpoint5_10Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
export type SessionSwitchModelOperation<E = never> = (input: Endpoint5_10Input) => Effect.Effect<Endpoint5_10Output, E>
|
||||
|
||||
export type Endpoint5_11Input = {
|
||||
export type Endpoint5_11Input = { readonly sessionID: Session.ID; readonly title: string }
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionRenameOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly workspaceID?: Workspace.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_11Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_11Input) => Effect.Effect<Endpoint5_11Output, E>
|
||||
export type Endpoint5_12Output = void
|
||||
export type SessionMoveOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
|
||||
export type Endpoint5_12Input = {
|
||||
export type Endpoint5_13Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -185,10 +197,10 @@ export type Endpoint5_12Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_12Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_12Input) => Effect.Effect<Endpoint5_12Output, E>
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionPromptOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
|
||||
export type Endpoint5_13Input = {
|
||||
export type Endpoint5_14Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly command: string
|
||||
@@ -201,19 +213,19 @@ export type Endpoint5_13Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_13Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_13Input) => Effect.Effect<Endpoint5_13Output, E>
|
||||
export type Endpoint5_14Output = SessionInbox.User
|
||||
export type SessionCommandOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
|
||||
export type Endpoint5_14Input = {
|
||||
export type Endpoint5_15Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly skill: Skill.ID
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_14Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_14Input) => Effect.Effect<Endpoint5_14Output, E>
|
||||
export type Endpoint5_15Output = void
|
||||
export type SessionSkillOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
|
||||
export type Endpoint5_15Input = {
|
||||
export type Endpoint5_16Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly text: string
|
||||
@@ -222,97 +234,97 @@ export type Endpoint5_15Input = {
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
readonly resume?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_15Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_15Input) => Effect.Effect<Endpoint5_15Output, E>
|
||||
export type Endpoint5_16Output = SessionInbox.Synthetic
|
||||
export type SessionSyntheticOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
|
||||
export type Endpoint5_16Input = {
|
||||
export type Endpoint5_17Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: Event.ID | undefined
|
||||
readonly command: string
|
||||
}
|
||||
export type Endpoint5_16Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_16Input) => Effect.Effect<Endpoint5_16Output, E>
|
||||
export type Endpoint5_17Output = void
|
||||
export type SessionShellOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
|
||||
export type Endpoint5_17Input = {
|
||||
export type Endpoint5_18Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly id?: SessionMessage.ID | undefined
|
||||
readonly delivery?: SessionInbox.Delivery | undefined
|
||||
}
|
||||
export type Endpoint5_17Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_17Input) => Effect.Effect<Endpoint5_17Output, E>
|
||||
export type Endpoint5_18Output = SessionInbox.Compaction
|
||||
export type SessionCompactOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
|
||||
export type Endpoint5_18Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_18Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_18Input) => Effect.Effect<Endpoint5_18Output, E>
|
||||
export type Endpoint5_19Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_19Output = void
|
||||
export type SessionWaitOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_19Input = {
|
||||
export type Endpoint5_20Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly messageID: SessionMessage.ID
|
||||
readonly files?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_19Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_19Input) => Effect.Effect<Endpoint5_19Output, E>
|
||||
|
||||
export type Endpoint5_20Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_20Output = void
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
export type Endpoint5_20Output = Session.Revert
|
||||
export type SessionRevertStageOperation<E = never> = (input: Endpoint5_20Input) => Effect.Effect<Endpoint5_20Output, E>
|
||||
|
||||
export type Endpoint5_21Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_21Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
export type SessionRevertClearOperation<E = never> = (input: Endpoint5_21Input) => Effect.Effect<Endpoint5_21Output, E>
|
||||
|
||||
export type Endpoint5_22Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_22Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
export type Endpoint5_22Output = void
|
||||
export type SessionRevertCommitOperation<E = never> = (input: Endpoint5_22Input) => Effect.Effect<Endpoint5_22Output, E>
|
||||
|
||||
export type Endpoint5_23Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
export type Endpoint5_23Output = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: Endpoint5_23Input) => Effect.Effect<Endpoint5_23Output, E>
|
||||
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_24Output = void
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
export type Endpoint5_24Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_24Output = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (input: Endpoint5_24Input) => Effect.Effect<Endpoint5_24Output, E>
|
||||
|
||||
export type Endpoint5_25Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_25Output = void
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
export type SessionInboxCancelOperation<E = never> = (input: Endpoint5_25Input) => Effect.Effect<Endpoint5_25Output, E>
|
||||
|
||||
export type Endpoint5_26Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_26Output = void
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
export type SessionInboxSteerOperation<E = never> = (input: Endpoint5_26Input) => Effect.Effect<Endpoint5_26Output, E>
|
||||
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_27Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type Endpoint5_27Input = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
|
||||
export type Endpoint5_27Output = void
|
||||
export type SessionInboxQueueOperation<E = never> = (input: Endpoint5_27Input) => Effect.Effect<Endpoint5_27Output, E>
|
||||
|
||||
export type Endpoint5_28Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_28Output = ReadonlyArray<InstructionEntry.Info>
|
||||
export type SessionInstructionsEntryListOperation<E = never> = (
|
||||
input: Endpoint5_27Input,
|
||||
) => Effect.Effect<Endpoint5_27Output, E>
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_28Input = {
|
||||
export type Endpoint5_29Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly key: InstructionEntry.Key
|
||||
readonly value: Schema.Json
|
||||
}
|
||||
export type Endpoint5_28Output = void
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_28Input,
|
||||
) => Effect.Effect<Endpoint5_28Output, E>
|
||||
|
||||
export type Endpoint5_29Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_29Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
export type SessionInstructionsEntryPutOperation<E = never> = (
|
||||
input: Endpoint5_29Input,
|
||||
) => Effect.Effect<Endpoint5_29Output, E>
|
||||
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_30Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_30Input) => Effect.Effect<Endpoint5_30Output, E>
|
||||
export type Endpoint5_30Input = { readonly sessionID: Session.ID; readonly key: InstructionEntry.Key }
|
||||
export type Endpoint5_30Output = void
|
||||
export type SessionInstructionsEntryRemoveOperation<E = never> = (
|
||||
input: Endpoint5_30Input,
|
||||
) => Effect.Effect<Endpoint5_30Output, E>
|
||||
|
||||
export type Endpoint5_31Input = {
|
||||
export type Endpoint5_31Input = { readonly sessionID: Session.ID; readonly prompt: string }
|
||||
export type Endpoint5_31Output = { readonly text: string }
|
||||
export type SessionGenerateOperation<E = never> = (input: Endpoint5_31Input) => Effect.Effect<Endpoint5_31Output, E>
|
||||
|
||||
export type Endpoint5_32Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly after?: Event.Seq | undefined
|
||||
readonly follow?: boolean | undefined
|
||||
}
|
||||
export type Endpoint5_31Output =
|
||||
export type Endpoint5_32Output =
|
||||
| (
|
||||
| {
|
||||
readonly id: Event.ID
|
||||
@@ -901,19 +913,19 @@ export type Endpoint5_31Output =
|
||||
}
|
||||
)
|
||||
| EventLog.Synced
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_31Input) => Stream.Stream<Endpoint5_31Output, E>
|
||||
export type SessionLogOperation<E = never> = (input: Endpoint5_32Input) => Stream.Stream<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_32Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_32Output = void
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_32Input) => Effect.Effect<Endpoint5_32Output, E>
|
||||
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_33Input = { readonly sessionID: Session.ID; readonly continue?: boolean | undefined }
|
||||
export type Endpoint5_33Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
export type SessionInterruptOperation<E = never> = (input: Endpoint5_33Input) => Effect.Effect<Endpoint5_33Output, E>
|
||||
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_34Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
export type Endpoint5_34Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint5_34Output = void
|
||||
export type SessionBackgroundOperation<E = never> = (input: Endpoint5_34Input) => Effect.Effect<Endpoint5_34Output, E>
|
||||
|
||||
export type Endpoint5_35Input = { readonly sessionID: Session.ID; readonly messageID: SessionMessage.ID }
|
||||
export type Endpoint5_35Output = SessionMessage.Info
|
||||
export type SessionMessageOperation<E = never> = (input: Endpoint5_35Input) => Effect.Effect<Endpoint5_35Output, E>
|
||||
|
||||
export interface SessionApi<E = never> {
|
||||
readonly list: SessionListOperation<E>
|
||||
@@ -924,6 +936,7 @@ export interface SessionApi<E = never> {
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
readonly fork: SessionForkOperation<E>
|
||||
readonly select: SessionSelectOperation<E>
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
readonly switchModel: SessionSwitchModelOperation<E>
|
||||
readonly rename: SessionRenameOperation<E>
|
||||
@@ -1306,6 +1319,7 @@ export type Endpoint15_3Input = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string> | undefined
|
||||
readonly opaque?: boolean | undefined
|
||||
readonly metadata?: { readonly [x: string]: unknown } | undefined
|
||||
readonly source?: Permission.Source | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
@@ -1502,38 +1516,69 @@ export interface ShellApi<E = never> {
|
||||
export type Endpoint22_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint22_0Input) => Effect.Effect<Endpoint22_0Output, E>
|
||||
export type Endpoint22_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Question.Request> }
|
||||
export type QuestionRequestListOperation<E = never> = (
|
||||
input?: Endpoint22_0Input,
|
||||
) => Effect.Effect<Endpoint22_0Output, E>
|
||||
|
||||
export type Endpoint22_1Input = { readonly sessionID: Session.ID }
|
||||
export type Endpoint22_1Output = ReadonlyArray<Question.Request>
|
||||
export type QuestionListOperation<E = never> = (input: Endpoint22_1Input) => Effect.Effect<Endpoint22_1Output, E>
|
||||
|
||||
export type Endpoint22_2Input = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly requestID: Question.ID
|
||||
readonly answers: ReadonlyArray<Question.Answer>
|
||||
}
|
||||
export type Endpoint22_2Output = void
|
||||
export type QuestionReplyOperation<E = never> = (input: Endpoint22_2Input) => Effect.Effect<Endpoint22_2Output, E>
|
||||
|
||||
export type Endpoint22_3Input = { readonly sessionID: Session.ID; readonly requestID: Question.ID }
|
||||
export type Endpoint22_3Output = void
|
||||
export type QuestionRejectOperation<E = never> = (input: Endpoint22_3Input) => Effect.Effect<Endpoint22_3Output, E>
|
||||
|
||||
export interface QuestionApi<E = never> {
|
||||
readonly request: { readonly list: QuestionRequestListOperation<E> }
|
||||
readonly list: QuestionListOperation<E>
|
||||
readonly reply: QuestionReplyOperation<E>
|
||||
readonly reject: QuestionRejectOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint23_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint23_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Reference.Info> }
|
||||
export type ReferenceListOperation<E = never> = (input?: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||
|
||||
export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint23_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint23_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint23_0Input) => Effect.Effect<Endpoint23_0Output, E>
|
||||
export type Endpoint24_0Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_0Output = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
|
||||
export type Endpoint23_1Input = {
|
||||
export type Endpoint24_1Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly strategy: Worktree.StrategyID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly directory: AbsolutePath
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type Endpoint23_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint23_1Input) => Effect.Effect<Endpoint23_1Output, E>
|
||||
export type Endpoint24_1Output = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
|
||||
export type Endpoint23_2Input = {
|
||||
export type Endpoint24_2Input = {
|
||||
readonly projectID: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type Endpoint23_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint23_2Input) => Effect.Effect<Endpoint23_2Output, E>
|
||||
export type Endpoint24_2Output = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
|
||||
export type Endpoint23_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint23_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint23_3Input) => Effect.Effect<Endpoint23_3Output, E>
|
||||
export type Endpoint24_3Input = { readonly projectID: Project.ID }
|
||||
export type Endpoint24_3Output = void
|
||||
export type WorktreeRefreshOperation<E = never> = (input: Endpoint24_3Input) => Effect.Effect<Endpoint24_3Output, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
readonly list: WorktreeListOperation<E>
|
||||
@@ -1542,25 +1587,25 @@ export interface WorktreeApi<E = never> {
|
||||
readonly refresh: WorktreeRefreshOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint24_0Input = {
|
||||
export type Endpoint25_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint24_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint24_0Input) => Effect.Effect<Endpoint24_0Output, E>
|
||||
export type Endpoint25_0Output = { readonly location: Location.Info; readonly data: Vcs.Info }
|
||||
export type VcsGetOperation<E = never> = (input?: Endpoint25_0Input) => Effect.Effect<Endpoint25_0Output, E>
|
||||
|
||||
export type Endpoint24_1Input = {
|
||||
export type Endpoint25_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint24_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint24_1Input) => Effect.Effect<Endpoint24_1Output, E>
|
||||
export type Endpoint25_1Output = { readonly location: Location.Info; readonly data: ReadonlyArray<Vcs.FileStatus> }
|
||||
export type VcsStatusOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
|
||||
export type Endpoint24_2Input = {
|
||||
export type Endpoint25_2Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly mode: Vcs.Mode
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type Endpoint24_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint24_2Input) => Effect.Effect<Endpoint24_2Output, E>
|
||||
export type Endpoint25_2Output = { readonly location: Location.Info; readonly data: ReadonlyArray<FileDiff.Info> }
|
||||
export type VcsDiffOperation<E = never> = (input: Endpoint25_2Input) => Effect.Effect<Endpoint25_2Output, E>
|
||||
|
||||
export interface VcsApi<E = never> {
|
||||
readonly get: VcsGetOperation<E>
|
||||
@@ -1568,20 +1613,20 @@ export interface VcsApi<E = never> {
|
||||
readonly diff: VcsDiffOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint25_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint25_0Output, E>
|
||||
export type Endpoint26_0Output = ReadonlyArray<Location.Ref>
|
||||
export type DebugLocationListOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
|
||||
export type Endpoint25_1Input = {
|
||||
export type Endpoint26_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint25_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint25_1Input) => Effect.Effect<Endpoint25_1Output, E>
|
||||
export type Endpoint26_1Output = void
|
||||
export type DebugLocationEvictOperation<E = never> = (input?: Endpoint26_1Input) => Effect.Effect<Endpoint26_1Output, E>
|
||||
|
||||
export interface DebugApi<E = never> {
|
||||
readonly location: { readonly list: DebugLocationListOperation<E>; readonly evict: DebugLocationEvictOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint26_0Output =
|
||||
export type Endpoint27_0Output =
|
||||
| { readonly status: "required" | "completed" }
|
||||
| {
|
||||
readonly status: "running"
|
||||
@@ -1592,36 +1637,36 @@ export type Endpoint26_0Output =
|
||||
}
|
||||
}
|
||||
| { readonly status: "error"; readonly error: string }
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint26_0Output, E>
|
||||
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
|
||||
|
||||
export interface MigrationApi<E = never> {
|
||||
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
|
||||
}
|
||||
|
||||
export type Endpoint27_0Input = {
|
||||
export type Endpoint28_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint27_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint27_0Input) => Effect.Effect<Endpoint27_0Output, E>
|
||||
export type Endpoint28_0Output = { readonly location: Location.Info; readonly data: ReadonlyArray<WebSearch.Provider> }
|
||||
export type WebsearchProvidersOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
|
||||
export type Endpoint27_1Input = {
|
||||
export type Endpoint28_1Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly query: string
|
||||
readonly providerID?: WebSearch.ID | undefined
|
||||
}
|
||||
export type Endpoint27_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint27_1Input) => Effect.Effect<Endpoint27_1Output, E>
|
||||
export type Endpoint28_1Output = { readonly location: Location.Info; readonly data: WebSearch.Response }
|
||||
export type WebsearchQueryOperation<E = never> = (input: Endpoint28_1Input) => Effect.Effect<Endpoint28_1Output, E>
|
||||
|
||||
export interface WebsearchApi<E = never> {
|
||||
readonly providers: WebsearchProvidersOperation<E>
|
||||
readonly query: WebsearchQueryOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint28_0Input = {
|
||||
export type Endpoint29_0Input = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type Endpoint28_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint28_0Input) => Effect.Effect<Endpoint28_0Output, E>
|
||||
export type Endpoint29_0Output = ReadonlyArray<Config.Entry>
|
||||
export type ConfigGetOperation<E = never> = (input?: Endpoint29_0Input) => Effect.Effect<Endpoint29_0Output, E>
|
||||
|
||||
export interface ConfigApi<E = never> {
|
||||
readonly get: ConfigGetOperation<E>
|
||||
@@ -1650,6 +1695,7 @@ export interface AppApi<E = never> {
|
||||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly shell: ShellApi<E>
|
||||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly worktree: WorktreeApi<E>
|
||||
readonly vcs: VcsApi<E>
|
||||
|
||||
@@ -86,6 +86,8 @@ import type {
|
||||
Endpoint5_33Output,
|
||||
Endpoint5_34Input,
|
||||
Endpoint5_34Output,
|
||||
Endpoint5_35Input,
|
||||
Endpoint5_35Output,
|
||||
Endpoint6_0Input,
|
||||
Endpoint6_0Output,
|
||||
Endpoint7_0Input,
|
||||
@@ -200,30 +202,38 @@ import type {
|
||||
Endpoint21_5Output,
|
||||
Endpoint22_0Input,
|
||||
Endpoint22_0Output,
|
||||
Endpoint22_1Input,
|
||||
Endpoint22_1Output,
|
||||
Endpoint22_2Input,
|
||||
Endpoint22_2Output,
|
||||
Endpoint22_3Input,
|
||||
Endpoint22_3Output,
|
||||
Endpoint23_0Input,
|
||||
Endpoint23_0Output,
|
||||
Endpoint23_1Input,
|
||||
Endpoint23_1Output,
|
||||
Endpoint23_2Input,
|
||||
Endpoint23_2Output,
|
||||
Endpoint23_3Input,
|
||||
Endpoint23_3Output,
|
||||
Endpoint24_0Input,
|
||||
Endpoint24_0Output,
|
||||
Endpoint24_1Input,
|
||||
Endpoint24_1Output,
|
||||
Endpoint24_2Input,
|
||||
Endpoint24_2Output,
|
||||
Endpoint24_3Input,
|
||||
Endpoint24_3Output,
|
||||
Endpoint25_0Input,
|
||||
Endpoint25_0Output,
|
||||
Endpoint25_1Input,
|
||||
Endpoint25_1Output,
|
||||
Endpoint25_2Input,
|
||||
Endpoint25_2Output,
|
||||
Endpoint26_0Output,
|
||||
Endpoint27_0Input,
|
||||
Endpoint26_1Input,
|
||||
Endpoint26_1Output,
|
||||
Endpoint27_0Output,
|
||||
Endpoint27_1Input,
|
||||
Endpoint27_1Output,
|
||||
Endpoint28_0Input,
|
||||
Endpoint28_0Output,
|
||||
Endpoint28_1Input,
|
||||
Endpoint28_1Output,
|
||||
Endpoint29_0Input,
|
||||
Endpoint29_0Output,
|
||||
} from "../api/api.js"
|
||||
import { ClientError } from "./client-error.js"
|
||||
|
||||
@@ -368,35 +378,43 @@ const Endpoint5_7 = (raw: RawClient["server.session"]) => (input: Endpoint5_7Inp
|
||||
|
||||
const Endpoint5_8 = (raw: RawClient["server.session"]) => (input: Endpoint5_8Input) =>
|
||||
preserveEffect<Endpoint5_8Output>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
raw["session.select"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { agent: input["agent"], model: input["model"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_9 = (raw: RawClient["server.session"]) => (input: Endpoint5_9Input) =>
|
||||
preserveEffect<Endpoint5_9Output>()(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_10 = (raw: RawClient["server.session"]) => (input: Endpoint5_10Input) =>
|
||||
preserveEffect<Endpoint5_10Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
raw["session.switchModel"]({ params: { sessionID: input["sessionID"] }, payload: { model: input["model"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_11 = (raw: RawClient["server.session"]) => (input: Endpoint5_11Input) =>
|
||||
preserveEffect<Endpoint5_11Output>()(
|
||||
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
raw["session.move"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { directory: input["directory"], workspaceID: input["workspaceID"], delivery: input["delivery"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12Input) =>
|
||||
preserveEffect<Endpoint5_12Output>()(
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
raw["session.prompt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -415,8 +433,8 @@ const Endpoint5_12 = (raw: RawClient["server.session"]) => (input: Endpoint5_12I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13Input) =>
|
||||
preserveEffect<Endpoint5_13Output>()(
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
raw["session.command"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -437,16 +455,16 @@ const Endpoint5_13 = (raw: RawClient["server.session"]) => (input: Endpoint5_13I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_14 = (raw: RawClient["server.session"]) => (input: Endpoint5_14Input) =>
|
||||
preserveEffect<Endpoint5_14Output>()(
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
raw["session.skill"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], skill: input["skill"], resume: input["resume"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15Input) =>
|
||||
preserveEffect<Endpoint5_15Output>()(
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
raw["session.synthetic"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
@@ -463,16 +481,16 @@ const Endpoint5_15 = (raw: RawClient["server.session"]) => (input: Endpoint5_15I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_16 = (raw: RawClient["server.session"]) => (input: Endpoint5_16Input) =>
|
||||
preserveEffect<Endpoint5_16Output>()(
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
raw["session.shell"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], command: input["command"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17Input) =>
|
||||
preserveEffect<Endpoint5_17Output>()(
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
raw["session.compact"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { id: input["id"], delivery: input["delivery"] },
|
||||
@@ -482,13 +500,13 @@ const Endpoint5_17 = (raw: RawClient["server.session"]) => (input: Endpoint5_17I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_18 = (raw: RawClient["server.session"]) => (input: Endpoint5_18Input) =>
|
||||
preserveEffect<Endpoint5_18Output>()(
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
raw["session.wait"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19Input) =>
|
||||
preserveEffect<Endpoint5_19Output>()(
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.revert.stage"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: { messageID: input["messageID"], files: input["files"] },
|
||||
@@ -498,27 +516,19 @@ const Endpoint5_19 = (raw: RawClient["server.session"]) => (input: Endpoint5_19I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_20 = (raw: RawClient["server.session"]) => (input: Endpoint5_20Input) =>
|
||||
preserveEffect<Endpoint5_20Output>()(
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_21 = (raw: RawClient["server.session"]) => (input: Endpoint5_21Input) =>
|
||||
preserveEffect<Endpoint5_21Output>()(
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["session.revert.clear"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_22 = (raw: RawClient["server.session"]) => (input: Endpoint5_22Input) =>
|
||||
preserveEffect<Endpoint5_22Output>()(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
raw["session.revert.commit"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23Input) =>
|
||||
preserveEffect<Endpoint5_23Output>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
raw["session.context"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
@@ -526,58 +536,66 @@ const Endpoint5_23 = (raw: RawClient["server.session"]) => (input: Endpoint5_23I
|
||||
|
||||
const Endpoint5_24 = (raw: RawClient["server.session"]) => (input: Endpoint5_24Input) =>
|
||||
preserveEffect<Endpoint5_24Output>()(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_25 = (raw: RawClient["server.session"]) => (input: Endpoint5_25Input) =>
|
||||
preserveEffect<Endpoint5_25Output>()(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.cancel"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_26 = (raw: RawClient["server.session"]) => (input: Endpoint5_26Input) =>
|
||||
preserveEffect<Endpoint5_26Output>()(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_27 = (raw: RawClient["server.session"]) => (input: Endpoint5_27Input) =>
|
||||
preserveEffect<Endpoint5_27Output>()(
|
||||
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
raw["session.instructions.entry.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_28 = (raw: RawClient["server.session"]) => (input: Endpoint5_28Input) =>
|
||||
preserveEffect<Endpoint5_28Output>()(
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
raw["session.instructions.entry.put"]({
|
||||
params: { sessionID: input["sessionID"], key: input["key"] },
|
||||
payload: { value: input["value"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_29 = (raw: RawClient["server.session"]) => (input: Endpoint5_29Input) =>
|
||||
preserveEffect<Endpoint5_29Output>()(
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
raw["session.instructions.entry.remove"]({ params: { sessionID: input["sessionID"], key: input["key"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_30 = (raw: RawClient["server.session"]) => (input: Endpoint5_30Input) =>
|
||||
preserveEffect<Endpoint5_30Output>()(
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveEffect<Endpoint5_31Output>()(
|
||||
raw["session.generate"]({ params: { sessionID: input["sessionID"] }, payload: { prompt: input["prompt"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31Input) =>
|
||||
preserveStream<Endpoint5_31Output>()(
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveStream<Endpoint5_32Output>()(
|
||||
Stream.unwrap(
|
||||
raw["session.log"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
@@ -589,21 +607,21 @@ const Endpoint5_31 = (raw: RawClient["server.session"]) => (input: Endpoint5_31I
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint5_32 = (raw: RawClient["server.session"]) => (input: Endpoint5_32Input) =>
|
||||
preserveEffect<Endpoint5_32Output>()(
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
raw["session.interrupt"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { continue: input["continue"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_33 = (raw: RawClient["server.session"]) => (input: Endpoint5_33Input) =>
|
||||
preserveEffect<Endpoint5_33Output>()(
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
raw["session.background"]({ params: { sessionID: input["sessionID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint5_34 = (raw: RawClient["server.session"]) => (input: Endpoint5_34Input) =>
|
||||
preserveEffect<Endpoint5_34Output>()(
|
||||
const Endpoint5_35 = (raw: RawClient["server.session"]) => (input: Endpoint5_35Input) =>
|
||||
preserveEffect<Endpoint5_35Output>()(
|
||||
raw["session.message"]({ params: { sessionID: input["sessionID"], messageID: input["messageID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
@@ -619,26 +637,27 @@ const adaptGroup5 = (raw: RawClient["server.session"]) => ({
|
||||
get: Endpoint5_5(raw),
|
||||
remove: Endpoint5_6(raw),
|
||||
fork: Endpoint5_7(raw),
|
||||
switchAgent: Endpoint5_8(raw),
|
||||
switchModel: Endpoint5_9(raw),
|
||||
rename: Endpoint5_10(raw),
|
||||
move: Endpoint5_11(raw),
|
||||
prompt: Endpoint5_12(raw),
|
||||
command: Endpoint5_13(raw),
|
||||
skill: Endpoint5_14(raw),
|
||||
synthetic: Endpoint5_15(raw),
|
||||
shell: Endpoint5_16(raw),
|
||||
compact: Endpoint5_17(raw),
|
||||
wait: Endpoint5_18(raw),
|
||||
revert: { stage: Endpoint5_19(raw), clear: Endpoint5_20(raw), commit: Endpoint5_21(raw) },
|
||||
context: Endpoint5_22(raw),
|
||||
inbox: { list: Endpoint5_23(raw), cancel: Endpoint5_24(raw), steer: Endpoint5_25(raw), queue: Endpoint5_26(raw) },
|
||||
instructions: { entry: { list: Endpoint5_27(raw), put: Endpoint5_28(raw), remove: Endpoint5_29(raw) } },
|
||||
generate: Endpoint5_30(raw),
|
||||
log: Endpoint5_31(raw),
|
||||
interrupt: Endpoint5_32(raw),
|
||||
background: Endpoint5_33(raw),
|
||||
message: Endpoint5_34(raw),
|
||||
select: Endpoint5_8(raw),
|
||||
switchAgent: Endpoint5_9(raw),
|
||||
switchModel: Endpoint5_10(raw),
|
||||
rename: Endpoint5_11(raw),
|
||||
move: Endpoint5_12(raw),
|
||||
prompt: Endpoint5_13(raw),
|
||||
command: Endpoint5_14(raw),
|
||||
skill: Endpoint5_15(raw),
|
||||
synthetic: Endpoint5_16(raw),
|
||||
shell: Endpoint5_17(raw),
|
||||
compact: Endpoint5_18(raw),
|
||||
wait: Endpoint5_19(raw),
|
||||
revert: { stage: Endpoint5_20(raw), clear: Endpoint5_21(raw), commit: Endpoint5_22(raw) },
|
||||
context: Endpoint5_23(raw),
|
||||
inbox: { list: Endpoint5_24(raw), cancel: Endpoint5_25(raw), steer: Endpoint5_26(raw), queue: Endpoint5_27(raw) },
|
||||
instructions: { entry: { list: Endpoint5_28(raw), put: Endpoint5_29(raw), remove: Endpoint5_30(raw) } },
|
||||
generate: Endpoint5_31(raw),
|
||||
log: Endpoint5_32(raw),
|
||||
interrupt: Endpoint5_33(raw),
|
||||
background: Endpoint5_34(raw),
|
||||
message: Endpoint5_35(raw),
|
||||
})
|
||||
|
||||
const Endpoint6_0 = (raw: RawClient["server.message"]) => (input: Endpoint6_0Input) =>
|
||||
@@ -962,6 +981,7 @@ const Endpoint15_3 = (raw: RawClient["server.permission"]) => (input: Endpoint15
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
opaque: input["opaque"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
@@ -1150,110 +1170,145 @@ const adaptGroup21 = (raw: RawClient["server.shell"]) => ({
|
||||
remove: Endpoint21_5(raw),
|
||||
})
|
||||
|
||||
const Endpoint22_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint22_0Input) =>
|
||||
const Endpoint22_0 = (raw: RawClient["server.question"]) => (input?: Endpoint22_0Input) =>
|
||||
preserveEffect<Endpoint22_0Output>()(
|
||||
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint22_1 = (raw: RawClient["server.question"]) => (input: Endpoint22_1Input) =>
|
||||
preserveEffect<Endpoint22_1Output>()(
|
||||
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const Endpoint22_2 = (raw: RawClient["server.question"]) => (input: Endpoint22_2Input) =>
|
||||
preserveEffect<Endpoint22_2Output>()(
|
||||
raw["session.question.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { answers: input["answers"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint22_3 = (raw: RawClient["server.question"]) => (input: Endpoint22_3Input) =>
|
||||
preserveEffect<Endpoint22_3Output>()(
|
||||
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup22 = (raw: RawClient["server.question"]) => ({
|
||||
request: { list: Endpoint22_0(raw) },
|
||||
list: Endpoint22_1(raw),
|
||||
reply: Endpoint22_2(raw),
|
||||
reject: Endpoint22_3(raw),
|
||||
})
|
||||
|
||||
const Endpoint23_0 = (raw: RawClient["server.reference"]) => (input?: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
raw["reference.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup22 = (raw: RawClient["server.reference"]) => ({ list: Endpoint22_0(raw) })
|
||||
const adaptGroup23 = (raw: RawClient["server.reference"]) => ({ list: Endpoint23_0(raw) })
|
||||
|
||||
const Endpoint23_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_0Input) =>
|
||||
preserveEffect<Endpoint23_0Output>()(
|
||||
const Endpoint24_0 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
raw["worktree.list"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint23_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_1Input) =>
|
||||
preserveEffect<Endpoint23_1Output>()(
|
||||
const Endpoint24_1 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
raw["worktree.create"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { strategy: input["strategy"], from: input["from"], directory: input["directory"], name: input["name"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint23_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_2Input) =>
|
||||
preserveEffect<Endpoint23_2Output>()(
|
||||
const Endpoint24_2 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
raw["worktree.remove"]({
|
||||
params: { projectID: input["projectID"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint23_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint23_3Input) =>
|
||||
preserveEffect<Endpoint23_3Output>()(
|
||||
const Endpoint24_3 = (raw: RawClient["server.worktree"]) => (input: Endpoint24_3Input) =>
|
||||
preserveEffect<Endpoint24_3Output>()(
|
||||
raw["worktree.refresh"]({ params: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup23 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint23_0(raw),
|
||||
create: Endpoint23_1(raw),
|
||||
remove: Endpoint23_2(raw),
|
||||
refresh: Endpoint23_3(raw),
|
||||
const adaptGroup24 = (raw: RawClient["server.worktree"]) => ({
|
||||
list: Endpoint24_0(raw),
|
||||
create: Endpoint24_1(raw),
|
||||
remove: Endpoint24_2(raw),
|
||||
refresh: Endpoint24_3(raw),
|
||||
})
|
||||
|
||||
const Endpoint24_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_0Input) =>
|
||||
preserveEffect<Endpoint24_0Output>()(
|
||||
const Endpoint25_0 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_0Input) =>
|
||||
preserveEffect<Endpoint25_0Output>()(
|
||||
raw["vcs.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint24_1Input) =>
|
||||
preserveEffect<Endpoint24_1Output>()(
|
||||
const Endpoint25_1 = (raw: RawClient["server.vcs"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint24_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint24_2Input) =>
|
||||
preserveEffect<Endpoint24_2Output>()(
|
||||
const Endpoint25_2 = (raw: RawClient["server.vcs"]) => (input: Endpoint25_2Input) =>
|
||||
preserveEffect<Endpoint25_2Output>()(
|
||||
raw["vcs.diff"]({ query: { location: input["location"], mode: input["mode"], context: input["context"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroup24 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint24_0(raw),
|
||||
status: Endpoint24_1(raw),
|
||||
diff: Endpoint24_2(raw),
|
||||
const adaptGroup25 = (raw: RawClient["server.vcs"]) => ({
|
||||
get: Endpoint25_0(raw),
|
||||
status: Endpoint25_1(raw),
|
||||
diff: Endpoint25_2(raw),
|
||||
})
|
||||
|
||||
const Endpoint25_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint25_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint26_0 = (raw: RawClient["server.debug"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["debug.location"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const Endpoint25_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint25_1Input) =>
|
||||
preserveEffect<Endpoint25_1Output>()(
|
||||
const Endpoint26_1 = (raw: RawClient["server.debug"]) => (input?: Endpoint26_1Input) =>
|
||||
preserveEffect<Endpoint26_1Output>()(
|
||||
raw["debug.location.evict"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup25 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint25_0(raw), evict: Endpoint25_1(raw) },
|
||||
const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
|
||||
location: { list: Endpoint26_0(raw), evict: Endpoint26_1(raw) },
|
||||
})
|
||||
|
||||
const Endpoint26_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint26_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
|
||||
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroup26 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint26_0(raw) } })
|
||||
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
|
||||
|
||||
const Endpoint27_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint27_0Input) =>
|
||||
preserveEffect<Endpoint27_0Output>()(
|
||||
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
raw["websearch.providers"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const Endpoint27_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint27_1Input) =>
|
||||
preserveEffect<Endpoint27_1Output>()(
|
||||
const Endpoint28_1 = (raw: RawClient["server.websearch"]) => (input: Endpoint28_1Input) =>
|
||||
preserveEffect<Endpoint28_1Output>()(
|
||||
raw["websearch.query"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { query: input["query"], providerID: input["providerID"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup27 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint27_0(raw),
|
||||
query: Endpoint27_1(raw),
|
||||
const adaptGroup28 = (raw: RawClient["server.websearch"]) => ({
|
||||
providers: Endpoint28_0(raw),
|
||||
query: Endpoint28_1(raw),
|
||||
})
|
||||
|
||||
const Endpoint28_0 = (raw: RawClient["server.config"]) => (input?: Endpoint28_0Input) =>
|
||||
preserveEffect<Endpoint28_0Output>()(
|
||||
const Endpoint29_0 = (raw: RawClient["server.config"]) => (input?: Endpoint29_0Input) =>
|
||||
preserveEffect<Endpoint29_0Output>()(
|
||||
raw["config.get"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroup28 = (raw: RawClient["server.config"]) => ({ get: Endpoint28_0(raw) })
|
||||
const adaptGroup29 = (raw: RawClient["server.config"]) => ({ get: Endpoint29_0(raw) })
|
||||
|
||||
const adaptClient = (raw: RawClient) => ({
|
||||
health: adaptGroup0(raw["server.health"]),
|
||||
@@ -1278,13 +1333,14 @@ const adaptClient = (raw: RawClient) => ({
|
||||
event: adaptGroup19(raw["server.event"]),
|
||||
pty: adaptGroup20(raw["server.pty"]),
|
||||
shell: adaptGroup21(raw["server.shell"]),
|
||||
reference: adaptGroup22(raw["server.reference"]),
|
||||
worktree: adaptGroup23(raw["server.worktree"]),
|
||||
vcs: adaptGroup24(raw["server.vcs"]),
|
||||
debug: adaptGroup25(raw["server.debug"]),
|
||||
migration: adaptGroup26(raw["server.migration"]),
|
||||
websearch: adaptGroup27(raw["server.websearch"]),
|
||||
config: adaptGroup28(raw["server.config"]),
|
||||
question: adaptGroup22(raw["server.question"]),
|
||||
reference: adaptGroup23(raw["server.reference"]),
|
||||
worktree: adaptGroup24(raw["server.worktree"]),
|
||||
vcs: adaptGroup25(raw["server.vcs"]),
|
||||
debug: adaptGroup26(raw["server.debug"]),
|
||||
migration: adaptGroup27(raw["server.migration"]),
|
||||
websearch: adaptGroup28(raw["server.websearch"]),
|
||||
config: adaptGroup29(raw["server.config"]),
|
||||
})
|
||||
|
||||
export const make = (options?: { readonly baseUrl?: URL | string }) =>
|
||||
|
||||
@@ -26,6 +26,8 @@ import type {
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
SessionForkOutput,
|
||||
SessionSelectInput,
|
||||
SessionSelectOutput,
|
||||
SessionSwitchAgentInput,
|
||||
SessionSwitchAgentOutput,
|
||||
SessionSwitchModelInput,
|
||||
@@ -194,6 +196,14 @@ import type {
|
||||
ShellOutputOutput,
|
||||
ShellRemoveInput,
|
||||
ShellRemoveOutput,
|
||||
QuestionRequestListInput,
|
||||
QuestionRequestListOutput,
|
||||
QuestionListInput,
|
||||
QuestionListOutput,
|
||||
QuestionReplyInput,
|
||||
QuestionReplyOutput,
|
||||
QuestionRejectInput,
|
||||
QuestionRejectOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
WorktreeListInput,
|
||||
@@ -549,6 +559,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
select: (input: SessionSelectInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSelectOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/selection`,
|
||||
body: { agent: input["agent"], model: input["model"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 503, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSwitchAgentOutput>(
|
||||
{
|
||||
@@ -1382,6 +1404,7 @@ export function make(options: ClientOptions) {
|
||||
action: input["action"],
|
||||
resources: input["resources"],
|
||||
save: input["save"],
|
||||
opaque: input["opaque"],
|
||||
metadata: input["metadata"],
|
||||
source: input["source"],
|
||||
agent: input["agent"],
|
||||
@@ -1652,6 +1675,56 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
question: {
|
||||
request: {
|
||||
list: (input?: QuestionRequestListInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionRequestListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/question/request`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: QuestionListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionReplyOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
|
||||
body: { answers: input["answers"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionRejectOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
reference: {
|
||||
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
|
||||
request<ReferenceListOutput>(
|
||||
|
||||
@@ -324,6 +324,12 @@ export type Pty = {
|
||||
exitCode?: number
|
||||
}
|
||||
|
||||
export type QuestionOption = { label: string; description: string }
|
||||
|
||||
export type QuestionTool = { messageID: string; id: string }
|
||||
|
||||
export type QuestionAnswer = Array<string>
|
||||
|
||||
export type FormMetadata1 = { [x: string]: any }
|
||||
|
||||
export type FormWhen1 = { key: string; op: "eq" | "neq"; value: string | number | boolean }
|
||||
@@ -904,6 +910,15 @@ export type ShellDeleted = {
|
||||
data: { id: string }
|
||||
}
|
||||
|
||||
export type QuestionRejected = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.rejected"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string }
|
||||
}
|
||||
|
||||
export type FormCancelled = {
|
||||
id: string
|
||||
created: number
|
||||
@@ -1362,6 +1377,7 @@ export type PermissionRequest = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
opaque?: boolean
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
source?: PermissionSource
|
||||
}
|
||||
@@ -1378,6 +1394,7 @@ export type PermissionAsked = {
|
||||
action: string
|
||||
resources: Array<string>
|
||||
save?: Array<string>
|
||||
opaque?: boolean
|
||||
metadata?: { [x: string]: any }
|
||||
source?: PermissionSource
|
||||
}
|
||||
@@ -1410,6 +1427,23 @@ export type PtyUpdated = {
|
||||
data: { info: Pty }
|
||||
}
|
||||
|
||||
export type QuestionInfo = {
|
||||
question: string
|
||||
header: string
|
||||
options: Array<QuestionOption>
|
||||
multiple?: boolean
|
||||
custom?: boolean
|
||||
}
|
||||
|
||||
export type QuestionReplied = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.replied"
|
||||
location?: LocationRef
|
||||
data: { sessionID: string; requestID: string; answers: Array<QuestionAnswer> }
|
||||
}
|
||||
|
||||
export type FormStringField1 = {
|
||||
key: string
|
||||
title?: string
|
||||
@@ -1650,6 +1684,17 @@ export type FormReplied = {
|
||||
data: { id: string; sessionID: string; answer: FormAnswer }
|
||||
}
|
||||
|
||||
export type QuestionAsked = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "question.asked"
|
||||
location?: LocationRef
|
||||
data: { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
}
|
||||
|
||||
export type QuestionRequest = { id: string; sessionID: string; questions: Array<QuestionInfo>; tool?: QuestionTool }
|
||||
|
||||
export type FormField1 =
|
||||
| FormStringField1
|
||||
| FormNumberField1
|
||||
@@ -1835,7 +1880,6 @@ export type ConfigEntry =
|
||||
}
|
||||
}
|
||||
experimental?: {
|
||||
portable_shell_scanner?: boolean
|
||||
subagent_depth?: number
|
||||
policies?: Array<{ action: "provider.use"; resource: string; effect: "allow" | "deny" }>
|
||||
}
|
||||
@@ -2072,6 +2116,9 @@ export type V2Event =
|
||||
| ShellCreated
|
||||
| ShellExited
|
||||
| ShellDeleted
|
||||
| QuestionAsked
|
||||
| QuestionReplied
|
||||
| QuestionRejected
|
||||
| FormCreated
|
||||
| FormReplied
|
||||
| FormCancelled
|
||||
@@ -2147,6 +2194,14 @@ export type MessageNotFoundError = {
|
||||
export const isMessageNotFoundError = (value: unknown): value is MessageNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "MessageNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type CommandNotFoundError = {
|
||||
readonly _tag: "CommandNotFoundError"
|
||||
readonly command: string
|
||||
@@ -2171,14 +2226,6 @@ export type SkillNotFoundError = {
|
||||
export const isSkillNotFoundError = (value: unknown): value is SkillNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "SkillNotFoundError"
|
||||
|
||||
export type ServiceUnavailableError = {
|
||||
readonly _tag: "ServiceUnavailableError"
|
||||
readonly message: string
|
||||
readonly service?: string | undefined
|
||||
}
|
||||
export const isServiceUnavailableError = (value: unknown): value is ServiceUnavailableError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ServiceUnavailableError"
|
||||
|
||||
export type SessionBusyError = {
|
||||
readonly _tag: "SessionBusyError"
|
||||
readonly sessionID: string
|
||||
@@ -2251,6 +2298,14 @@ export type ShellNotFoundError = { readonly _tag: "ShellNotFoundError"; readonly
|
||||
export const isShellNotFoundError = (value: unknown): value is ShellNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "ShellNotFoundError"
|
||||
|
||||
export type QuestionNotFoundError = {
|
||||
readonly _tag: "QuestionNotFoundError"
|
||||
readonly requestID: string
|
||||
readonly message: string
|
||||
}
|
||||
export const isQuestionNotFoundError = (value: unknown): value is QuestionNotFoundError =>
|
||||
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "QuestionNotFoundError"
|
||||
|
||||
export type WorktreeError = {
|
||||
readonly name: "WorktreeError"
|
||||
readonly data: { readonly message: string; readonly forceRequired?: boolean | undefined }
|
||||
@@ -3288,6 +3343,32 @@ export type SessionForkInput = {
|
||||
|
||||
export type SessionForkOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionSelectInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly agent: {
|
||||
readonly agent: string
|
||||
readonly model:
|
||||
| { readonly type: "preserve" }
|
||||
| { readonly type: "configured" }
|
||||
| {
|
||||
readonly type: "explicit"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
}["agent"]
|
||||
readonly model: {
|
||||
readonly agent: string
|
||||
readonly model:
|
||||
| { readonly type: "preserve" }
|
||||
| { readonly type: "configured" }
|
||||
| {
|
||||
readonly type: "explicit"
|
||||
readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
|
||||
}
|
||||
}["model"]
|
||||
}
|
||||
|
||||
export type SessionSelectOutput = void
|
||||
|
||||
export type SessionSwitchAgentInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly agent: { readonly agent: string }["agent"]
|
||||
@@ -5177,6 +5258,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5186,6 +5268,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5195,6 +5278,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5204,15 +5288,27 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
}["save"]
|
||||
readonly opaque?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
}["opaque"]
|
||||
readonly metadata?: {
|
||||
readonly id?: string | null
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5222,6 +5318,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5231,6 +5328,7 @@ export type PermissionCreateInput = {
|
||||
readonly action: string
|
||||
readonly resources: ReadonlyArray<string>
|
||||
readonly save?: ReadonlyArray<string>
|
||||
readonly opaque?: boolean
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly source?: { readonly type: "tool"; readonly messageID: string; readonly id: string }
|
||||
readonly agent?: string | null
|
||||
@@ -5539,6 +5637,36 @@ export type ShellRemoveInput = {
|
||||
|
||||
export type ShellRemoveOutput = void
|
||||
|
||||
export type QuestionRequestListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type QuestionRequestListOutput = {
|
||||
location: { directory: string; workspaceID?: string; project: { id: string; directory: string; canonical: string } }
|
||||
data: Array<QuestionRequest>
|
||||
}
|
||||
|
||||
export type QuestionListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type QuestionListOutput = { data: Array<QuestionRequest> }["data"]
|
||||
|
||||
export type QuestionReplyInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
readonly answers: { readonly answers: ReadonlyArray<ReadonlyArray<string>> }["answers"]
|
||||
}
|
||||
|
||||
export type QuestionReplyOutput = void
|
||||
|
||||
export type QuestionRejectInput = {
|
||||
readonly sessionID: { readonly sessionID: string; readonly requestID: string }["sessionID"]
|
||||
readonly requestID: { readonly sessionID: string; readonly requestID: string }["requestID"]
|
||||
}
|
||||
|
||||
export type QuestionRejectOutput = void
|
||||
|
||||
export type ReferenceListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
|
||||
@@ -53,12 +53,6 @@
|
||||
"node": "./src/image/photon-wasm.node.ts",
|
||||
"default": "./src/image/photon-wasm.bun.ts"
|
||||
},
|
||||
"#shell-parser-wasm": {
|
||||
"workerd": "./src/shell/parser-wasm.workerd.ts",
|
||||
"bun": "./src/shell/parser-wasm.bun.ts",
|
||||
"node": "./src/shell/parser-wasm.node.ts",
|
||||
"default": "./src/shell/parser-wasm.bun.ts"
|
||||
},
|
||||
"#process-lock-ffi": {
|
||||
"workerd": "./src/util/process-lock-ffi.workerd.ts",
|
||||
"bun": "./src/util/process-lock-ffi.bun.ts",
|
||||
@@ -137,10 +131,7 @@
|
||||
"ignore": "7.0.5",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"mime-types": "3.0.2",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
"venice-ai-sdk-provider": "2.1.1",
|
||||
"web-tree-sitter": "0.25.10",
|
||||
"which": "6.0.1",
|
||||
"zod": "catalog:"
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ const result = await Bun.build({
|
||||
target: "node",
|
||||
format: "esm",
|
||||
packages: "external",
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#shell-parser-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
external: ["#sqlite", "#pty", "#fff", "#photon-wasm", "#process-lock-ffi", "#v1-migration"],
|
||||
plugins: [
|
||||
{
|
||||
name: "bundle-shell-scan",
|
||||
|
||||
@@ -321,7 +321,7 @@ function modelFromLanguage(info: Info, language: LanguageModelV3) {
|
||||
transport: {
|
||||
id: "ai-sdk",
|
||||
prepare: (input) => Effect.succeed(input.body),
|
||||
execute: () => Effect.succeed({ frames: Stream.empty }),
|
||||
frames: () => Stream.empty,
|
||||
},
|
||||
defaults: {
|
||||
headers: info.headers,
|
||||
|
||||
@@ -401,15 +401,6 @@ function normalizeExperimental(
|
||||
unsupportedExperimental.forEach((key) =>
|
||||
unsupportedIfPresent(experimental, key, ["experimental", key], diagnostics),
|
||||
)
|
||||
if (own(experimental, "portable_shell_scanner")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.portable_shell_scanner,
|
||||
experimental.portable_shell_scanner,
|
||||
["experimental", "portable_shell_scanner"],
|
||||
diagnostics,
|
||||
)
|
||||
if (value !== undefined) result.portable_shell_scanner = value
|
||||
}
|
||||
if (own(experimental, "subagent_depth")) {
|
||||
const value = decodeEncoded(
|
||||
ConfigExperimental.Info.fields.subagent_depth,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/ai/route"
|
||||
import { NodeSocket } from "@effect/platform-node"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
|
||||
|
||||
@@ -12,10 +10,4 @@ export const requestExecutor = makeGlobalNode({
|
||||
|
||||
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export const webSocketConstructor = makeGlobalNode({
|
||||
service: Socket.WebSocketConstructor,
|
||||
layer: NodeSocket.layerWebSocketConstructorWS,
|
||||
deps: [],
|
||||
})
|
||||
|
||||
export * as LayerNodePlatform from "./app-node-platform.js"
|
||||
|
||||
@@ -27,13 +27,13 @@ import { Plugin } from "./plugin.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { WebSearch } from "./websearch.js"
|
||||
import { ReferenceInstructions } from "./reference/instructions.js"
|
||||
import { SessionRunnerLLM } from "./session/runner/llm.js"
|
||||
import { SessionRunnerModel } from "./session/runner/model.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { SessionCompaction } from "./session/compaction.js"
|
||||
import { SessionTitle } from "./session/title.js"
|
||||
import { Skill } from "./skill.js"
|
||||
@@ -86,13 +86,13 @@ const locationServiceNodes = [
|
||||
ReferenceInstructions.node,
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Question.node,
|
||||
Generate.node,
|
||||
SessionGenerateNode.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
SessionRunnerModel.node,
|
||||
SessionModelTransport.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
Snapshot.node,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Permission from "./permission.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Deferred, Effect, Layer, Schema, Struct } from "effect"
|
||||
import { Permission } from "@opencode-ai/schema/permission"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -23,15 +23,6 @@ export type ID = typeof ID.Type
|
||||
export const Source = Permission.Source
|
||||
export type Source = typeof Source.Type
|
||||
|
||||
const RequestFields = {
|
||||
sessionID: Permission.Request.fields.sessionID,
|
||||
action: Permission.Request.fields.action,
|
||||
resources: Permission.Request.fields.resources,
|
||||
save: Permission.Request.fields.save,
|
||||
metadata: Permission.Request.fields.metadata,
|
||||
source: Permission.Request.fields.source,
|
||||
}
|
||||
|
||||
export const Request = Permission.Request
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
@@ -40,7 +31,7 @@ export type Reply = typeof Reply.Type
|
||||
|
||||
export const AssertInput = Schema.Struct({
|
||||
id: ID.pipe(Schema.optional),
|
||||
...RequestFields,
|
||||
...Struct.omit(Permission.Request.fields, ["id"]),
|
||||
agent: Agent.ID.pipe(Schema.optional),
|
||||
}).annotate({ identifier: "Permission.AssertInput" })
|
||||
export type AssertInput = typeof AssertInput.Type
|
||||
@@ -94,6 +85,16 @@ export function evaluate(action: string, resource: string, ...rulesets: Permissi
|
||||
)
|
||||
}
|
||||
|
||||
function evaluateOpaque(action: string, rules: Permission.Ruleset): Permission.Rule {
|
||||
return (
|
||||
rules.findLast((rule) => Wildcard.match(action, rule.action) && rule.resource === "*") ?? {
|
||||
action,
|
||||
resource: "*",
|
||||
effect: "ask",
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function merge(...rulesets: Permission.Ruleset[]): Permission.Ruleset {
|
||||
return rulesets.flat()
|
||||
}
|
||||
@@ -178,6 +179,13 @@ const layer = Layer.effect(
|
||||
})
|
||||
|
||||
function denied(input: AssertInput, rules: Permission.Ruleset) {
|
||||
if (input.opaque)
|
||||
return rules.some(
|
||||
(rule) =>
|
||||
rule.effect === "deny" &&
|
||||
Wildcard.match(input.action, rule.action) &&
|
||||
input.resources.some((resource) => resource === "*" || Wildcard.match(resource, rule.resource)),
|
||||
)
|
||||
return input.resources.some((resource) => evaluate(input.action, resource, rules).effect === "deny")
|
||||
}
|
||||
|
||||
@@ -187,9 +195,27 @@ const layer = Layer.effect(
|
||||
|
||||
const evaluateInput = Effect.fnUntraced(function* (input: AssertInput) {
|
||||
const rules = yield* configured(input.sessionID, input.agent)
|
||||
if (input.resources.length === 0) return { effect: "deny" as const, rules }
|
||||
if (denied(input, rules)) return { effect: "deny" as const, rules }
|
||||
const all = [...rules, ...(yield* savedRules())]
|
||||
const effects = input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
if (
|
||||
input.opaque &&
|
||||
rules.some(
|
||||
(rule) => rule.effect !== "allow" && rule.resource !== "*" && Wildcard.match(input.action, rule.action),
|
||||
)
|
||||
)
|
||||
return { effect: "ask" as const, rules }
|
||||
const saved = yield* savedRules()
|
||||
if (
|
||||
input.opaque &&
|
||||
input.resources.every((resource) =>
|
||||
saved.some((rule) => Wildcard.match(input.action, rule.action) && rule.resource === resource),
|
||||
)
|
||||
)
|
||||
return { effect: "allow" as const, rules: [...rules, ...saved] }
|
||||
const all = [...rules, ...saved]
|
||||
const effects = input.opaque
|
||||
? [evaluateOpaque(input.action, all).effect]
|
||||
: input.resources.map((resource) => evaluate(input.action, resource, all).effect)
|
||||
const effect: Permission.Effect = effects.includes("deny") ? "deny" : effects.includes("ask") ? "ask" : "allow"
|
||||
return { effect, rules: all }
|
||||
})
|
||||
@@ -200,7 +226,8 @@ const layer = Layer.effect(
|
||||
sessionID: input.sessionID,
|
||||
action: input.action,
|
||||
resources: input.resources,
|
||||
save: input.save,
|
||||
save: input.opaque ? input.resources : input.save,
|
||||
opaque: input.opaque,
|
||||
metadata: input.metadata,
|
||||
source: input.source,
|
||||
}
|
||||
@@ -299,21 +326,11 @@ const layer = Layer.effect(
|
||||
pending.delete(input.requestID)
|
||||
if (input.reply !== "always" || !existing.request.save?.length) return
|
||||
|
||||
const rememberedRules = yield* savedRules()
|
||||
for (const [id, item] of pending) {
|
||||
const input = { ...item.request }
|
||||
const rules = yield* configured(item.request.sessionID, item.agent).pipe(
|
||||
const result = yield* evaluateInput({ ...item.request, agent: item.agent }).pipe(
|
||||
Effect.catchTag("Session.NotFoundError", () => Effect.succeed(undefined)),
|
||||
)
|
||||
if (!rules) continue
|
||||
if (denied(input, rules)) continue
|
||||
const effective = [...rules, ...rememberedRules]
|
||||
if (
|
||||
!item.request.resources.every(
|
||||
(resource) => evaluate(item.request.action, resource, effective).effect === "allow",
|
||||
)
|
||||
)
|
||||
continue
|
||||
if (!result || result.effect !== "allow") continue
|
||||
yield* bus.publish(Permission.Event.Replied, {
|
||||
sessionID: item.request.sessionID,
|
||||
requestID: item.request.id,
|
||||
|
||||
@@ -28,10 +28,6 @@ interface Failures extends Record<keyof Domains, unknown> {
|
||||
type Callback<Event, Error> = (event: Event) => Effect.Effect<void, Error>
|
||||
|
||||
export interface Interface {
|
||||
readonly has: <Domain extends keyof Domains>(
|
||||
domain: Domain,
|
||||
name: keyof Domains[Domain] & keyof Failures[Domain],
|
||||
) => Effect.Effect<boolean>
|
||||
readonly register: <Domain extends keyof Domains, Name extends keyof Domains[Domain] & keyof Failures[Domain]>(
|
||||
domain: Domain,
|
||||
name: Name,
|
||||
@@ -78,9 +74,7 @@ const layer = Layer.effect(
|
||||
return event
|
||||
})
|
||||
|
||||
const has: Interface["has"] = (domain, name) => Effect.sync(() => callbacks.has(key(domain, name)))
|
||||
|
||||
return Service.of({ has, register, trigger })
|
||||
return Service.of({ register, trigger })
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { App } from "../../app.js"
|
||||
import { Credential } from "../../credential.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
import { Integration } from "../../integration.js"
|
||||
import { Model } from "../../model.js"
|
||||
import { OauthCallbackPage } from "../../oauth/page.js"
|
||||
import { Provider } from "../../provider.js"
|
||||
import type { PluginInternal } from "../internal.js"
|
||||
@@ -202,10 +203,10 @@ export const OpenAIPlugin = define({
|
||||
if (!item) return
|
||||
item.provider.settings = Provider.mergeOverlay(item.provider.settings, { baseURL: codexBaseURL })
|
||||
const account = chatgpt.metadata?.accountID
|
||||
item.provider.headers = Provider.mergeHeaders(item.provider.headers, {
|
||||
originator: "opencode",
|
||||
...(typeof account === "string" ? { "chatgpt-account-id": account } : {}),
|
||||
})
|
||||
item.provider.headers = Provider.mergeHeaders(
|
||||
item.provider.headers,
|
||||
typeof account === "string" ? { "chatgpt-account-id": account } : undefined,
|
||||
)
|
||||
for (const model of item.models.values()) {
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
@@ -229,6 +230,17 @@ export const OpenAIPlugin = define({
|
||||
})
|
||||
}
|
||||
})
|
||||
yield* ctx.session.hook("http.request", (evt) =>
|
||||
Effect.sync(() => {
|
||||
if (!chatgpt || evt.model.providerID !== Provider.ID.openai) return
|
||||
const url = new URL(evt.request.url)
|
||||
evt.request.headers.set("originator", "opencode")
|
||||
evt.request.headers.set("session-id", evt.sessionID)
|
||||
if (url.origin !== "https://api.openai.com") return
|
||||
evt.request = new Request(`${codexBaseURL}${url.pathname.replace(/^\/v1/, "")}${url.search}`, evt.request)
|
||||
}),
|
||||
)
|
||||
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(ctx.catalog.reload())))
|
||||
yield* bus.subscribe(Integration.Event.ConnectionUpdated).pipe(
|
||||
Stream.filter((event) => event.data.integrationID === Integration.ID.make("openai")),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { WebSearchExa } from "./exa.js"
|
||||
import { WebSearchFirecrawl } from "./firecrawl.js"
|
||||
import { WebSearchParallel } from "./parallel.js"
|
||||
import { WebSearchTavily } from "./tavily.js"
|
||||
|
||||
export const WebSearchPlugins = [
|
||||
WebSearchExa.Plugin,
|
||||
WebSearchFirecrawl.Plugin,
|
||||
WebSearchParallel.Plugin,
|
||||
WebSearchTavily.Plugin,
|
||||
] as const
|
||||
export const WebSearchPlugins = [WebSearchExa.Plugin, WebSearchFirecrawl.Plugin, WebSearchParallel.Plugin] as const
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
export * as WebSearchTavily from "./tavily.js"
|
||||
|
||||
import { define } from "@opencode-ai/plugin/effect/plugin"
|
||||
import { Duration, Effect, Schema, Scope } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
|
||||
import { App } from "../../app.js"
|
||||
|
||||
export const endpoint = "https://api.tavily.com/search"
|
||||
|
||||
const SearchRequest = Schema.Struct({
|
||||
query: Schema.String,
|
||||
search_depth: Schema.Literal("basic"),
|
||||
chunks_per_source: Schema.Number,
|
||||
max_results: Schema.Number,
|
||||
})
|
||||
|
||||
const SearchResponse = Schema.Struct({
|
||||
results: Schema.Array(
|
||||
Schema.Struct({
|
||||
title: Schema.String,
|
||||
url: Schema.String,
|
||||
content: Schema.String,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const Plugin = define<HttpClient.HttpClient | Scope.Scope>({
|
||||
id: "opencode.websearch.tavily",
|
||||
effect: Effect.fn("WebSearchTavily.Plugin")(function* (ctx) {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
yield* ctx.integration.transform((draft) => {
|
||||
draft.update("tavily", (integration) => (integration.name = "Tavily"))
|
||||
draft.method.update({
|
||||
integrationID: "tavily",
|
||||
method: { type: "key" },
|
||||
})
|
||||
draft.method.update({
|
||||
integrationID: "tavily",
|
||||
method: { type: "env", names: ["TAVILY_API_KEY"] },
|
||||
})
|
||||
})
|
||||
yield* ctx.websearch.transform((draft) => {
|
||||
draft.add({
|
||||
id: "tavily",
|
||||
name: "Tavily",
|
||||
execute: (input) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("tavily")
|
||||
const credential = connection ? yield* ctx.integration.connection.resolve(connection) : undefined
|
||||
const request = yield* HttpClientRequest.post(endpoint).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.setHeaders({
|
||||
"User-Agent": App.useragent(ctx.app),
|
||||
"X-Client-Name": "opencode2",
|
||||
...(credential?.type === "key"
|
||||
? { Authorization: `Bearer ${credential.key}` }
|
||||
: { "X-Tavily-Access-Mode": "keyless" }),
|
||||
}),
|
||||
HttpClientRequest.schemaBodyJson(SearchRequest)({
|
||||
query: input.query,
|
||||
search_depth: "basic",
|
||||
chunks_per_source: 3,
|
||||
max_results: 8,
|
||||
}),
|
||||
)
|
||||
const response = yield* Effect.gen(function* () {
|
||||
const httpResponse = yield* HttpClient.filterStatusOk(http).execute(request)
|
||||
return yield* HttpClientResponse.schemaBodyJson(SearchResponse)(httpResponse)
|
||||
}).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.seconds(25),
|
||||
orElse: () => Effect.fail(new Error("Tavily web search request timed out")),
|
||||
}),
|
||||
)
|
||||
return response.results.map((item) => ({
|
||||
url: item.url,
|
||||
title: item.title,
|
||||
...(item.content ? { content: item.content } : {}),
|
||||
time: {},
|
||||
}))
|
||||
}),
|
||||
})
|
||||
})
|
||||
}),
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
export * as Question from "./question.js"
|
||||
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { Bus } from "./bus.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
|
||||
export const ID = Question.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Option = Question.Option
|
||||
export type Option = typeof Option.Type
|
||||
|
||||
export const Info = Question.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const Prompt = Question.Prompt
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export const Tool = Question.Tool
|
||||
export type Tool = typeof Tool.Type
|
||||
|
||||
export const Request = Question.Request
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
export const Answer = Question.Answer
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Reply = Question.Reply
|
||||
export type Reply = typeof Reply.Type
|
||||
|
||||
export { Event } from "@opencode-ai/schema/question"
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("Question.RejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Question.NotFoundError", {
|
||||
requestID: ID,
|
||||
}) {}
|
||||
|
||||
export interface AskInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly questions: ReadonlyArray<Info>
|
||||
readonly tool?: Tool
|
||||
}
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly requestID: ID
|
||||
readonly answers: ReadonlyArray<Answer>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Question") {}
|
||||
|
||||
interface Pending {
|
||||
readonly request: Request
|
||||
readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Location-owned pending prompts. The Location layer map must materialize this
|
||||
* layer once per embedded Location so replies cannot settle another Location's
|
||||
* deferred request.
|
||||
*/
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Question.ask")((input: AskInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = ID.ascending()
|
||||
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
|
||||
const request: Request = { id, ...input }
|
||||
pending.set(id, { request, deferred })
|
||||
return yield* bus.publish(Question.Event.Asked, request).pipe(
|
||||
Effect.andThen(restore(Deferred.await(deferred))),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reply = Effect.fn("Question.reply")((input: ReplyInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
yield* bus.publish(Question.Event.Replied, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
answers: input.answers.map((answer) => [...answer]),
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
pending.delete(input.requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reject = Effect.fn("Question.reject")((requestID: ID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID })
|
||||
yield* bus.publish(Question.Event.Rejected, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
pending.delete(requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const list = Effect.fn("Question.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.request)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [Bus.node] })
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as Session from "./session.js"
|
||||
export * from "./session/schema.js"
|
||||
|
||||
import { Effect, Layer, Schema, Context, RcMap, Stream, Scope } from "effect"
|
||||
import { Effect, Layer, Schema, Context, Stream, Scope } from "effect"
|
||||
import { ListAnchor } from "@opencode-ai/schema/session"
|
||||
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"
|
||||
import { Project } from "./project.js"
|
||||
@@ -27,7 +27,6 @@ import { fromRow } from "./session/info.js"
|
||||
import { SessionRunner } from "./session/runner/index.js"
|
||||
import { SessionStore } from "./session/store.js"
|
||||
import { SessionExecution } from "./session/execution.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { ForkEmptyError, MessageDecodeError, NotFoundError } from "./session/error.js"
|
||||
import { makeGlobalNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
@@ -205,6 +204,11 @@ export interface Interface {
|
||||
}) => Stream.Stream<SessionEvent.DurableEvent | EventLog.Synced, NotFoundError>
|
||||
readonly switchAgent: (input: { sessionID: SessionSchema.ID; agent: Agent.ID }) => Effect.Effect<void, NotFoundError>
|
||||
readonly switchModel: (input: { sessionID: SessionSchema.ID; model: Model.Ref }) => Effect.Effect<void, NotFoundError>
|
||||
readonly select: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
agent: Agent.ID
|
||||
model?: Model.Ref
|
||||
}) => Effect.Effect<void, NotFoundError>
|
||||
readonly rename: (input: { sessionID: SessionSchema.ID; title: string }) => Effect.Effect<void, NotFoundError>
|
||||
readonly move: (input: {
|
||||
sessionID: SessionSchema.ID
|
||||
@@ -307,16 +311,6 @@ const layer = Layer.effect(
|
||||
const scope = yield* Scope.Scope
|
||||
const activeShells = new Set<SessionSchema.ID>()
|
||||
const shellLocks = KeyedMutex.makeUnsafe<SessionSchema.ID>()
|
||||
const closeTransport = Effect.fn("Session.closeTransport")(function* (session: SessionSchema.Info) {
|
||||
const location = Location.Ref.make({
|
||||
directory: session.location.directory,
|
||||
workspaceID: session.location.workspaceID,
|
||||
})
|
||||
if (!(yield* RcMap.has(locations.rcMap, location))) return
|
||||
yield* SessionModelTransport.Service.use((transport) => transport.close(session.id)).pipe(
|
||||
Effect.provide(locations.get(location)),
|
||||
)
|
||||
})
|
||||
const decodeMessage = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
const persistProject = (project: Project.Resolved) => upsertProject(db, project).pipe(Effect.orDie)
|
||||
@@ -448,10 +442,9 @@ const layer = Layer.effect(
|
||||
return session
|
||||
}),
|
||||
remove: Effect.fn("Session.remove")(function* (sessionID) {
|
||||
const session = yield* result.get(sessionID)
|
||||
yield* result.get(sessionID)
|
||||
yield* execution.interrupt(sessionID)
|
||||
yield* execution.awaitIdle(sessionID)
|
||||
yield* closeTransport(session)
|
||||
const children = yield* result.list({ parentID: sessionID })
|
||||
yield* Effect.forEach(children.data, (child) => result.remove(child.id), { concurrency: 1, discard: true })
|
||||
yield* bus.publish(SessionEvent.Deleted, { sessionID })
|
||||
@@ -713,22 +706,45 @@ const layer = Layer.effect(
|
||||
.resume(input.sessionID)
|
||||
.pipe(Effect.ignore, Effect.forkIn(scope, { startImmediately: true }), Effect.asVoid)
|
||||
}),
|
||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||
select: Effect.fn("Session.select")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
yield* bus.publish(SessionEvent.AgentSelected, {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
previous: session.agent,
|
||||
})
|
||||
const agent =
|
||||
session.agent === input.agent
|
||||
? undefined
|
||||
: {
|
||||
sessionID: input.sessionID,
|
||||
agent: input.agent,
|
||||
previous: session.agent,
|
||||
}
|
||||
const model = !input.model || sameModel(session.model, input.model) ? undefined : input.model
|
||||
if (agent && model) {
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.AgentSelected, agent],
|
||||
[
|
||||
SessionEvent.ModelSelected,
|
||||
{
|
||||
sessionID: input.sessionID,
|
||||
model,
|
||||
previous: session.model,
|
||||
},
|
||||
],
|
||||
])
|
||||
return
|
||||
}
|
||||
if (agent) yield* bus.publish(SessionEvent.AgentSelected, agent)
|
||||
if (model)
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID: input.sessionID,
|
||||
model,
|
||||
previous: session.model,
|
||||
})
|
||||
}),
|
||||
switchAgent: Effect.fn("Session.switchAgent")(function* (input) {
|
||||
yield* result.select({ sessionID: input.sessionID, agent: input.agent })
|
||||
}),
|
||||
switchModel: Effect.fn("Session.switchModel")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
if (
|
||||
session.model?.providerID === input.model.providerID &&
|
||||
session.model.id === input.model.id &&
|
||||
(session.model.variant ?? "default") === (input.model.variant ?? "default")
|
||||
)
|
||||
return
|
||||
if (sameModel(session.model, input.model)) return
|
||||
yield* bus.publish(SessionEvent.ModelSelected, {
|
||||
sessionID: input.sessionID,
|
||||
model: input.model,
|
||||
@@ -1096,6 +1112,14 @@ function positiveInt(value: string | null) {
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined
|
||||
}
|
||||
|
||||
function sameModel(left: Model.Ref | undefined, right: Model.Ref) {
|
||||
return (
|
||||
left?.providerID === right.providerID &&
|
||||
left.id === right.id &&
|
||||
(left.variant ?? "default") === (right.variant ?? "default")
|
||||
)
|
||||
}
|
||||
|
||||
// Mirrors the shell tool's in-memory preview safety limit.
|
||||
const SHELL_MAX_CAPTURE_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@ export * as SessionModelRequest from "./model-request.js"
|
||||
import { LLM, Message, SystemPart, type LLMRequest } from "@opencode-ai/ai"
|
||||
import type { StreamOptions } from "@opencode-ai/ai/route"
|
||||
import type { Content } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Cause, Config, Context, Effect, Layer, Result } from "effect"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { App } from "../app.js"
|
||||
import { Model } from "../model.js"
|
||||
import { Provider } from "../provider.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
@@ -15,7 +15,6 @@ import { Tool } from "../tool.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import { SessionModelHeaders } from "./model-headers.js"
|
||||
import { SessionModelHttp } from "./model-http.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionPromptCacheKey } from "./prompt-cache-key.js"
|
||||
import { PromptCacheDiagnostics } from "./prompt-cache-diagnostics.js"
|
||||
import { MAX_STEPS_PROMPT } from "./runner/max-steps.js"
|
||||
@@ -47,8 +46,6 @@ const declineDefect = (cause: Cause.Cause<Tool.Error>) => {
|
||||
interface Prepared {
|
||||
readonly request: LLMRequest
|
||||
readonly options: StreamOptions
|
||||
/** False when Session HTTP hooks require the request to remain on HTTP. */
|
||||
readonly webSocketEligible: boolean
|
||||
/**
|
||||
* One request-scoped execution operation. Unknown, hook-removed, and
|
||||
* step-limit-violating calls fail individually through the same seam.
|
||||
@@ -168,12 +165,7 @@ export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
const transport = yield* SessionModelTransport.Service
|
||||
const app = yield* App.Metadata
|
||||
const webSocket = yield* Config.boolean("OPENCODE_EXPERIMENTAL_OPENAI_RESPONSES_WEBSOCKET").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
)
|
||||
const diagnostics = yield* Config.boolean("OPENCODE_PROMPT_CACHE_DIAGNOSTICS").pipe(
|
||||
Config.withDefault(false),
|
||||
Effect.orDie,
|
||||
@@ -234,23 +226,12 @@ export const layer = Layer.effect(
|
||||
tools: Array.from(hooked, ([name, tool]) => ({ ...tool, name })),
|
||||
toolChoice: stepLimitReached ? "none" : undefined,
|
||||
})
|
||||
const webSocketEligible =
|
||||
!(yield* hooks.has("session", "http.request")) && !(yield* hooks.has("session", "http.response"))
|
||||
const http = webSocketEligible
|
||||
? undefined
|
||||
: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
})
|
||||
const options: StreamOptions = {
|
||||
...(http ? { http } : {}),
|
||||
...(webSocket &&
|
||||
webSocketEligible &&
|
||||
resolved.ref.providerID === Provider.ID.openai &&
|
||||
model.route.id === "openai-responses"
|
||||
? { webSocket: transport.bind(session.id) }
|
||||
: {}),
|
||||
http: SessionModelHttp.middleware(hooks, {
|
||||
sessionID: session.id,
|
||||
agent: agent.id,
|
||||
model: resolved.ref,
|
||||
}),
|
||||
}
|
||||
if (promptCacheSnapshots) {
|
||||
const current = PromptCacheDiagnostics.snapshot(request)
|
||||
@@ -282,7 +263,6 @@ export const layer = Layer.effect(
|
||||
return {
|
||||
request,
|
||||
options,
|
||||
webSocketEligible,
|
||||
executeTool,
|
||||
stepLimitReached,
|
||||
}
|
||||
@@ -295,5 +275,5 @@ export const layer = Layer.effect(
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [PluginHooks.node, SessionModelTransport.node, App.node],
|
||||
deps: [PluginHooks.node, App.node],
|
||||
})
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
export * as SessionModelTransport from "./model-transport.js"
|
||||
|
||||
import {
|
||||
WebSocketTransport,
|
||||
type ChannelObservation,
|
||||
type ChannelCheckpoint,
|
||||
type WebSocketChannelExchange,
|
||||
type WebSocketChannelExecution,
|
||||
type WebSocketChannelExecutor,
|
||||
type WebSocketConnection,
|
||||
type WebSocketConnector,
|
||||
} from "@opencode-ai/ai/route"
|
||||
import { AIError, TransportReason, type TransportOperation } from "@opencode-ai/ai"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import { Cause, Clock, Context, Effect, Fiber, Layer, Metric, Queue, Scope, Semaphore, Stream } from "effect"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { webSocketConstructor } from "../effect/app-node-platform.js"
|
||||
|
||||
const ROTATE_AFTER_MS = 55 * 60 * 1000
|
||||
const INBOUND_CAPACITY = 128
|
||||
const IDLE_TIMEOUT = "5 minutes"
|
||||
const events = Metric.counter("opencode_session_websocket_events_total", {
|
||||
description: "Session WebSocket lifecycle events",
|
||||
incremental: true,
|
||||
})
|
||||
const metric = (event: string, attributes: Record<string, string> = {}) =>
|
||||
Metric.update(events.pipe(Metric.withAttributes({ event, ...attributes })), 1)
|
||||
|
||||
type Delivery = "queued" | "connecting" | "ready" | "send-attempted" | "provider-observed" | "terminal"
|
||||
|
||||
interface Active {
|
||||
readonly queue: Queue.Queue<string, AIError>
|
||||
readonly lifecycle: { delivery: Delivery }
|
||||
}
|
||||
|
||||
interface Channel {
|
||||
readonly affinity: string
|
||||
readonly connection: WebSocketConnection
|
||||
readonly openedAt: number
|
||||
active?: Active
|
||||
closing: boolean
|
||||
poisoned: boolean
|
||||
checkpoint?: ChannelCheckpoint
|
||||
pending?: { readonly token: object; readonly checkpoint: ChannelCheckpoint }
|
||||
reader?: Fiber.Fiber<unknown, unknown>
|
||||
}
|
||||
|
||||
interface State {
|
||||
readonly lock: Semaphore.Semaphore
|
||||
closed: boolean
|
||||
channel?: Channel
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly bind: (sessionID: SessionSchema.ID) => WebSocketChannelExecutor
|
||||
readonly close: (sessionID: SessionSchema.ID) => Effect.Effect<void>
|
||||
readonly closeAll: Effect.Effect<void>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelTransport") {}
|
||||
|
||||
const transportError = (
|
||||
method: string,
|
||||
message: string,
|
||||
input: {
|
||||
readonly operation: TransportOperation
|
||||
readonly url?: string
|
||||
readonly code?: string
|
||||
readonly phase?: TransportReason["phase"]
|
||||
readonly delivery?: TransportReason["delivery"]
|
||||
},
|
||||
) =>
|
||||
new AIError({
|
||||
module: "SessionModelTransport",
|
||||
method,
|
||||
reason: new TransportReason({ message, transport: "websocket", ...input }),
|
||||
})
|
||||
|
||||
const annotate = (
|
||||
error: AIError,
|
||||
input: { readonly phase: TransportReason["phase"]; readonly delivery: TransportReason["delivery"] },
|
||||
) => {
|
||||
if (error.reason._tag !== "Transport") return error
|
||||
return new AIError({
|
||||
module: error.module,
|
||||
method: error.method,
|
||||
reason: new TransportReason({
|
||||
message: error.reason.message,
|
||||
transport: error.reason.transport,
|
||||
operation: error.reason.operation,
|
||||
code: error.reason.code,
|
||||
url: error.reason.url,
|
||||
http: error.reason.http,
|
||||
recovery: error.reason.recovery,
|
||||
...input,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
const affinity = (exchange: WebSocketChannelExchange) =>
|
||||
`${exchange.connect.url}:${Hash.sha256(JSON.stringify(Object.entries(exchange.connect.headers).sort(([a], [b]) => a.localeCompare(b))))}`
|
||||
|
||||
const observationFrame = (observation: ChannelObservation) => {
|
||||
if (observation.type === "frame" || observation.type === "completed" || observation.type === "incomplete")
|
||||
return Effect.succeed(observation.frame)
|
||||
return Effect.fail(observation.error)
|
||||
}
|
||||
|
||||
const observationTerminal = (observation: ChannelObservation) => observation.type !== "frame"
|
||||
|
||||
export const makeLayer = (connector: WebSocketConnector) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const scope = yield* Scope.Scope
|
||||
const states = new Map<SessionSchema.ID, State>()
|
||||
const state = (sessionID: SessionSchema.ID) => {
|
||||
const current = states.get(sessionID)
|
||||
if (current) return current
|
||||
const created = { lock: Semaphore.makeUnsafe(1), closed: false }
|
||||
states.set(sessionID, created)
|
||||
return created
|
||||
}
|
||||
|
||||
const closeChannel = Effect.fn("SessionModelTransport.closeChannel")(function* (owner: State, channel: Channel) {
|
||||
if (owner.channel === channel) owner.channel = undefined
|
||||
if (channel.closing) return
|
||||
channel.closing = true
|
||||
if (channel.reader) yield* Fiber.interrupt(channel.reader)
|
||||
yield* channel.connection.close
|
||||
if (channel.active)
|
||||
Queue.failCauseUnsafe(
|
||||
channel.active.queue,
|
||||
Cause.fail(
|
||||
transportError("close", "Session WebSocket closed", {
|
||||
operation: "read",
|
||||
code: "close",
|
||||
phase: "close",
|
||||
delivery:
|
||||
channel.active.lifecycle.delivery === "queued" ||
|
||||
channel.active.lifecycle.delivery === "connecting" ||
|
||||
channel.active.lifecycle.delivery === "ready"
|
||||
? "not-sent"
|
||||
: channel.active.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active.lifecycle.delivery === "terminal"
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* metric("close")
|
||||
})
|
||||
|
||||
const poison = Effect.fn("SessionModelTransport.poison")(function* (
|
||||
owner: State,
|
||||
channel: Channel,
|
||||
error: AIError,
|
||||
) {
|
||||
channel.poisoned = true
|
||||
if (owner.channel === channel) owner.channel = undefined
|
||||
if (channel.closing) return
|
||||
channel.closing = true
|
||||
if (channel.active) Queue.failCauseUnsafe(channel.active.queue, Cause.fail(error))
|
||||
yield* metric(
|
||||
error.reason._tag === "Transport" && error.reason.code === "queue-overflow"
|
||||
? "queue_overflow"
|
||||
: "protocol_failure",
|
||||
)
|
||||
yield* channel.connection.close
|
||||
})
|
||||
|
||||
const open = Effect.fn("SessionModelTransport.open")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
key: string,
|
||||
) {
|
||||
return yield* Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const connection = yield* restore(
|
||||
connector.open(exchange.connect).pipe(Effect.withSpan("SessionModelTransport.connect")),
|
||||
)
|
||||
if (owner.closed) {
|
||||
yield* connection.close
|
||||
return yield* transportError("open", "Session WebSocket owner closed while connecting", {
|
||||
operation: "request",
|
||||
code: "owner-closed",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
}
|
||||
const channel: Channel = {
|
||||
affinity: key,
|
||||
connection,
|
||||
openedAt: yield* Clock.currentTimeMillis,
|
||||
closing: false,
|
||||
poisoned: false,
|
||||
}
|
||||
owner.channel = channel
|
||||
channel.reader = yield* connection.messages.pipe(
|
||||
Stream.runForEach((message) =>
|
||||
Effect.gen(function* () {
|
||||
const active = channel.active
|
||||
if (!active)
|
||||
return yield* transportError("receive", "WebSocket data arrived without an active exchange", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
})
|
||||
active.lifecycle.delivery = "provider-observed"
|
||||
if (typeof message !== "string")
|
||||
return yield* transportError("receive", "Unsupported binary WebSocket frame", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "message",
|
||||
phase: "receive",
|
||||
})
|
||||
if (Queue.offerUnsafe(active.queue, message)) return undefined
|
||||
return yield* transportError("receive", "Session WebSocket inbound queue overflow", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "queue-overflow",
|
||||
phase: "receive",
|
||||
delivery: "accepted",
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.catch((error) =>
|
||||
channel.closing
|
||||
? Effect.void
|
||||
: poison(
|
||||
owner,
|
||||
channel,
|
||||
annotate(error, {
|
||||
phase:
|
||||
error.reason._tag === "Transport" && error.reason.phase === "close" ? "close" : "receive",
|
||||
delivery:
|
||||
channel.active?.lifecycle.delivery === "provider-observed" ||
|
||||
channel.active?.lifecycle.delivery === "terminal" ||
|
||||
(error.reason._tag === "Transport" && error.reason.code === "queue-overflow")
|
||||
? "accepted"
|
||||
: "ambiguous",
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.forkIn(scope, { startImmediately: true }),
|
||||
)
|
||||
yield* Effect.logDebug("session websocket connected", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
})
|
||||
yield* metric("connect")
|
||||
return channel
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const fallback = (exchange: WebSocketChannelExchange): WebSocketChannelExecution => ({
|
||||
frames: exchange.fallback(),
|
||||
complete: Effect.void,
|
||||
})
|
||||
|
||||
const start = Effect.fn("SessionModelTransport.start")(function* (
|
||||
owner: State,
|
||||
exchange: WebSocketChannelExchange,
|
||||
lifecycle: { delivery: Delivery },
|
||||
) {
|
||||
if (owner.closed)
|
||||
return yield* transportError("start", "Session WebSocket owner is closed", {
|
||||
operation: "request",
|
||||
code: "owner-closed",
|
||||
phase: "queue",
|
||||
delivery: "not-sent",
|
||||
})
|
||||
const key = affinity(exchange)
|
||||
const now = yield* Clock.currentTimeMillis
|
||||
const current = owner.channel
|
||||
const rotateAfterMs = exchange.connect.rotateAfterMs ?? ROTATE_AFTER_MS
|
||||
const rotation = current
|
||||
? current.poisoned
|
||||
? "poisoned"
|
||||
: current.affinity !== key
|
||||
? "affinity"
|
||||
: now - current.openedAt >= rotateAfterMs
|
||||
? "age"
|
||||
: undefined
|
||||
: undefined
|
||||
if (current && rotation) {
|
||||
yield* Effect.logDebug("session websocket rotating", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
reason: rotation,
|
||||
})
|
||||
yield* metric("rotation", { reason: rotation })
|
||||
yield* metric("reconnect")
|
||||
yield* closeChannel(owner, current)
|
||||
}
|
||||
|
||||
lifecycle.delivery = owner.channel ? "ready" : "connecting"
|
||||
if (owner.channel)
|
||||
yield* Effect.logDebug("session websocket reused", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
})
|
||||
if (owner.channel) yield* metric("reuse")
|
||||
const channel = owner.channel
|
||||
? owner.channel
|
||||
: yield* open(owner, exchange, key).pipe(
|
||||
Effect.catch((error) =>
|
||||
error.reason._tag === "Transport" && error.reason.code === "owner-closed"
|
||||
? Effect.fail(error)
|
||||
: Effect.logWarning("session websocket connect failed; using http", {
|
||||
sessionTransport: "websocket",
|
||||
phase: "connect",
|
||||
delivery: "not-sent",
|
||||
code: error.reason._tag === "Transport" ? error.reason.code : error.reason._tag,
|
||||
}).pipe(
|
||||
Effect.andThen(metric("connect_failure")),
|
||||
Effect.andThen(metric("fallback")),
|
||||
Effect.andThen(Effect.succeed(undefined)),
|
||||
),
|
||||
),
|
||||
)
|
||||
if (!channel) return fallback(exchange)
|
||||
lifecycle.delivery = "ready"
|
||||
|
||||
if (channel.pending) {
|
||||
channel.pending = undefined
|
||||
channel.checkpoint = undefined
|
||||
}
|
||||
|
||||
const create = yield* exchange.driver.create(channel.checkpoint).pipe(
|
||||
Effect.tapError(() => closeChannel(owner, channel)),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
)
|
||||
if (create.mode === "full") channel.checkpoint = undefined
|
||||
const active: Active = { queue: yield* Queue.bounded<string, AIError>(INBOUND_CAPACITY), lifecycle }
|
||||
channel.active = active
|
||||
lifecycle.delivery = "send-attempted"
|
||||
const sent = yield* channel.connection.sendText(create.message).pipe(
|
||||
Effect.withSpan("SessionModelTransport.send"),
|
||||
Effect.onInterrupt(() => closeChannel(owner, channel)),
|
||||
Effect.result,
|
||||
)
|
||||
if (sent._tag === "Failure") {
|
||||
const failure = sent.failure
|
||||
const notSent = failure.reason._tag === "Transport" && failure.reason.delivery === "not-sent"
|
||||
yield* closeChannel(owner, channel)
|
||||
if (notSent) {
|
||||
yield* metric("fallback")
|
||||
return fallback(exchange)
|
||||
}
|
||||
yield* metric("ambiguous_delivery")
|
||||
return yield* annotate(failure, { phase: "send", delivery: "ambiguous" })
|
||||
}
|
||||
yield* metric("send")
|
||||
|
||||
let terminal: ChannelObservation | undefined
|
||||
const token = {}
|
||||
let staged: ChannelCheckpoint | undefined
|
||||
const frames = Stream.fromQueue(active.queue).pipe(
|
||||
Stream.timeoutOrElse({
|
||||
duration: IDLE_TIMEOUT,
|
||||
orElse: () =>
|
||||
Stream.fail(
|
||||
transportError("receive", "Timed out waiting for WebSocket data", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-timeout",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
}),
|
||||
),
|
||||
}),
|
||||
Stream.mapEffect((frame) => exchange.driver.observe(create, frame)),
|
||||
Stream.tap((observation) =>
|
||||
Effect.sync(() => {
|
||||
if (!observationTerminal(observation)) return
|
||||
terminal = observation
|
||||
lifecycle.delivery = "terminal"
|
||||
staged = observation.type === "completed" ? observation.checkpoint : undefined
|
||||
if (staged) channel.pending = { token, checkpoint: staged }
|
||||
if (observation.type !== "completed" || !staged) channel.checkpoint = undefined
|
||||
}),
|
||||
),
|
||||
Stream.takeUntil(observationTerminal),
|
||||
Stream.mapEffect(observationFrame),
|
||||
Stream.ensuring(
|
||||
Effect.gen(function* () {
|
||||
if (channel.active === active) channel.active = undefined
|
||||
const pending = yield* Queue.size(active.queue)
|
||||
yield* Queue.shutdown(active.queue)
|
||||
if (terminal && pending === 0) {
|
||||
yield* metric("terminal", { type: terminal.type })
|
||||
if (terminal.type === "rejected") yield* metric("rejection", { recovery: terminal.recovery })
|
||||
if (terminal.type === "rejected" && terminal.recovery === "rotate-and-retry-full")
|
||||
yield* closeChannel(owner, channel)
|
||||
return
|
||||
}
|
||||
yield* metric("cancellation")
|
||||
channel.checkpoint = undefined
|
||||
channel.pending = undefined
|
||||
const error = terminal
|
||||
? transportError("receive", "WebSocket data arrived after the terminal event", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "idle-data",
|
||||
phase: "receive",
|
||||
delivery: "accepted",
|
||||
})
|
||||
: transportError("execute", "Session WebSocket exchange did not reach a terminal event", {
|
||||
url: exchange.connect.url,
|
||||
operation: "read",
|
||||
code: "incomplete",
|
||||
phase: "receive",
|
||||
delivery: lifecycle.delivery === "provider-observed" ? "accepted" : "ambiguous",
|
||||
})
|
||||
yield* poison(owner, channel, error)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const complete = Effect.sync(() => {
|
||||
if (owner.channel !== channel || channel.pending?.token !== token) return
|
||||
channel.checkpoint = channel.pending.checkpoint
|
||||
channel.pending = undefined
|
||||
})
|
||||
return { frames, complete }
|
||||
})
|
||||
|
||||
const bind = (sessionID: SessionSchema.ID): WebSocketChannelExecutor => ({
|
||||
execute: (exchange) => {
|
||||
const owner = state(sessionID)
|
||||
const lifecycle = { delivery: "queued" as Delivery }
|
||||
let complete = Effect.void
|
||||
return Effect.succeed({
|
||||
frames: Stream.unwrap(
|
||||
Effect.acquireRelease(owner.lock.take(1), () => owner.lock.release(1), { interruptible: true }).pipe(
|
||||
Effect.andThen(start(owner, exchange, lifecycle)),
|
||||
Effect.tap((execution) =>
|
||||
Effect.sync(() => {
|
||||
complete = execution.complete
|
||||
}),
|
||||
),
|
||||
Effect.map((execution) => execution.frames),
|
||||
),
|
||||
),
|
||||
complete: Effect.suspend(() => complete),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const close = Effect.fn("SessionModelTransport.close")(function* (sessionID: SessionSchema.ID) {
|
||||
const owner = states.get(sessionID)
|
||||
if (!owner) return
|
||||
states.delete(sessionID)
|
||||
owner.closed = true
|
||||
if (owner.channel) yield* closeChannel(owner, owner.channel)
|
||||
})
|
||||
const closeAll = Effect.suspend(() => {
|
||||
const owners = Array.from(states.values())
|
||||
states.clear()
|
||||
return Effect.forEach(
|
||||
owners,
|
||||
(owner) => {
|
||||
owner.closed = true
|
||||
return owner.channel ? closeChannel(owner, owner.channel) : Effect.void
|
||||
},
|
||||
{ discard: true },
|
||||
)
|
||||
})
|
||||
|
||||
yield* Effect.addFinalizer(() => closeAll)
|
||||
return Service.of({ bind, close, closeAll })
|
||||
}),
|
||||
)
|
||||
|
||||
export const layer = Layer.unwrap(
|
||||
Effect.map(Socket.WebSocketConstructor, (constructor) =>
|
||||
makeLayer({
|
||||
open: (input) =>
|
||||
WebSocketTransport.open(input).pipe(Effect.provideService(Socket.WebSocketConstructor, constructor)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [webSocketConstructor] })
|
||||
@@ -19,7 +19,6 @@ import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionSchema } from "../schema.js"
|
||||
import { SessionStore } from "../store.js"
|
||||
@@ -35,7 +34,7 @@ import { SessionRunnerRetry } from "./retry.js"
|
||||
import { SessionUsage } from "../usage.js"
|
||||
import { ToolOutput } from "../../tool-output.js"
|
||||
|
||||
/** How one model call ended: settled, awaiting retry/recovery, or restarted by compaction. */
|
||||
/** How one model call ended: settled, awaiting a scheduled retry, or restarted by compaction. */
|
||||
type CallOutcome = Data.TaggedEnum<{
|
||||
Completed: { readonly needsContinuation: boolean; readonly step: number }
|
||||
Retry: { readonly step: number }
|
||||
@@ -44,7 +43,6 @@ type CallOutcome = Data.TaggedEnum<{
|
||||
readonly error: SessionRunnerRetry.RetryableFailure["error"]
|
||||
readonly step: number
|
||||
}
|
||||
RecoverFull: { readonly step: number }
|
||||
Restart: { readonly step: number; readonly recoveredOverflow: boolean }
|
||||
}>
|
||||
const CallOutcome = Data.taggedEnum<CallOutcome>()
|
||||
@@ -110,7 +108,6 @@ const layer = Layer.effect(
|
||||
const store = yield* SessionStore.Service
|
||||
const context = yield* SessionContext.Service
|
||||
const modelRequests = yield* SessionModelRequest.Service
|
||||
const modelTransport = yield* SessionModelTransport.Service
|
||||
const snapshots = yield* Snapshot.Service
|
||||
const db = (yield* Database.Service).db
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
@@ -208,15 +205,12 @@ const layer = Layer.effect(
|
||||
let currentStep = step
|
||||
// Overflow recovery is one-shot: a call after recovery must not recover another overflow.
|
||||
let recoverOverflow = true
|
||||
// Continuation rejection permits one immediate full-context Physical Attempt without generic backoff.
|
||||
let recoverContinuation = true
|
||||
while (true) {
|
||||
const outcome = yield* callModel(
|
||||
sessionID,
|
||||
currentPromotable,
|
||||
currentStep,
|
||||
recoverOverflow,
|
||||
recoverContinuation,
|
||||
assistantMessageID,
|
||||
).pipe(Effect.catchTag("SessionRunner.RetryableFailure", waitForRetry))
|
||||
if (outcome._tag === "Completed") return { needsContinuation: outcome.needsContinuation, step: outcome.step }
|
||||
@@ -238,7 +232,6 @@ const layer = Layer.effect(
|
||||
if (outcome.recoveredOverflow) recoverOverflow = false
|
||||
assistantMessageID = SessionMessage.ID.create()
|
||||
}
|
||||
if (outcome._tag === "RecoverFull") recoverContinuation = false
|
||||
// Neither a retry nor a compaction restart re-promotes input.
|
||||
currentPromotable = undefined
|
||||
currentStep = outcome.step
|
||||
@@ -254,7 +247,6 @@ const layer = Layer.effect(
|
||||
promotable: SessionInbox.Promotable | undefined,
|
||||
step: number,
|
||||
recoverOverflow: boolean,
|
||||
recoverContinuation: boolean,
|
||||
assistantMessageID: SessionMessage.ID,
|
||||
) {
|
||||
const selected = yield* context.select(sessionID)
|
||||
@@ -420,13 +412,6 @@ const layer = Layer.effect(
|
||||
// escapes as a scheduled retry or fails the assistant durably.
|
||||
const llmFailure = streamFailure instanceof AIError ? streamFailure : undefined
|
||||
const llmError = llmFailure && !publisher.record().providerFailed ? toSessionError(llmFailure) : undefined
|
||||
if (
|
||||
recoverContinuation &&
|
||||
llmFailure?.reason._tag === "Transport" &&
|
||||
(llmFailure.reason.recovery === "retry-full" || llmFailure.reason.recovery === "rotate-and-retry-full") &&
|
||||
!publisher.record().outputStarted
|
||||
)
|
||||
return CallOutcome.RecoverFull({ step: currentStep })
|
||||
if (
|
||||
llmFailure &&
|
||||
llmError &&
|
||||
@@ -568,7 +553,6 @@ const layer = Layer.effect(
|
||||
(yield* SessionInbox.nextSteer(db, sessionID)) ??
|
||||
(promotable === "input" ? yield* SessionInbox.nextQueued(db, sessionID) : undefined)
|
||||
if (pending?.type !== "move") return false
|
||||
yield* modelTransport.close(sessionID)
|
||||
yield* bus.publishAll([
|
||||
[SessionEvent.InboxDelivered, { sessionID, inboxID: pending.id }],
|
||||
[
|
||||
@@ -639,7 +623,6 @@ export const node = makeLocationNode({
|
||||
llmClient,
|
||||
SessionContext.node,
|
||||
SessionModelRequest.node,
|
||||
SessionModelTransport.node,
|
||||
SessionStore.node,
|
||||
SessionCompaction.node,
|
||||
SessionTitle.node,
|
||||
|
||||
@@ -18,9 +18,8 @@ export function isRetryable(error: AIError) {
|
||||
switch (error.reason._tag) {
|
||||
case "RateLimit":
|
||||
case "ProviderInternal":
|
||||
return true
|
||||
case "Transport":
|
||||
return error.reason.delivery === undefined || error.reason.delivery === "not-sent"
|
||||
return true
|
||||
case "InvalidProviderOutput":
|
||||
return error.reason.classification === "incomplete-stream"
|
||||
case "Authentication":
|
||||
|
||||
@@ -2,6 +2,7 @@ import { AIError, ToolFailure } from "@opencode-ai/ai"
|
||||
import { Tool } from "@opencode-ai/schema/tool"
|
||||
import { SessionError } from "@opencode-ai/schema/session-error"
|
||||
import { Permission } from "../permission.js"
|
||||
import { Question } from "../question.js"
|
||||
import { Integration } from "../integration.js"
|
||||
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
@@ -36,6 +37,7 @@ export function toSessionError(cause: unknown): SessionError.Error {
|
||||
}
|
||||
}
|
||||
if (cause instanceof Permission.BlockedError) return { type: "permission.rejected", message: cause.message }
|
||||
if (cause instanceof Question.RejectedError) return { type: "aborted", message: cause.message }
|
||||
if (cause instanceof ToolFailure || cause instanceof Tool.Error) {
|
||||
if (cause.error === undefined) return { type: "tool.execution", message: cause.message }
|
||||
// The canonical error is the sole model-visible representation, so a cause
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
export * as ShellParse from "./parse.js"
|
||||
|
||||
import { Effect } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import { ShellScan } from "@opencode-ai/shell-scan"
|
||||
import os from "os"
|
||||
import path from "path"
|
||||
import type { Node } from "web-tree-sitter"
|
||||
import { shellParserWasm } from "#shell-parser-wasm"
|
||||
import { ShellSelect } from "./select.js"
|
||||
|
||||
type Part = { type: string; text: string }
|
||||
type SourceToken = { raw: string; value: string }
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location"])
|
||||
const CWD = new Set(["cd", "chdir", "popd", "pushd", "push-location", "set-location", "sl"])
|
||||
const POWERSHELL_PATH_FLAGS = new Set(["-literalpath", "-path"])
|
||||
|
||||
const ARITY: Record<string, number> = {
|
||||
@@ -153,205 +149,63 @@ const ARITY: Record<string, number> = {
|
||||
"yarn run": 3,
|
||||
}
|
||||
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (
|
||||
command: string,
|
||||
shell: string,
|
||||
cwd: string,
|
||||
options?: { portable?: boolean },
|
||||
) {
|
||||
if (options?.portable) return yield* Effect.promise(() => scanPortable(command, shell, cwd))
|
||||
return yield* scanLegacy(command, shell, cwd)
|
||||
export const scan = Effect.fn("ShellParse.scan")(function* (command: string, shell: string, cwd: string) {
|
||||
return scanCommands(command, cwd, shell, ShellSelect.ps(shell))
|
||||
})
|
||||
|
||||
const scanLegacy = Effect.fn("ShellParse.scanLegacy")(function* (command: string, shell: string, cwd: string) {
|
||||
const parsers = yield* Effect.promise(load)
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
const tree = (powershell ? parsers.ps : parsers.bash).parse(command)
|
||||
if (!tree) return yield* Effect.fail(new Error("Failed to parse shell command"))
|
||||
|
||||
return yield* Effect.acquireUseRelease(
|
||||
Effect.succeed(tree),
|
||||
(tree) =>
|
||||
Effect.sync(() =>
|
||||
tree.rootNode.descendantsOfType("command").reduce(
|
||||
(result, node) => {
|
||||
if (!node) return result
|
||||
const command = parts(node)
|
||||
const tokens = command.map((part) => part.text)
|
||||
if (tokens.length === 0) return result
|
||||
const name = powershell ? tokens[0].toLowerCase() : tokens[0]
|
||||
if (CWD.has(name)) {
|
||||
result.directories.push(...directoryArgs(command, powershell, cwd, shell))
|
||||
return result
|
||||
}
|
||||
result.commands.push({
|
||||
resource: (node.parent?.type === "redirected_statement" ? node.parent.text : node.text).trim(),
|
||||
save: `${prefix(tokens).join(" ")} *`,
|
||||
})
|
||||
return result
|
||||
},
|
||||
{ commands: [] as Array<{ resource: string; save: string }>, directories: [] as string[] },
|
||||
),
|
||||
),
|
||||
(tree) => Effect.sync(() => tree.delete()),
|
||||
)
|
||||
})
|
||||
|
||||
async function scanPortable(command: string, shell: string, cwd: string) {
|
||||
const { ShellScan } = await import("@opencode-ai/shell-scan")
|
||||
const powershell = ShellSelect.ps(shell)
|
||||
function scanCommands(command: string, cwd: string, shell: string, powershell: boolean) {
|
||||
const result = powershell ? ShellScan.scanPowerShell(command) : ShellScan.scan(command)
|
||||
if (result.kind === "opaque") return { commands: [{ resource: command, save: command }], directories: [] }
|
||||
const carriage = powershell ? command.search(/\r(?!\n)/) : -1
|
||||
if (carriage >= 0) return { commands: [], directories: [] }
|
||||
|
||||
const parsed = result.commands.reduce(
|
||||
if (result.kind === "opaque")
|
||||
return {
|
||||
commands: [{ resource: command, save: command }],
|
||||
directories: [],
|
||||
opaque: true,
|
||||
directoryUnknown: true,
|
||||
}
|
||||
if (
|
||||
result.commands.some((item) => {
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name || !CWD.has(name) || name === "popd" || name === "pop-location") return false
|
||||
return directoryArgs(item.words, powershell, cwd, shell).length === 0
|
||||
})
|
||||
)
|
||||
return { commands: [{ resource: command }], directories: [], opaque: true, directoryUnknown: true }
|
||||
return result.commands.reduce(
|
||||
(output, item) => {
|
||||
const index = item[ShellScan.Nested] ? -1 : command.indexOf(item.resource, output.cursor)
|
||||
const offset = item[ShellScan.Nested]
|
||||
? command.lastIndexOf(item.resource, output.cursor - 1)
|
||||
: index < 0
|
||||
? command.indexOf(item.resource)
|
||||
: index
|
||||
if (index >= 0) output.cursor = index + item.resource.length
|
||||
const before = command.slice(0, Math.max(0, offset))
|
||||
const name = powershell ? item.words[0]?.toLowerCase() : item.words[0]
|
||||
if (!name) return output
|
||||
if (powershell && name === "<") return output
|
||||
if (
|
||||
powershell &&
|
||||
name === "foreach-object" &&
|
||||
item.words.some((word) => word.startsWith("{")) &&
|
||||
!/\|\s*$/.test(before)
|
||||
)
|
||||
return output
|
||||
const tokens = powershell ? powerShellSourceTokens(item.resource) : sourceTokens(item.resource)
|
||||
const sourceHead = powershell ? item.words[0] : tokens.find((token) => token.value === item.words[0])?.raw
|
||||
if (CWD.has(name) && (powershell || sourceHead === item.words[0])) {
|
||||
output.directories.push(...portableDirectoryArgs(item.words, tokens, powershell, cwd, shell))
|
||||
if (CWD.has(name)) {
|
||||
output.directories.push(...directoryArgs(item.words, powershell, cwd, shell))
|
||||
return output
|
||||
}
|
||||
const save = powershell ? powerShellSourcePrefix(tokens, item.words) : bashSourcePrefix(tokens, item.words)
|
||||
output.commands.push({
|
||||
resource: powershell ? item.resource : bashResource(item.resource, before),
|
||||
save: `${save} *`,
|
||||
})
|
||||
output.commands.push({ resource: item.resource, save: `${prefix(item.words).join(" ")} *` })
|
||||
return output
|
||||
},
|
||||
{
|
||||
commands: [] as Array<{ resource: string; save: string }>,
|
||||
directories: [] as string[],
|
||||
cursor: 0,
|
||||
opaque: false,
|
||||
},
|
||||
)
|
||||
return { commands: parsed.commands, directories: parsed.directories }
|
||||
}
|
||||
|
||||
function bashResource(resource: string, before: string) {
|
||||
if (!/(?:&&|\|\||\|&)\s*$|\|\s*$/.test(before)) return resource
|
||||
const redirect = bashRedirect(resource)
|
||||
return redirect < 0 ? resource : resource.slice(0, redirect).replace(/\d+$/, "").trim()
|
||||
}
|
||||
|
||||
function bashRedirect(resource: string) {
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === "$" && resource[index + 1] === "(") index = bashParenthesizedEnd(resource, index + 1)
|
||||
else if (char === "`") index = bashBacktickEnd(resource, index)
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if ((char === "$" || char === "<" || char === ">") && resource[index + 1] === "(") {
|
||||
index = bashParenthesizedEnd(resource, index + 1)
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
index = bashBacktickEnd(resource, index)
|
||||
continue
|
||||
}
|
||||
if (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">")) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function bashParenthesizedEnd(resource: string, start: number) {
|
||||
let level = 1
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = quote === "double" ? undefined : "double"
|
||||
continue
|
||||
}
|
||||
if (quote === "double") continue
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === "(") level++
|
||||
if (char === ")" && --level === 0) return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function bashBacktickEnd(resource: string, start: number) {
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
if (resource[index] === "\\") index++
|
||||
else if (resource[index] === "`") return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function portableDirectoryArgs(
|
||||
command: string[],
|
||||
tokens: SourceToken[],
|
||||
powershell: boolean,
|
||||
cwd: string,
|
||||
shell: string,
|
||||
) {
|
||||
function directoryArgs(command: string[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell) {
|
||||
const start = tokens.findIndex((token) => token.value === command[0])
|
||||
if (start < 0) return []
|
||||
return directoryArgs(
|
||||
tokens.slice(start).map((token) => ({ type: "word", text: token.raw })),
|
||||
false,
|
||||
cwd,
|
||||
shell,
|
||||
)
|
||||
const values = command
|
||||
.slice(1)
|
||||
.filter((part) => !part.startsWith("-"))
|
||||
.map((part) => directoryArgument(part, powershell, cwd, shell))
|
||||
.filter((part) => part !== undefined)
|
||||
if (command[0] === "cd" && (values.length !== 1 || (!path.isAbsolute(values[0]) && Boolean(process.env.CDPATH))))
|
||||
return []
|
||||
return values
|
||||
}
|
||||
|
||||
const start = tokens.findIndex((token) => token.value.toLowerCase() === command[0]?.toLowerCase())
|
||||
if (start < 0) return []
|
||||
const directories: string[] = []
|
||||
let expectsPath = false
|
||||
for (const part of tokens.slice(start + 1).map((token) => token.raw)) {
|
||||
for (const part of command.slice(1)) {
|
||||
if (expectsPath) {
|
||||
const value = directoryArgument(part, true, cwd, shell)
|
||||
const value = directoryArgument(part, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
expectsPath = false
|
||||
continue
|
||||
@@ -360,292 +214,7 @@ function portableDirectoryArgs(
|
||||
expectsPath = POWERSHELL_PATH_FLAGS.has(part.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directoryArgument(part, true, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
}
|
||||
|
||||
function sourceTokens(resource: string) {
|
||||
const tokens: SourceToken[] = []
|
||||
let raw = ""
|
||||
let value = ""
|
||||
let quote: "single" | "double" | "backtick" | undefined
|
||||
let substitution = 0
|
||||
let redirect = false
|
||||
|
||||
const finish = () => {
|
||||
if (!raw) return
|
||||
if (!redirect) tokens.push({ raw, value })
|
||||
raw = ""
|
||||
value = ""
|
||||
redirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
raw += char
|
||||
if (char === "'") quote = undefined
|
||||
else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
raw += char
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "\\" && index + 1 < resource.length) {
|
||||
const next = resource[index + 1]
|
||||
if ('$`"\\\n'.includes(next)) {
|
||||
raw += resource[++index]
|
||||
if (next !== "\n") value += next
|
||||
} else value += char
|
||||
} else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "backtick") {
|
||||
raw += char
|
||||
value += char
|
||||
if (char === "`" && resource[index - 1] !== "\\") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
raw += char
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
raw += char
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`") {
|
||||
raw += char
|
||||
value += char
|
||||
quote = "backtick"
|
||||
continue
|
||||
}
|
||||
if (char === "\\" && index + 1 < resource.length) {
|
||||
if (resource[index + 1] === "\n") {
|
||||
finish()
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (!raw && /\s/.test(resource[index + 1])) {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
raw += char + resource[++index]
|
||||
value += resource[index]
|
||||
continue
|
||||
}
|
||||
if ((char === "<" || char === ">") && resource[index + 1] === "(") {
|
||||
const end = bashParenthesizedEnd(resource, index + 1)
|
||||
if (raw) {
|
||||
raw += resource.slice(index, end + 1)
|
||||
value += resource.slice(index, end + 1)
|
||||
}
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (char === "$" && resource[index + 1] === "(") substitution++
|
||||
if (char === ")" && substitution > 0) substitution--
|
||||
if (substitution === 0 && /\s/.test(char)) {
|
||||
finish()
|
||||
continue
|
||||
}
|
||||
if (substitution === 0 && (char === "<" || char === ">" || (char === "&" && resource[index + 1] === ">"))) {
|
||||
if (/^\d+$/.test(value)) {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else finish()
|
||||
redirect = true
|
||||
if (char === "&") index++
|
||||
while (/[<>&|]/.test(resource[index + 1] ?? "")) index++
|
||||
continue
|
||||
}
|
||||
raw += char
|
||||
value += char
|
||||
}
|
||||
finish()
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
function bashSourcePrefix(tokens: SourceToken[], words: string[]) {
|
||||
const start = tokens.findIndex((token) => token.value === words[0])
|
||||
if (start < 0) {
|
||||
const command = tokens.findIndex((token) => !/^[A-Za-z_][A-Za-z0-9_]*\+?=/.test(token.raw))
|
||||
return prefix(tokens.slice(Math.max(0, command)).map((token) => token.raw)).join(" ")
|
||||
}
|
||||
const source = tokens
|
||||
.slice(start)
|
||||
.map((token) => token.raw)
|
||||
.filter((token) => !/^\$\([\s\S]*\)$/.test(token) && !/^`[\s\S]*`$/.test(token))
|
||||
return prefix(source).join(" ")
|
||||
}
|
||||
|
||||
function powerShellSourcePrefix(tokens: SourceToken[], words: string[]) {
|
||||
const start = tokens.findIndex((token) => token.value.toLowerCase() === words[0]?.toLowerCase())
|
||||
if (start < 0) return prefix(words).join(" ")
|
||||
return prefix(tokens.slice(start).map((token) => token.raw)).join(" ")
|
||||
}
|
||||
|
||||
function powerShellSourceTokens(resource: string) {
|
||||
const tokens: SourceToken[] = []
|
||||
let raw = ""
|
||||
let value = ""
|
||||
let quote: "single" | "double" | undefined
|
||||
let redirect = false
|
||||
|
||||
const finish = () => {
|
||||
if (!raw) return
|
||||
if (!redirect) tokens.push({ raw, value })
|
||||
raw = ""
|
||||
value = ""
|
||||
redirect = false
|
||||
}
|
||||
|
||||
for (let index = 0; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (quote === "single") {
|
||||
raw += char
|
||||
if (char === "'" && resource[index + 1] === "'") {
|
||||
raw += resource[++index]
|
||||
value += "'"
|
||||
} else if (char === "'") quote = undefined
|
||||
else value += char
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
raw += char
|
||||
if (char === '"') quote = undefined
|
||||
else if (char === "`" && index + 1 < resource.length) {
|
||||
raw += resource[++index]
|
||||
value += resource[index]
|
||||
} else value += char
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
raw += char
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
raw += char
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "`" && index + 1 < resource.length) {
|
||||
raw += char + resource[++index]
|
||||
if (resource[index] !== "\n" && resource[index] !== "\r") value += resource[index]
|
||||
continue
|
||||
}
|
||||
if (char === "{" && !raw) {
|
||||
const end = powerShellBracedEnd(resource, index)
|
||||
raw = resource.slice(index, end + 1)
|
||||
value = raw
|
||||
index = end
|
||||
continue
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
finish()
|
||||
continue
|
||||
}
|
||||
if (char === ">") {
|
||||
if (resource[index + 1] && !/[\s>&]/.test(resource[index + 1])) {
|
||||
raw += char
|
||||
value += char
|
||||
continue
|
||||
}
|
||||
if (/^\d+$/.test(value)) {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else if (raw === "*") {
|
||||
raw = ""
|
||||
value = ""
|
||||
} else finish()
|
||||
redirect = true
|
||||
while (/[>&\d]/.test(resource[index + 1] ?? "")) index++
|
||||
continue
|
||||
}
|
||||
if ((char === "&" || char === ".") && !raw && tokens.length === 0) continue
|
||||
raw += char
|
||||
value += char
|
||||
}
|
||||
finish()
|
||||
return tokens
|
||||
}
|
||||
|
||||
function powerShellBracedEnd(resource: string, start: number) {
|
||||
let level = 1
|
||||
let quote: "single" | "double" | undefined
|
||||
for (let index = start + 1; index < resource.length; index++) {
|
||||
const char = resource[index]
|
||||
if (char === "`" && quote !== "single") {
|
||||
index++
|
||||
continue
|
||||
}
|
||||
if (quote === "single") {
|
||||
if (char === "'" && resource[index + 1] === "'") index++
|
||||
else if (char === "'") quote = undefined
|
||||
continue
|
||||
}
|
||||
if (quote === "double") {
|
||||
if (char === '"') quote = undefined
|
||||
continue
|
||||
}
|
||||
if (char === "'") {
|
||||
quote = "single"
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
quote = "double"
|
||||
continue
|
||||
}
|
||||
if (char === "{") level++
|
||||
if (char === "}" && --level === 0) return index
|
||||
}
|
||||
return resource.length - 1
|
||||
}
|
||||
|
||||
function parts(node: Node) {
|
||||
return Array.from({ length: node.childCount }).flatMap((_, index): Part[] => {
|
||||
const child = node.child(index)
|
||||
if (!child) return []
|
||||
if (child.type === "command_elements")
|
||||
return Array.from({ length: child.childCount }).flatMap((_, itemIndex): Part[] => {
|
||||
const item = child.child(itemIndex)
|
||||
if (!item || item.type === "command_argument_sep" || item.type === "redirection") return []
|
||||
return [{ type: item.type, text: item.text }]
|
||||
})
|
||||
if (!["command_name", "command_name_expr", "word", "string", "raw_string", "concatenation"].includes(child.type))
|
||||
return []
|
||||
return [{ type: child.type, text: child.text }]
|
||||
})
|
||||
}
|
||||
|
||||
function directoryArgs(command: Part[], powershell: boolean, cwd: string, shell: string) {
|
||||
if (!powershell)
|
||||
return command
|
||||
.slice(1)
|
||||
.filter((part) => !part.text.startsWith("-"))
|
||||
.map((part) => directoryArgument(part.text, powershell, cwd, shell))
|
||||
.filter((part) => part !== undefined)
|
||||
|
||||
const directories: string[] = []
|
||||
let path = false
|
||||
for (const part of command.slice(1)) {
|
||||
if (path) {
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
path = false
|
||||
continue
|
||||
}
|
||||
if (part.type === "command_parameter") {
|
||||
path = POWERSHELL_PATH_FLAGS.has(part.text.toLowerCase())
|
||||
continue
|
||||
}
|
||||
const value = directoryArgument(part.text, powershell, cwd, shell)
|
||||
const value = directoryArgument(part, powershell, cwd, shell)
|
||||
if (value) directories.push(value)
|
||||
}
|
||||
return directories
|
||||
@@ -675,6 +244,7 @@ function expandKnownDirectory(value: string) {
|
||||
if (value.includes("$") || value.includes("`") || value.startsWith("(")) return
|
||||
if (value === "~") return os.homedir()
|
||||
if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(os.homedir(), value.slice(2))
|
||||
if (value.startsWith("~")) return
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -691,28 +261,3 @@ function prefix(tokens: string[]) {
|
||||
}
|
||||
return tokens.slice(0, 1)
|
||||
}
|
||||
|
||||
function resolve(asset: string) {
|
||||
if (asset.startsWith("file://")) return fileURLToPath(asset)
|
||||
if (path.isAbsolute(asset)) return asset
|
||||
return fileURLToPath(new URL(asset, import.meta.url))
|
||||
}
|
||||
|
||||
const load = (() => {
|
||||
let loading: ReturnType<typeof initialize> | undefined
|
||||
return () => (loading ??= initialize())
|
||||
})()
|
||||
|
||||
async function initialize() {
|
||||
const { Parser, Language } = await import("web-tree-sitter")
|
||||
await Parser.init({ locateFile: () => resolve(shellParserWasm.runtime) })
|
||||
const [bashLanguage, psLanguage] = await Promise.all([
|
||||
Language.load(resolve(shellParserWasm.bash)),
|
||||
Language.load(resolve(shellParserWasm.powershell)),
|
||||
])
|
||||
const bash = new Parser()
|
||||
bash.setLanguage(bashLanguage)
|
||||
const ps = new Parser()
|
||||
ps.setLanguage(psLanguage)
|
||||
return { bash, ps }
|
||||
}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import runtime from "web-tree-sitter/tree-sitter.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import bash from "tree-sitter-bash/tree-sitter-bash.wasm" with { type: "file" }
|
||||
// @ts-ignore Bun embeds static file imports when compiling the CLI.
|
||||
import powershell from "tree-sitter-powershell/tree-sitter-powershell.wasm" with { type: "file" }
|
||||
|
||||
export const shellParserWasm = { runtime, bash, powershell }
|
||||
@@ -1,11 +0,0 @@
|
||||
import { createRequire } from "node:module"
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
|
||||
export const shellParserWasm = {
|
||||
runtime: process.env.OPENCODE_TREE_SITTER_WASM_PATH ?? require.resolve("web-tree-sitter/tree-sitter.wasm"),
|
||||
bash: process.env.OPENCODE_TREE_SITTER_BASH_WASM_PATH ?? require.resolve("tree-sitter-bash/tree-sitter-bash.wasm"),
|
||||
powershell:
|
||||
process.env.OPENCODE_TREE_SITTER_POWERSHELL_WASM_PATH ??
|
||||
require.resolve("tree-sitter-powershell/tree-sitter-powershell.wasm"),
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// workerd has no filesystem paths to tree-sitter wasm artifacts. ShellParse
|
||||
// loads these lazily and degrades when initialization fails, so empty paths
|
||||
// keep module load side-effect free instead of resolving from disk.
|
||||
export const shellParserWasm = { runtime: "", bash: "", powershell: "" }
|
||||
@@ -5,7 +5,7 @@ import { ToolFailure } from "@opencode-ai/ai"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Form } from "../../form.js"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { Question } from "../../question.js"
|
||||
|
||||
export const name = "question"
|
||||
|
||||
|
||||
@@ -163,11 +163,7 @@ export const Plugin = {
|
||||
invocation.cwd = target.absolute
|
||||
finalTimeout = invocation.timeout
|
||||
if (!unrestricted) {
|
||||
const portable =
|
||||
Config.latest(yield* config.entries(), "experimental")?.portable_shell_scanner === true
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute, {
|
||||
portable,
|
||||
})
|
||||
const parsed = yield* ShellParse.scan(invocation.command, invocation.shell, target.absolute)
|
||||
const directories = yield* Effect.forEach(parsed.directories, (directory) =>
|
||||
mutation.resolve({ path: path.resolve(target.absolute, directory), kind: "directory" }),
|
||||
)
|
||||
@@ -187,11 +183,21 @@ export const Plugin = {
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if ("directoryUnknown" in parsed && parsed.directoryUnknown)
|
||||
yield* permission.assert({
|
||||
action: "external_directory",
|
||||
resources: ["*"],
|
||||
opaque: true,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
})
|
||||
if (parsed.commands.length > 0)
|
||||
yield* permission.assert({
|
||||
action: name,
|
||||
resources: parsed.commands.map((command) => command.resource),
|
||||
save: parsed.commands.map((command) => command.save),
|
||||
save: parsed.commands.flatMap((command) => ("save" in command ? [command.save] : [])),
|
||||
opaque: parsed.opaque,
|
||||
sessionID: context.sessionID,
|
||||
agent: context.agent,
|
||||
source,
|
||||
|
||||
@@ -468,7 +468,7 @@ it.effect("derives status and code when the AI SDK error message is empty", () =
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves complete HTTP context on AI SDK call errors", () =>
|
||||
it.effect("preserves redacted HTTP context on AI SDK call errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* streamFailure(
|
||||
apiCallError({
|
||||
@@ -480,7 +480,7 @@ it.effect("preserves complete HTTP context on AI SDK call errors", () =>
|
||||
const http = "http" in error.reason ? error.reason.http : undefined
|
||||
expect(http?.request.url).toBe("https://api.example.com/chat")
|
||||
expect(http?.response?.status).toBe(404)
|
||||
expect(http?.response?.headers["authorization"]).toBe("Bearer secret-token")
|
||||
expect(http?.response?.headers["authorization"]).toBe("<redacted>")
|
||||
expect(http?.body).toBe('{"error":{"message":"","code":"not_found"}}')
|
||||
}),
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user