Compare commits

..
Author SHA1 Message Date
Shoubhit Dash 5c1e7ca00f feat(app): show last turn changes in review panel 2026-09-27 15:06:22 +05:30
107 changed files with 956 additions and 2729 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"@opencode/core": patch
---
Correct directory page headings when the read offset is zero.
-20
View File
@@ -24,10 +24,6 @@ on:
description: "Override version (optional)"
required: false
type: string
release_notes:
description: "Reviewed V2 release notes for the Discord announcement (optional)"
required: false
type: string
concurrency: ${{ github.workflow }}-${{ github.ref }}-${{ (github.ref_name == 'v2' && (inputs.version || inputs.bump) && 'release') || inputs.version || inputs.bump }}
@@ -657,19 +653,3 @@ jobs:
OPENCODE_DESKTOP_DIST: /tmp/desktop
CLOUDFLARE_ACCOUNT_ID: 15d29c8639fd3733b1b5486a2acfd968
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
notify-discord-v2:
needs:
- version
- publish
if: github.repository == 'anomalyco/opencode' && github.ref_name == 'v2' && needs.version.outputs.release && needs.publish.result == 'success'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
# Unlike dev, V2 publishes a tag rather than a GitHub Release event.
- name: Announce V2 release in Discord
uses: SethCohen/github-releases-to-discord@24d166886aee4646d448c8a389ff9e1ebcab3682 # v1.20.0
with:
webhook_url: ${{ secrets.DISCORD_WEBHOOK }}
release_name: OpenCode V2 ${{ needs.version.outputs.tag }}
release_body: ${{ inputs.release_notes }}
release_html_url: https://github.com/${{ github.repository }}/tree/${{ needs.version.outputs.tag }}
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1790510107,
"narHash": "sha256-EVMNYv7hYDDD9TGVT/hIyTYgpiXA8y3m5xIEIxuGNU0=",
"lastModified": 1776683584,
"narHash": "sha256-NuTLMrr10Tng72hurYG8jYQ4XKK8wnpJmOGcPiis96g=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3181085bfd08663b6b9e60bc7a8395c2aaa741bd",
"rev": "9dd5558b06dbdacbf635a3dd36dce1b1a7ee3a89",
"type": "github"
},
"original": {
+2 -2
View File
@@ -98,11 +98,11 @@ When a provider supports multiple physical transports, selection remains executi
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`; a `failed` generation maps the provider's error code through a per-protocol `FAILURE` table via `MediaProtocol.failure` so rejected inputs are not reported as retryable `ProviderInternal`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram and ElevenLabs are inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
Every modality route is the inline | stream | queued union (transcription uses all three: OpenAI and Gemini stream, Deepgram is inline, AssemblyAI is queued), every client is `MediaClient.make(Service, { modality, responseEvents })` (`src/media-client.ts`), which dispatches on the route's `kind`, and every model composes through `composeRoute`. fal queue protocols come from `protocols/utils/fal-queue.ts`, bodies are `json`, `multipart`, or `binary` (a raw upload), and a queued protocol that must upload media before submitting implements `start.prepare` (`MediaProtocol.Prepare`; AssemblyAI `/v2/upload`).
### URL Construction
+12 -24
View File
@@ -129,9 +129,8 @@ VercelAIGateway.configure().experimental.evaluation("typesafe-ai/jev")
OpenRouter reads `OPENROUTER_API_KEY`. Vercel reads `AI_GATEWAY_API_KEY`, then `VERCEL_OIDC_TOKEN`.
The common API uses `boolean`; System One routes lower it to native `noul`.
Choice and score answers include `confidence` when the provider returns it, such as
`response.answers.department.confidence`. Score legends remain available in provider metadata, and
the provider's rounded probabilities are returned unchanged.
Choice and score confidence plus score legends remain available in provider metadata, and the
provider's rounded probabilities are returned unchanged.
## Alibaba Cloud Model Studio
@@ -753,10 +752,7 @@ const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"
Status polls, result fetches, cancels, and asset downloads all run through the same request executor with the route's
auth. `Generation.await` and `Generation.events` fail with a
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Status polls and result fetches retry transient
failures (rate limits, provider 5xx, network errors) with backoff that honors `retry-after`, always within
`poll.timeout`; submits and cancels never retry. Interrupting a wait (or aborting its `signal`) does not cancel the
provider job, which keeps running and billing: call `cancel()` to stop it. Failed,
`Timeout` reason when `poll.timeout` (default 10 minutes) elapses. Failed,
cancelled, and expired generations fail typed with the provider's terminal document on `reason.body`; moderation
outcomes (Veo `raiMediaFilteredReasons`, xAI `respect_moderation`, Runway `SAFETY.*` codes) surface as `notices` when
a video is still returned and as a `ContentPolicy` reason when nothing is.
@@ -777,9 +773,7 @@ Provider notes:
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `events`,
`result`, `refresh`, `cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and
`ai.video.stream` mirror the Effect API. The handle's `status` and `progress` are a snapshot from when it was
created; `refresh()` resolves to a new handle. Every promise method and stream accepts `{ signal }`: like `fetch`,
aborting rejects the Promise or throws from the `for await` loop with `signal.reason` (an `AbortError` `DOMException`
unless `abort(reason)` passed one), while `break` stops a stream without throwing.
created; `refresh()` resolves to a new handle.
```ts
import { ai } from "@opencode/ai/promise"
@@ -877,12 +871,11 @@ for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode."
## Transcription
Transcription (speech-to-text) is the one modality whose providers use every route kind: OpenAI and Gemini stream,
Deepgram and ElevenLabs answer inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream`
work on all of them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with
`UnsupportedOperation` elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`,
`Deepgram`, `ElevenLabs`, and `AssemblyAI` facades. Common fields (`language`, `prompt`,
`timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower natively or fail with a typed `AIError` before
any network call; a route may return more than asked.
Deepgram answers inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream` work on all of
them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with `UnsupportedOperation`
elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`, `Deepgram`, and `AssemblyAI`
facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower
natively or fail with a typed `AIError` before any network call; a route may return more than asked.
```ts
import { Console, Effect, Stream } from "effect"
@@ -894,7 +887,7 @@ const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
const program = Effect.gen(function* () {
const audio = yield* Media.file("./call.mp3")
// Speaker-labelled segments; labels are provider-native strings ("A", "0", "spk:0", "speaker_0").
// Speaker-labelled segments; labels are provider-native strings ("A", "0", "spk:0").
const response = yield* Transcription.generate({
model: Deepgram.configure({ apiKey }).transcription("nova-3"),
audio,
@@ -904,7 +897,7 @@ const program = Effect.gen(function* () {
response.text // "Hello from OpenCode."
response.segments // [{ text, startSeconds, endSeconds, speaker: "0" }]
response.words // [{ text, startSeconds, endSeconds, speaker, confidence }]
response.language // the provider's own value, lowercased ("en", "eng", "english", "en_us")
response.language // the provider's own value, lowercased ("en", "english", "en_us")
// Text deltas as the model transcribes, then one finish carrying the whole transcript.
yield* Transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }).pipe(
@@ -928,12 +921,7 @@ Provider notes:
- **OpenAI** takes inline audio only; `diarize` needs `gpt-4o-transcribe-diarize`, timestamps need `whisper-1`, and `whisper-1` does not stream.
- **Gemini** needs a transcribe model (`gemini-3.5-transcribe`); `prompt` and `speakers` fail typed.
- **Deepgram** detects the language unless `language` is set; vocabulary goes in `providerOptions.keyterm`.
- **ElevenLabs** (`scribe_v2`) uploads inline audio as the multipart `file` and sends a URL as `source_url`. Words
always carry timestamps, and segments are speaker turns, so `diarize`, `timestamps: "segment"`, or `speakers` turns
on diarization. `speakers` is an upper bound (`num_speakers`); `prompt` fails typed (vocabulary goes in
`providerOptions.keyterms`), as do webhook delivery and per-channel output (`use_multi_channel` without
`multichannel_output_style: "combined"`).
- **AssemblyAI** uploads inline audio before submitting and treats `speakers` as the exact speaker count.
- **AssemblyAI** uploads inline audio before submitting and is the only route that accepts `speakers`.
The promise client mirrors the Effect API:
+17 -27
View File
@@ -1,6 +1,7 @@
# Media generation in `@opencode/ai` — public API direction
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
Status: phases 1–4 implemented (through Image queued routes and partial images; ElevenLabs Scribe transcription
pending); phase 5 proposal.
## Goal
@@ -270,8 +271,8 @@ Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incre
#### Transcription (STT)
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `elevenlabs-transcription`,
`assemblyai-transcription`; new `AssemblyAI` facade).
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `assemblyai-transcription`; new `AssemblyAI`
facade).
```ts
const request = Transcription.request({
@@ -280,7 +281,7 @@ const request = Transcription.request({
language: "en", // provider-native passthrough
timestamps: "segment", // none | segment | word
diarize: true,
speakers: 2, // speaker count (AssemblyAI exact, ElevenLabs maximum)
speakers: 2, // exact speaker count (AssemblyAI only)
providerOptions: { known_speaker_names: ["agent"] },
})
@@ -308,23 +309,17 @@ upload); `packages/ai/AGENTS.md` (Media Routes) describes both.
Settled rules:
- **Timestamps.** A granularity the selected route or model cannot produce fails as `UnsupportedOperation`
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram, ElevenLabs, and AssemblyAI
always return words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
(`media.timestamps`), following Speech; a route that returns more than asked (Deepgram and AssemblyAI always return
words) is not stripped. Segments always carry start and end times: Gemini times each transcription part from its
word offsets, so segment timestamps and diarization also request word offsets there.
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A`,
ElevenLabs `speaker_0` — with no cross-provider speaker model. `speakers` is the number of speakers to label:
AssemblyAI (`speakers_expected`) treats it as an exact constraint rather than a hint, and ElevenLabs
(`num_speakers`) as the maximum. Both turn on diarization for it; the other routes reject it.
- **Segments from words.** ElevenLabs returns only a token list (`word`, `spacing`, `audio_event`), so its segments
are speaker turns: consecutive words and spacing with one `speaker_id`, text joined from the provider's own spacing
tokens. `words` drops spacing and audio events. Segments therefore need diarization, which `timestamps: "segment"`
turns on, as AssemblyAI's utterances need speaker labels.
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A` — with no
cross-provider speaker model. `speakers` is the exact number of speakers to label, which AssemblyAI (`speakers_expected`, the only route that
accepts it) treats as a constraint rather than a hint.
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
AssemblyAI and ElevenLabs `language_code`). `response.language` is the provider's own value, lowercased but not
normalized: an ISO code on most routes (AssemblyAI's detection returns `en`, ElevenLabs ISO 639-3 `eng`), `english`
from whisper-1. Deepgram and AssemblyAI assume English unless asked to detect, so a missing `language` enables their
detection.
AssemblyAI `language_code`). `response.language` is the provider's own value, lowercased but not normalized: an
ISO code on most routes (AssemblyAI's detection returns `en`), `english` from whisper-1. Deepgram and AssemblyAI
assume English unless asked to detect, so a missing `language` enables their detection.
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
turns (one part per turn), which join with a space.
@@ -337,12 +332,11 @@ Settled rules:
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
| ElevenLabs | inline | multipart `file`, or `source_url` | words always; `segment` → `diarize` (speaker turns) | `diarize` | `prompt`; `webhook`, per-channel `use_multi_channel` | `seconds` (`audio_duration_secs`) |
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
with the realtime work in phase 5.
with the realtime work in phase 5. ElevenLabs Scribe is not implemented yet.
### `Generation` — shared async execution
@@ -367,10 +361,6 @@ Poll = { interval?: Duration; timeout?: Duration }
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
Status polls and result reads retry transient failures (rate limits, provider 5xx, and transport errors, classified by the same `isRetryable` the Session runner uses) inside `MediaRoute.queued`. Only the HTTP exchange retries, never the decoded document: a terminal `failed` generation also surfaces as `ProviderInternal` and must not be re-read. Gaps grow exponentially from 1s with jitter, up to 30s each, honoring a provider `retry-after` up to that cap, for at most 8 retries. `await`, `events`, and `Video.stream` cut retries off at `poll.timeout` and fail with `Timeout`, so retries never extend the caller's deadline; a direct `result()` or `resume` read is bounded by the retry cap alone. `start` and `cancel` never retry: a repeated submit can start and bill a second job. The policy is internal; there is no option for it.
Interrupting `await`, `events`, or `Video.stream` (or aborting the promise API's `signal`) stops waiting only. The provider job keeps running and billing; call `cancel()` explicitly to stop it.
### Usage
```ts
@@ -412,7 +402,7 @@ for await (const event of ai.llm.stream(request)) { … }
await ai.dispose()
```
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. Aborting an `AbortSignal` interrupts the work and, like `fetch`, rejects the Promise or throws from the stream with `signal.reason` instead of ending the stream as if complete. Nothing in `src/*` except this entrypoint knows about promises.
Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown as-is. `AbortSignal` maps to interruption. Nothing in `src/*` except this entrypoint knows about promises.
### Providers
@@ -424,7 +414,7 @@ implemented):
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
| `XAI` | ✓ | ✓ | ✓ | | | |
| `ElevenLabs` | | | | ✓ | Scribe | *soundEffect, music (phase 5)* |
| `ElevenLabs` | | | | ✓ | *Scribe (pending)* | *soundEffect, music (phase 5)* |
| `Cartesia` | | | | ✓ | | |
| `Deepgram` | | | | Aura | ✓ | |
| `Fal` | | ✓ (queued) | ✓ | | | |
@@ -477,7 +467,7 @@ Foundation + Image ship together as the reference implementation, serially. Vide
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, ElevenLabs Scribe, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Pending: ElevenLabs Scribe. Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints.
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
@@ -63,7 +63,6 @@ export const ChoiceAnswer = Schema.Struct({
type: Schema.Literal("choice"),
choice: Schema.String,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
@@ -71,7 +70,6 @@ export const ScoreAnswer = Schema.Struct({
type: Schema.Literal("score"),
score: Schema.Number,
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
confidence: Schema.optional(Probability),
})
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
@@ -94,7 +92,6 @@ export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
readonly type: "choice"
readonly choice: Extract<keyof Criteria, string>
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
readonly confidence?: number
}
: Question extends { readonly type: "score" }
? ScoreAnswer
+5 -10
View File
@@ -142,37 +142,32 @@ export const model = <Options extends EvaluationOptions = EvaluationOptions>(cfg
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
)
const confidence: Record<string, number> = {}
const legend: Record<string, Record<string, Schema.Json>> = {}
const answers = Object.fromEntries(
Object.entries(data.answers).map(([id, answer]): [string, EvaluationAnswer] => {
if (answer.type === "noul") return [id, { type: "boolean", probability: answer.noul }]
if (answer.type === "choice") {
if (answer.confidence !== undefined) confidence[id] = answer.confidence
return [
id,
{
type: "choice",
choice: answer.choice,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
}
if (answer.confidence !== undefined) confidence[id] = answer.confidence
if (answer.legend !== undefined) legend[id] = answer.legend
return [
id,
{
type: "score",
score: answer.score,
probabilities: answer.probabilities,
...(answer.confidence === undefined ? {} : { confidence: answer.confidence }),
},
]
return [id, { type: "score", score: answer.score, probabilities: answer.probabilities }]
}),
)
const meta = {
...(data.id === undefined ? {} : { responseId: data.id }),
...(data.provider === undefined ? {} : { provider: data.provider }),
...data.provider_metadata?.[cfg.providerMetadataKey],
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
...(Object.keys(legend).length === 0 ? {} : { legend }),
}
return new EvaluationResponse({
+28 -47
View File
@@ -102,7 +102,7 @@ export class Generation<Response> {
return settled.pipe(
// Non-completed terminal states also go through `result` so the route can surface its provider failure body.
Effect.flatMap((generation) => generation.result()),
Effect.timeoutOrElse({ duration: timeout, orElse: () => timeoutError(this.id, timeout) }),
Effect.timeoutOrElse({ duration: timeout, orElse: () => this.timeoutError(timeout) }),
)
}
@@ -123,7 +123,20 @@ export class Generation<Response> {
Clock.currentTimeMillis.pipe(
Effect.map((start) => {
const deadline = start + Duration.toMillis(timeout)
const refresh = within(this.refresh(), this.id, timeout, deadline)
// Fail before polling once the deadline has passed: a fast status request could otherwise win the zero-budget
// race and schedule another zero-delay poll.
const refresh = Clock.currentTimeMillis.pipe(
Effect.flatMap((now) =>
now >= deadline
? this.timeoutError(timeout)
: this.refresh().pipe(
Effect.timeoutOrElse({
duration: Duration.millis(deadline - now),
orElse: () => this.timeoutError(timeout),
}),
),
),
)
const schedule = this.schedule(options?.poll).pipe(
Schedule.modifyDelay((meta) =>
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
@@ -144,6 +157,15 @@ export class Generation<Response> {
return { type: "generation-progress", id: this.id, progress: this.progress }
}
private timeoutError(timeout: Duration.Duration) {
return new AIError({
reason: new TimeoutError({
message: `Generation ${this.id} did not finish within ${Duration.format(timeout)}`,
timeoutMs: Duration.toMillis(timeout),
}),
})
}
private poll(poll: Poll | undefined) {
return this.refresh().pipe(
Effect.repeat({ schedule: this.schedule(poll), until: (generation) => generation.terminal }),
@@ -155,53 +177,12 @@ export class Generation<Response> {
}
}
/** `events` followed by the expanded result, with the result fetch bounded by the same `poll.timeout` deadline. */
export const resultEvents = <Response, A>(
generation: Generation<Response>,
expand: (response: Response) => ReadonlyArray<A>,
options?: AwaitOptions,
): Stream.Stream<Observation | A, AIError> => {
const timeout = Duration.fromInputUnsafe(options?.poll?.timeout ?? DEFAULT_POLL_TIMEOUT)
return Stream.unwrap(
Clock.currentTimeMillis.pipe(
Effect.map((start) =>
generation.events(options).pipe(
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
Stream.concat(
Stream.fromIterableEffect(
within(generation.result(), generation.id, timeout, start + Duration.toMillis(timeout)).pipe(
Effect.map(expand),
),
),
),
),
),
),
): Stream.Stream<Observation | A, AIError> =>
generation.events(options).pipe(
Stream.filter((event): event is Observation => event.type !== "generation-finished"),
Stream.concat(Stream.fromIterableEffect(Effect.map(generation.result(), expand))),
)
}
/**
* Run `effect` within the time left until `deadline`. Fails before starting once the deadline has passed: a fast
* request could otherwise win the zero-budget race and schedule another zero-delay poll.
*/
const within = <A>(effect: Effect.Effect<A, AIError>, id: string, timeout: Duration.Duration, deadline: number) =>
Clock.currentTimeMillis.pipe(
Effect.flatMap((now) =>
now >= deadline
? Effect.fail(timeoutError(id, timeout))
: effect.pipe(
Effect.timeoutOrElse({
duration: Duration.millis(deadline - now),
orElse: () => Effect.fail(timeoutError(id, timeout)),
}),
),
),
)
const timeoutError = (id: string, timeout: Duration.Duration) =>
new AIError({
reason: new TimeoutError({
message: `Generation ${id} did not finish within ${Duration.format(timeout)}`,
timeoutMs: Duration.toMillis(timeout),
}),
})
+1 -1
View File
@@ -4,7 +4,7 @@ export { ImageClient } from "./image-client.js"
export { Auth } from "./route/auth.js"
export { Provider } from "./provider.js"
export { ProviderPackage } from "./provider-package.js"
export { isContextOverflow, isContextOverflowFailure, isRetryable } from "./provider-error.js"
export { isContextOverflow, isContextOverflowFailure } from "./provider-error.js"
export type {
RouteLanguageModelInput,
RouteRoutedLanguageModelInput,
+6 -7
View File
@@ -42,7 +42,7 @@ export type GenerationHandle<Response> = Snapshot & {
/** Serializable JSON; pass it back to `resume` from another process. */
readonly token: unknown
readonly await: (options?: AwaitOptions & RunOptions) => Promise<Response>
/** Status observations until the first terminal one, polling like `await`; abort throws `signal.reason`. */
/** Status observations until the first terminal one, polling like `await`; abort ends iteration without throwing. */
readonly events: (options?: AwaitOptions & RunOptions) => AsyncIterable<Event>
/** The result without polling; fails when the generation has not completed. */
readonly result: (options?: RunOptions) => Promise<Response>
@@ -50,16 +50,15 @@ export type GenerationHandle<Response> = Snapshot & {
readonly cancel: (options?: RunOptions) => Promise<void>
}
// Fails with `signal.reason` so aborted calls reject and aborted streams throw like `fetch`: an `AbortError` by default.
const abortEffect = (signal: AbortSignal | undefined) =>
signal === undefined
? Effect.never
: Effect.callback<never, unknown>((resume) => {
: Effect.callback<void>((resume) => {
if (signal.aborted) {
resume(Effect.fail(signal.reason))
resume(Effect.void)
return
}
const onAbort = () => resume(Effect.fail(signal.reason))
const onAbort = () => resume(Effect.void)
signal.addEventListener("abort", onAbort, { once: true })
return Effect.sync(() => signal.removeEventListener("abort", onAbort))
})
@@ -69,14 +68,14 @@ export const make = (options: Options = {}) => {
/** Run any package Effect (for example `LLMClient.compact(...)`) inside this runtime. */
const run = <A, E>(effect: Effect.Effect<A, E, Services>, options?: RunOptions) =>
runtime.runPromise(Effect.raceFirst(effect, abortEffect(options?.signal)))
runtime.runPromise(effect, { signal: options?.signal })
const iterate = <A, E>(stream: Stream.Stream<A, E, Services>, options?: RunOptions): AsyncIterable<A> =>
Stream.toAsyncIterable(
Stream.unwrap(
runtime.contextEffect.pipe(
Effect.map(
(context): Stream.Stream<A, unknown> =>
(context): Stream.Stream<A, E> =>
stream.pipe(Stream.interruptWhen(abortEffect(options?.signal)), Stream.provideContext(context)),
),
),
@@ -6,7 +6,6 @@ import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
import { ProviderShared } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
import { SpeakerTurns } from "./utils/speaker-turns.js"
const route = MediaProtocol.identity({ id: "deepgram-transcription", name: "Deepgram", provider: "deepgram" })
export const DEFAULT_BASE_URL = "https://api.deepgram.com"
@@ -116,6 +115,16 @@ const speaker = (value: number | undefined) => (value === undefined ? undefined
const wordText = (word: typeof Word.Type) => word.punctuated_word ?? word.word
// Utterances split on pauses, not speakers: the v2 diarizer labels a whole utterance with one speaker even when its
// words change speaker, so segments split each utterance at speaker changes.
const speakerTurns = (words: ReadonlyArray<typeof Word.Type>) =>
words.reduce<Array<Array<typeof Word.Type>>>((turns, word) => {
const last = turns.at(-1)
if (last === undefined || last[0].speaker !== word.speaker) return [...turns, [word]]
last.push(word)
return turns
}, [])
const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
) {
@@ -127,8 +136,6 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
const requestID = output.value.metadata?.request_id
return new TranscriptionResponse({
text: alternative.transcript,
// Utterances split on pauses, not speakers: the v2 diarizer labels a whole utterance with one speaker even when
// its words change speaker, so segments split each utterance at speaker changes.
segments: output.value.results.utterances?.flatMap((utterance) =>
utterance.words === undefined || utterance.words.length === 0
? [
@@ -139,7 +146,7 @@ const decodeResponse = Effect.fn("DeepgramTranscription.decodeResponse")(functio
speaker: speaker(utterance.speaker),
},
]
: SpeakerTurns.group(utterance.words, (word) => word.speaker).map((turn) => ({
: speakerTurns(utterance.words).map((turn) => ({
text: turn.map(wordText).join(" "),
startSeconds: turn[0].start,
endSeconds: turn[turn.length - 1].end,
@@ -1,211 +0,0 @@
import { Effect, Schema } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { MediaProtocol } from "../route/media-protocol.js"
import { MediaRoute } from "../route/media.js"
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
import { TranscriptionModel, TranscriptionResponse, type TranscriptionRequestFor } from "../transcription.js"
import { mediaTypeExtension } from "../utils/media-type.js"
import { ProviderShared, optionalNull } from "./shared.js"
import { MediaInput } from "./utils/media-input.js"
import { SpeakerTurns } from "./utils/speaker-turns.js"
const route = MediaProtocol.identity({
id: "elevenlabs-transcription",
name: "ElevenLabs Transcription",
provider: "elevenlabs",
})
export const DEFAULT_BASE_URL = "https://api.elevenlabs.io"
export const PATH = "/v1/speech-to-text"
// ---------------------------------------------------------------------------
// 1. Public model input
// ---------------------------------------------------------------------------
export type ElevenLabsTranscriptionOptions = {
readonly tag_audio_events?: boolean
readonly timestamps_granularity?: OpenString<"none" | "word" | "character">
readonly diarization_threshold?: number
readonly file_format?: OpenString<"pcm_s16le_16" | "other">
readonly temperature?: number
readonly seed?: number
readonly keyterms?: ReadonlyArray<string>
readonly no_verbatim?: boolean
readonly detect_speaker_roles?: boolean
readonly use_speaker_library?: boolean
readonly entity_detection?: string | ReadonlyArray<string>
readonly entity_redaction?: string | ReadonlyArray<string>
readonly entity_redaction_mode?: OpenString<"redacted" | "entity_type" | "enumerated_entity_type">
} & Record<string, unknown>
export type Request = TranscriptionRequestFor<ElevenLabsTranscriptionOptions>
// ---------------------------------------------------------------------------
// 2. Response schema
// ---------------------------------------------------------------------------
/** `type` is `word`, `spacing` (the whitespace between words), or `audio_event` (`(laughter)`). */
const Token = Schema.Struct({
text: Schema.String,
type: Schema.String,
start: optionalNull(Schema.Number),
end: optionalNull(Schema.Number),
speaker_id: optionalNull(Schema.String),
logprob: optionalNull(Schema.Number),
})
type Token = Schema.Schema.Type<typeof Token>
const Transcript = Schema.Struct({
language_code: optionalNull(Schema.String),
text: Schema.String,
words: optionalNull(Schema.Array(Token)),
transcription_id: optionalNull(Schema.String),
audio_duration_secs: optionalNull(Schema.Number),
})
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
/** Speaker turns are the only segments ElevenLabs can produce, and `num_speakers` only applies to diarization. */
const diarizes = (request: Request) =>
request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
const RESERVED_FORM_FIELDS = new Set([
"file",
"cloud_storage_url",
"source_url",
"model_id",
"language_code",
"diarize",
"num_speakers",
])
const validate = (request: Request, overlay: Record<string, unknown>) => {
// Webhook requests return 202 with no transcript; the result arrives at a configured webhook instead.
if (overlay.webhook === true)
return Effect.fail(route.unsupported("transcription.webhook", `${route.name} does not deliver to webhooks`))
// Separate multichannel output replaces the transcript with one transcript per channel.
if (overlay.use_multi_channel === true && overlay.multichannel_output_style !== "combined")
return Effect.fail(
route.unsupported(
"transcription.multichannel",
`${route.name} returns a single transcript; set multichannel_output_style: "combined" to merge channels`,
),
)
if (overlay.timestamps_granularity === "none" && (request.timestamps === "word" || diarizes(request)))
return Effect.fail(
route.unsupported(
"media.timestamps",
`${route.name} cannot return word timestamps or speaker turns with timestamps_granularity: "none"`,
),
)
return Effect.void
}
const fromRequest = Effect.fn("ElevenLabsTranscription.fromRequest")(function* (request: Request) {
const overlay = mergeJsonRecords(request.providerOptions, request.http?.body) ?? {}
yield* validate(request, overlay)
const form = new FormData()
const url = ProviderShared.mediaUrl(request.audio)
if (url === undefined) {
const extension = mediaTypeExtension(request.audio.mediaType)
const audio = yield* MediaInput.inlineBytes(route.id, request.audio)
form.append(
"file",
MediaInput.blob(audio, request.audio.mediaType),
extension === undefined ? "audio" : `audio.${extension}`,
)
}
MediaInput.appendFields(
form,
{
model_id: request.model.id,
// `cloud_storage_url` is deprecated in favor of `source_url`, which accepts any hosted audio or video URL.
source_url: url,
language_code: request.language,
diarize: diarizes(request) ? true : undefined,
num_speakers: request.speakers,
},
{ overlay, reserved: RESERVED_FORM_FIELDS, repeatArrays: "key" },
)
return MediaProtocol.multipart(form)
})
// ---------------------------------------------------------------------------
// 6. Response decoding
// ---------------------------------------------------------------------------
const decodeTranscript = route.decodeJson(Transcript)
type TimedWord = Token & { readonly start: number; readonly end: number }
const isTimedWord = (token: Token): token is TimedWord =>
token.type === "word" && typeof token.start === "number" && typeof token.end === "number"
/** Turn text keeps the provider's own spacing tokens, so languages written without spaces are not re-spaced. */
const speakerTurns = (tokens: ReadonlyArray<Token>) =>
SpeakerTurns.group(
tokens.filter((token) => token.type === "word" || token.type === "spacing"),
(token) => token.speaker_id,
).flatMap((turn) => {
const words = turn.filter(isTimedWord)
if (words.length === 0) return []
return [
{
text: turn
.map((token) => token.text)
.join("")
.trim(),
startSeconds: words[0].start,
endSeconds: words[words.length - 1].end,
speaker: turn[0].speaker_id ?? undefined,
},
]
})
const decodeResponse = Effect.fn("ElevenLabsTranscription.decodeResponse")(function* (
response: HttpClientResponse.HttpClientResponse,
context: MediaProtocol.DecodeContext<Request>,
) {
const output = yield* decodeTranscript(response)
const transcript = output.value
const tokens = transcript.words ?? []
const duration = transcript.audio_duration_secs ?? undefined
const transcriptionID = transcript.transcription_id ?? undefined
return new TranscriptionResponse({
text: transcript.text,
segments: diarizes(context.request) ? speakerTurns(tokens) : undefined,
words: tokens.filter(isTimedWord).map((word) => ({
text: word.text,
startSeconds: word.start,
endSeconds: word.end,
speaker: word.speaker_id ?? undefined,
confidence: typeof word.logprob === "number" ? Math.exp(word.logprob) : undefined,
})),
language: transcript.language_code?.toLowerCase(),
durationSeconds: duration,
usage: duration === undefined ? undefined : { type: "seconds", seconds: duration },
providerMetadata: transcriptionID === undefined ? undefined : { elevenlabs: { transcriptionId: transcriptionID } },
})
})
// ---------------------------------------------------------------------------
// 7. Protocol and route
// ---------------------------------------------------------------------------
export const protocol = MediaProtocol.inline<Request, TranscriptionResponse>(route, {
unsupported: ["prompt"],
body: { from: fromRequest },
response: { decode: decodeResponse },
})
export const model = (input: MediaRoute.ModelInput) =>
TranscriptionModel.fromRoute<ElevenLabsTranscriptionOptions>(
{ protocol, baseURL: DEFAULT_BASE_URL, path: PATH },
input,
)
export const ElevenLabsTranscription = {
protocol,
model,
} as const
+1 -14
View File
@@ -36,9 +36,7 @@ const StartResponse = Schema.Struct({ name: Schema.String })
const Operation = Schema.Struct({
done: Schema.optional(Schema.Boolean),
error: Schema.optional(
Schema.Struct({ code: Schema.optional(Schema.Number), message: Schema.optional(Schema.String) }),
),
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
response: Schema.optional(
Schema.Struct({
generateVideoResponse: Schema.optional(
@@ -62,16 +60,6 @@ const Operation = Schema.Struct({
metadata: Schema.optional(Schema.Unknown),
})
// Operation errors are `google.rpc.Status`; unlisted codes (INTERNAL, UNAVAILABLE, ...) are provider-side.
const FAILURE = {
3: "InvalidRequest", // INVALID_ARGUMENT
7: "Authentication", // PERMISSION_DENIED
8: "RateLimit", // RESOURCE_EXHAUSTED
9: "InvalidRequest", // FAILED_PRECONDITION
11: "InvalidRequest", // OUT_OF_RANGE
16: "Authentication", // UNAUTHENTICATED
} as const satisfies Record<number, MediaProtocol.Failure>
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
@@ -166,7 +154,6 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
return yield* output.ended(
"failed",
`${route.name} operation failed${operation.error?.message === undefined ? "" : `: ${operation.error.message}`}`,
MediaProtocol.failure(FAILURE, operation.error?.code),
)
const generated = operation.response?.generateVideoResponse
// Downloads require the same API key as the poll; the asset carries it transiently and follows the redirect.
+9 -26
View File
@@ -64,14 +64,6 @@ const OpenAIChatTool = Schema.Struct({
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
// Gemini's OpenAI-compatible surface carries thought signatures in tool call
// `extra_content` and rejects replayed parallel calls without them:
// https://ai.google.dev/gemini-api/docs/thinking#signatures
const ExtraContent = Schema.Struct({
google: Schema.Struct({ thought_signature: Schema.String }),
})
const decodeExtraContent = (value: unknown) => Option.getOrUndefined(Schema.decodeUnknownOption(ExtraContent)(value))
const OpenAIChatAssistantToolCall = Schema.Struct({
id: Schema.String,
type: Schema.tag("function"),
@@ -79,7 +71,6 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
name: Schema.String,
arguments: Schema.String,
}),
extra_content: Schema.optional(ExtraContent),
})
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
@@ -121,6 +112,12 @@ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail)
const knownReasoningDetails = (details: ReadonlyArray<unknown>) =>
details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)))
// Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
// extension until direct Google OpenAI-compatible routing is supported here:
// https://github.com/vercel/ai/issues/11590
// https://github.com/vercel/ai/pull/11745
// https://ai.google.dev/gemini-api/docs/thought-signatures#openai
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({
type: Schema.Literal("text"),
@@ -245,7 +242,6 @@ const OpenAIChatToolCallDelta = Schema.Struct({
index: optionalNull(Schema.Number),
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
extra_content: optionalNull(Schema.Unknown),
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
@@ -298,7 +294,6 @@ interface PendingToolDelta {
readonly id?: string
readonly name?: string
readonly input: string
readonly extraContent?: Schema.Schema.Type<typeof ExtraContent>
}
export interface ParserState {
@@ -352,17 +347,13 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ type: "function" as const, function: { name } }),
})
const lowerToolCall = (
part: ToolCallPart,
options: LoweringOptions & { readonly providerMetadataKey: string },
): OpenAIChatAssistantToolCall => ({
const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChatAssistantToolCall => ({
id: options.toolCallID?.(part.id) ?? part.id,
type: "function",
function: {
name: part.name,
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
},
extra_content: decodeExtraContent(part.providerMetadata?.[options.providerMetadataKey]?.extraContent),
})
const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart) {
@@ -730,9 +721,7 @@ const detectSupportsStore = (provider: string, baseURL: string | undefined): boo
p === "vercel-ai-gateway" || url.includes("ai-gateway.vercel.sh") || url.includes("vercel.sh")
const isAntLing = p === "ant-ling" || url.includes("api.ant-ling.com")
const isOpencode = p === "opencode" || url.includes("opencode.ai")
const isGemini = url.includes("generativelanguage.googleapis.com")
const isNonStandard =
isGemini ||
isNvidia ||
isCerebras ||
isXai ||
@@ -1125,13 +1114,12 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
const id = current?.id ?? pending?.id ?? (tool.id || undefined)
const name = current?.name ?? pending?.name ?? (tool.function?.name || undefined)
const text = `${pending?.input ?? ""}${tool.function?.arguments ?? ""}`
const extraContent = pending?.extraContent ?? decodeExtraContent(tool.extra_content)
latestToolIndex = index
nextToolIndex = Math.max(nextToolIndex, index + 1)
if (!current && (!id || !name)) {
pendingTools = {
...pendingTools,
[index]: { id: id || undefined, name: name || undefined, input: text, extraContent },
[index]: { id: id || undefined, name: name || undefined, input: text },
}
continue
}
@@ -1143,12 +1131,7 @@ const step = (state: ParserState, event: OpenAIChatEvent) =>
ADAPTER,
tools,
index,
{
id: id || undefined,
name: name || undefined,
text,
providerMetadata: extraContent && { [state.providerMetadataKey]: { extraContent } },
},
{ id: id || undefined, name: name || undefined, text },
"OpenAI Chat tool call delta is missing id or name",
)
if (ToolStream.isError(result))
@@ -193,7 +193,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
{
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
reserved: RESERVED_FORM_FIELDS,
repeatArrays: "key[]",
repeatArrays: true,
},
)
return MediaProtocol.multipart(form)
+1 -6
View File
@@ -137,12 +137,7 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
const message = `${route.name} task failed${code === undefined ? "" : ` (${code})`}${task.failure ? `: ${task.failure}` : ""}`
// Runway failure codes are dotted paths; every moderation outcome carries a SAFETY segment.
if (code !== undefined && /(^|\.)SAFETY(\.|$)/.test(code)) return yield* output.contentPolicy(message)
// ASSET.INVALID rejects the caller's input media; Runway documents it as not retryable.
return yield* output.ended(
"failed",
message,
code !== undefined && /^ASSET\.INVALID(\.|$)/.test(code) ? "InvalidRequest" : "ProviderInternal",
)
return yield* output.ended("failed", message)
}
if (status === "cancelled")
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
@@ -71,9 +71,8 @@ export const imageOutput = (
}
/**
* Append multipart text fields: strings as-is, other values as JSON, or scalar arrays as one part per item with
* `repeatArrays`, named `key[]` or `key`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace
* route-owned fields.
* Append multipart text fields: strings as-is, other values as JSON, or arrays as repeated `key[]` parts with
* `repeatArrays`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace route-owned fields.
*/
export const appendFields = (
form: FormData,
@@ -81,13 +80,13 @@ export const appendFields = (
options: {
readonly overlay?: Record<string, unknown>
readonly reserved: ReadonlySet<string>
readonly repeatArrays?: "key[]" | "key"
readonly repeatArrays?: true
},
) => {
const overlay = Object.entries(options.overlay ?? {}).filter(([key]) => !options.reserved.has(key))
Object.entries(mergeJsonRecords(fields, Object.fromEntries(overlay)) ?? {}).forEach(([key, value]) => {
if (Array.isArray(value) && value.every(isScalar) && options.repeatArrays !== undefined)
return value.forEach((item) => form.append(options.repeatArrays === "key[]" ? `${key}[]` : key, String(item)))
if (Array.isArray(value) && options.repeatArrays)
return value.forEach((item) => form.append(`${key}[]`, String(item)))
form.append(key, typeof value === "string" ? value : encodeJson(value))
})
}
@@ -1,10 +0,0 @@
/** Split an ordered token list into runs of consecutive tokens with the same speaker. */
export const group = <Item>(items: ReadonlyArray<Item>, speaker: (item: Item) => unknown) =>
items.reduce<Array<Array<Item>>>((turns, item) => {
const last = turns.at(-1)
if (last === undefined || speaker(last[0]) !== speaker(item)) return [...turns, [item]]
last.push(item)
return turns
}, [])
export * as SpeakerTurns from "./speaker-turns.js"
@@ -147,12 +147,7 @@ export const appendOrStart = <K extends StreamKey>(
route: string,
tools: State<K>,
key: K,
delta: {
readonly id?: string
readonly name?: string
readonly text: string
readonly providerMetadata?: ProviderMetadata
},
delta: { readonly id?: string; readonly name?: string; readonly text: string },
missingToolMessage: string,
): AppendOutcome<K> | AIError => {
const current = tools[key]
@@ -166,7 +161,7 @@ export const appendOrStart = <K extends StreamKey>(
namespace: current?.namespace,
input: `${current?.input ?? ""}${delta.text}`,
providerExecuted: current?.providerExecuted,
providerMetadata: current?.providerMetadata ?? delta.providerMetadata,
providerMetadata: current?.providerMetadata,
}
if (current && delta.text.length === 0 && current.id === id && current.name === name)
return { tools, tool: current, events: [] }
-8
View File
@@ -65,13 +65,6 @@ const STATUS = {
expired: "expired",
} as const satisfies Record<string, Status>
// Documented video error codes; `service_unavailable`, `internal_error`, and unknown codes are provider-side.
const FAILURE = {
invalid_argument: "InvalidRequest",
failed_precondition: "InvalidRequest",
permission_denied: "Authentication",
} as const satisfies Record<string, MediaProtocol.Failure>
// ---------------------------------------------------------------------------
// 5. Request body construction
// ---------------------------------------------------------------------------
@@ -150,7 +143,6 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
return yield* output.ended(
"failed",
`${route.name} generation failed${code === undefined ? "" : ` (${code})`}${message === undefined ? "" : `: ${message}`}`,
MediaProtocol.failure(FAILURE, code),
)
}
if (status !== "completed")
-41
View File
@@ -58,47 +58,6 @@ export const isContextOverflowFailure = (failure: unknown) =>
? failure.reason._tag === "InvalidRequest" && failure.reason.classification === "context-overflow"
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
/**
* Whether a failed call may succeed when sent again: rate limits, provider-side failures, transport failures that did
* not deliver an accepted write, and unrecognized failures. Callers decide which calls are safe to repeat.
*/
export const isRetryable = (error: AIError) => {
const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
return true
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
// Read failures can still recover; the caller chooses retry versus continuation from durable output.
case "Transport":
return (
error.reason.delivery !== "rejected" &&
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
)
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
// deterministic evidence, and transient failures are exactly the ones
// that arrive in shapes no classifier anticipates.
case "UnknownProvider":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidRequest":
case "UnsupportedOperation":
case "NoRoute":
case "Timeout":
return false
default: {
const exhaustive: never = error.reason
return exhaustive
}
}
}
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
-5
View File
@@ -3,10 +3,8 @@ import type { ProviderAuthOption } from "../route/auth-options.js"
import { MediaRoute } from "../route/media.js"
import { type HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
import { ElevenLabsSpeech } from "../protocols/elevenlabs-speech.js"
import { ElevenLabsTranscription } from "../protocols/elevenlabs-transcription.js"
export type { ElevenLabsOutputFormat, ElevenLabsSpeechOptions } from "../protocols/elevenlabs-speech.js"
export type { ElevenLabsTranscriptionOptions } from "../protocols/elevenlabs-transcription.js"
export const id = ProviderID.make("elevenlabs")
@@ -26,15 +24,12 @@ const auth = (options: ProviderAuthOption<"optional">) => {
export const configure = (input: Config = {}) => {
const media = MediaRoute.deployment(input, auth(input))
const speech = (modelID: string | ModelID) => ElevenLabsSpeech.model({ ...media, id: modelID })
const transcription = (modelID: string | ModelID) => ElevenLabsTranscription.model({ ...media, id: modelID })
return {
id,
speech,
transcription,
configure,
}
}
export const provider = configure()
export const speech = provider.speech
export const transcription = provider.transcription
+5 -26
View File
@@ -5,14 +5,12 @@ import { Media } from "../media.js"
import type { AuthInput } from "./auth.js"
import {
AIError,
AuthenticationError,
ContentPolicyError,
HttpContext,
InvalidProviderOutputError,
InvalidRequestError,
ProviderID,
ProviderInternalError,
RateLimitError,
UnsupportedOperationError,
} from "../schema/index.js"
@@ -190,16 +188,6 @@ export const stream = <Request, Event, Frame, State>(
// Response helpers
// ---------------------------------------------------------------------------
/** Reasons a provider can report for a `failed` generation; anything it does not classify is `ProviderInternal`. */
const FAILURES = {
InvalidRequest: InvalidRequestError,
Authentication: AuthenticationError,
RateLimit: RateLimitError,
ProviderInternal: ProviderInternalError,
}
export type Failure = keyof typeof FAILURES
const context = (response: HttpClientResponse.HttpClientResponse) =>
new HttpContext({ url: response.request.url, status: response.status, headers: response.headers })
@@ -211,10 +199,9 @@ export const identity = (input: { readonly id: string; readonly name: string; re
/**
* Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed`
* carries the provider's classification, defaulting to `ProviderInternal`; `cancelled`/`expired` mean the result
* will never exist); `pending` is a `result()` read before the generation finished, which is caller misuse;
* `contentPolicy` is a moderated result.
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed` is
* provider-side, `cancelled`/`expired` mean the result will never exist); `pending` is a `result()` read before the
* generation finished, which is caller misuse; `contentPolicy` is a moderated result.
*/
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
const http = context(response)
@@ -236,15 +223,11 @@ export const identity = (input: { readonly id: string; readonly name: string; re
http,
invalid: (message: string, cause?: unknown) =>
new AIError({ reason: new InvalidProviderOutputError({ route: input.id, message, body, http, cause }) }),
ended: (
status: Exclude<Status, "queued" | "running" | "completed">,
message: string,
failure: Failure = "ProviderInternal",
) =>
ended: (status: Exclude<Status, "queued" | "running" | "completed">, message: string) =>
new AIError({
reason:
status === "failed"
? new FAILURES[failure]({ message, body, http })
? new ProviderInternalError({ message, body, http })
: new InvalidRequestError({ message, body, http }),
}),
pending: (id: string) =>
@@ -320,10 +303,6 @@ export const status = <Table extends Record<string, Status>>(
return Effect.succeed(table[raw])
}
/** Map a provider error code through the protocol's table; missing or unmapped codes are `ProviderInternal`. */
export const failure = (table: Readonly<Record<string, Failure>>, code: string | number | undefined): Failure =>
code !== undefined && Object.hasOwn(table, code) ? table[code] : "ProviderInternal"
/** A `url` asset whose provider-declared retention window starts now. */
export const expiringUrl = (url: string, retention: Duration.Duration, options?: Parameters<typeof Media.url>[1]) =>
Clock.currentTimeMillis.pipe(
+4 -34
View File
@@ -1,4 +1,4 @@
import { Duration, Effect, Schedule, Schema, Stream } from "effect"
import { Effect, Schema, Stream } from "effect"
import { Headers, HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
import { Auth, type AuthInput } from "./auth.js"
import { Endpoint } from "./endpoint.js"
@@ -7,7 +7,6 @@ import { RequestExecutor } from "./executor.js"
import { MediaProtocol } from "./media-protocol.js"
import { Generation, isTerminal } from "../generation.js"
import type { Media } from "../media.js"
import { isRetryable } from "../provider-error.js"
import {
AIError,
AIErrorReason,
@@ -138,32 +137,6 @@ export const inline = <Request extends MediaRequest, Response>(
}
}
const READ_RETRY_MAX_DELAY = Duration.seconds(30)
/**
* Status and result reads retry transient failures; `start` and `cancel` never do. Gaps grow exponentially from 1s,
* jittered, up to 30s each, for at most 8 retries (about two minutes when every attempt fails), so a direct
* `Generation.result()` stays bounded; `await` and `events` also cut retries off at `poll.timeout`. A provider
* `retryAfterMs` raises the gap, still capped at 30s.
*/
const READ_RETRY = Schedule.max([
Schedule.min([Schedule.exponential("1 second"), Schedule.spaced(READ_RETRY_MAX_DELAY)]),
Schedule.recurs(8),
]).pipe(
Schedule.jittered,
Schedule.setInputType<AIError>(),
Schedule.modifyDelay(({ input, duration }) =>
Effect.succeed(
Duration.min(
input.reason._tag === "RateLimit" || input.reason._tag === "ProviderInternal"
? Duration.max(duration, Duration.millis(input.reason.retryAfterMs ?? 0))
: duration,
READ_RETRY_MAX_DELAY,
),
),
),
)
/**
* Compose a queued media protocol the same way, adding `start`/`resume` handles whose polls reuse the route's auth,
* deployment headers, and (for `start`) the request's `http` overlay. The token is decoded once at the boundary and
@@ -181,8 +154,6 @@ export const queued = <Request extends MediaRequest, Response, Token>(
const generationRoute = (token: Token, http: HttpOptions | undefined, execute: Execute) => {
const materialize = (asset: Media.Asset) =>
asset.materialize().pipe(Effect.provideService(RequestExecutorService, { execute }))
// Only the GET exchange retries: a decoded terminal failure (`output.ended`) can be a `ProviderInternal` too, and
// re-reading it would spin until the caller's deadline.
const poll = <A>(operation: {
readonly path: (token: Token) => string
readonly decode: (
@@ -190,10 +161,9 @@ export const queued = <Request extends MediaRequest, Response, Token>(
context: MediaProtocol.PollContext<Token>,
) => Effect.Effect<A, AIError>
}) =>
transport.call("GET", operation.path(token), http, execute).pipe(
Effect.retry({ schedule: READ_RETRY, while: isRetryable }),
Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })),
)
transport
.call("GET", operation.path(token), http, execute)
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
const status = poll(protocol.status)
const cancel = protocol.cancel
const send =
+1 -1
View File
@@ -103,7 +103,7 @@ export type TranscriptionRequestInput<Model extends TranscriptionModel = Transcr
// Response and events
// ---------------------------------------------------------------------------
/** Speaker labels are provider-native (`A`, `0`, `spk:0`, `speaker_0`, or a known speaker name). */
/** Speaker labels are provider-native (`A`, `0`, `spk:0`, or a known speaker name). */
export const TranscriptionSegment = Schema.Struct({
text: Schema.String,
startSeconds: Schema.Number,
+1 -2
View File
@@ -39,18 +39,17 @@ describe("experimental Evaluation", () => {
type: "choice",
choice: "billing",
probabilities: { billing: 0.9, technical: 0.1 },
confidence: 0.8,
})
expect(response.answers.urgency).toEqual({
type: "score",
score: 1.2,
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
confidence: 0.6,
})
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
expect(response.usage?.totalTokens).toBe(36)
expect(response.providerMetadata).toEqual({
typesafe: {
confidence: { department: 0.8, urgency: 0.6 },
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
},
})
-2
View File
@@ -26,10 +26,8 @@ const request = Evaluation.request({
const result = EvaluationClient.evaluate(request)
type Result = Success<typeof result>
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
type Confidence = Assert<Equal<Result["answers"]["topic"]["confidence"], number | undefined>>
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
void (true satisfies Choice)
void (true satisfies Confidence)
void (true satisfies ClientRequirements)
Effect.gen(function* () {
-5
View File
@@ -197,11 +197,6 @@ describe("public exports", () => {
expect(Google.configure({ apiKey: "fixture" }).transcription("gemini-3.5-transcribe").route.kind).toBe("stream")
expect(Deepgram.configure({ apiKey: "fixture" }).transcription("nova-3").route.kind).toBe("inline")
expect(AssemblyAI.configure({ apiKey: "fixture" }).transcription("universal-3-5-pro").route.kind).toBe("queued")
expect(ElevenLabs.configure({ apiKey: "fixture" }).transcription("scribe_v2").route.id).toBe(
"elevenlabs-transcription",
)
expect(ElevenLabs.configure({ apiKey: "fixture" }).transcription("scribe_v2").route.kind).toBe("inline")
expect(ElevenLabs.provider.transcription).toBe(ElevenLabs.transcription)
})
test("protocol barrels expose supported low-level routes", () => {
@@ -1,32 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:elevenlabs-transcription",
"provider:elevenlabs",
"protocol:elevenlabs-transcription"
],
"name": "elevenlabs-transcription/groups-diarized-words-into-speaker-turns",
"recordedAt": "2026-09-27T09:35:28.265Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.elevenlabs.io/v1/speech-to-text",
"headers": {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundary356bdc14864a477dbacbfcf60d1ecceb"
},
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"diarize\"\r\n\r\ntrue\r\n--BOUNDARY--\r\n"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"language_code\":\"eng\",\"language_probability\":0.9495430588722229,\"text\":\"Did the release ship? Yes, it shipped this morning\",\"words\":[{\"text\":\"Did\",\"start\":0.34,\"end\":0.44,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.7881377516459906e-6},{\"text\":\" \",\"start\":0.44,\"end\":0.48,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.1920928244535389e-7},{\"text\":\"the\",\"start\":0.48,\"end\":0.56,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-1.1920928244535389e-7},{\"text\":\" \",\"start\":0.56,\"end\":0.6,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.152531907195225e-6},{\"text\":\"release\",\"start\":0.6,\"end\":0.92,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.152531907195225e-6},{\"text\":\" \",\"start\":0.92,\"end\":0.94,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-8.344646857949556e-7},{\"text\":\"ship?\",\"start\":0.94,\"end\":1.26,\"type\":\"word\",\"speaker_id\":\"speaker_0\",\"logprob\":-7.414704032271402e-6},{\"text\":\" \",\"start\":1.26,\"end\":1.26,\"type\":\"spacing\",\"speaker_id\":\"speaker_0\",\"logprob\":-0.0009363081189803779},{\"text\":\"Yes,\",\"start\":1.68,\"end\":2.02,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.003542040009030245},{\"text\":\" \",\"start\":2.02,\"end\":2.48,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.933898824470816e-6},{\"text\":\"it\",\"start\":2.48,\"end\":2.62,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.933898824470816e-6},{\"text\":\" \",\"start\":2.62,\"end\":2.64,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.000013589766240329482},{\"text\":\"shipped\",\"start\":2.66,\"end\":2.9,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-0.000013589766240329482},{\"text\":\" \",\"start\":2.9,\"end\":2.94,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":0.0},{\"text\":\"this\",\"start\":2.94,\"end\":3.12,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":0.0},{\"text\":\" \",\"start\":3.12,\"end\":3.18,\"type\":\"spacing\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.576278118089249e-7},{\"text\":\"morning\",\"start\":3.18,\"end\":3.5,\"type\":\"word\",\"speaker_id\":\"speaker_1\",\"logprob\":-3.576278118089249e-7}],\"transcription_id\":\"cs3I2282TH8hjw12brNg\",\"audio_duration_secs\":3.5526875}"
}
}
]
}
@@ -1,50 +0,0 @@
{
"version": 1,
"metadata": {
"tags": [
"prefix:elevenlabs-transcription",
"provider:elevenlabs",
"protocol:elevenlabs-transcription"
],
"name": "elevenlabs-transcription/transcribes-audio-with-word-timestamps",
"recordedAt": "2026-09-27T09:35:27.686Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.elevenlabs.io/v1/speech-to-text",
"headers": {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundarye2be7b31e94441bbbeb35a9c890a9d74"
},
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY--\r\n"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"language_code\":\"eng\",\"language_probability\":0.6618340611457825,\"text\":\"Hello from OpenCode\",\"words\":[{\"text\":\"Hello\",\"start\":0.4,\"end\":0.66,\"type\":\"word\",\"logprob\":-0.000014781842764932662},{\"text\":\" \",\"start\":0.66,\"end\":0.74,\"type\":\"spacing\",\"logprob\":-3.814689989667386e-6},{\"text\":\"from\",\"start\":0.74,\"end\":0.84,\"type\":\"word\",\"logprob\":-3.814689989667386e-6},{\"text\":\" \",\"start\":0.84,\"end\":0.9,\"type\":\"spacing\",\"logprob\":-0.018268775194883347},{\"text\":\"OpenCode\",\"start\":0.9,\"end\":1.44,\"type\":\"word\",\"logprob\":-0.1251817401498556}],\"transcription_id\":\"D4VfnANM2ArCHTujIb9q\",\"audio_duration_secs\":1.54125}"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://api.elevenlabs.io/v1/speech-to-text",
"headers": {
"content-type": "multipart/form-data; boundary=----WebKitFormBoundaryfb80d0e44d9e44d299416ed546a04056"
},
"body": "--BOUNDARY\r\nContent-Disposition: form-data; name=\"file\"; filename=\"audio.mp3\"\r\nContent-Type: audio/mpeg\r\n\r\n[audio]\r\n--BOUNDARY\r\nContent-Disposition: form-data; name=\"model_id\"\r\n\r\nscribe_v2\r\n--BOUNDARY--\r\n"
},
"response": {
"status": 200,
"headers": {
"content-type": "application/json"
},
"body": "{\"language_code\":\"eng\",\"language_probability\":0.6618340611457825,\"text\":\"Hello from OpenCode\",\"words\":[{\"text\":\"Hello\",\"start\":0.4,\"end\":0.66,\"type\":\"word\",\"logprob\":-0.000023007127310847864},{\"text\":\" \",\"start\":0.66,\"end\":0.74,\"type\":\"spacing\",\"logprob\":-2.3841830625315197e-6},{\"text\":\"from\",\"start\":0.74,\"end\":0.84,\"type\":\"word\",\"logprob\":-2.3841830625315197e-6},{\"text\":\" \",\"start\":0.84,\"end\":0.9,\"type\":\"spacing\",\"logprob\":-0.008306833915412426},{\"text\":\"OpenCode\",\"start\":0.9,\"end\":1.44,\"type\":\"word\",\"logprob\":-0.1075385226868093}],\"transcription_id\":\"SkYplzfq1DW8Ae3bWnoy\",\"audio_duration_secs\":1.54125}"
}
}
]
}
@@ -1,54 +0,0 @@
{
"version": 1,
"metadata": {
"model": "gemini-3.8-flash",
"tags": [
"prefix:openai-compatible-chat",
"provider:google",
"protocol:openai-chat",
"tool",
"tool-loop",
"continuation"
],
"name": "gemini-parallel-tool-signatures",
"recordedAt": "2026-09-28T03:12:05.083Z"
},
"interactions": [
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}},\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723181\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":16,\"prompt_tokens\":74,\"total_tokens\":132}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\",\"tool_calls\":[{\"function\":{\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\",\"name\":\"get_weather\"},\"id\":\"call_723184\",\"type\":\"function\"}]},\"index\":0}],\"created\":1790565123,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: {\"choices\":[{\"delta\":{\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565124,\"id\":\"A9u5aoufBp3rz7IPke_3oAo\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":32,\"prompt_tokens\":74,\"total_tokens\":148}}\n\ndata: [DONE]\n\n"
}
},
{
"transport": "http",
"request": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
"headers": {
"content-type": "application/json"
},
"body": "{\"model\":\"gemini-3.8-flash\",\"messages\":[{\"role\":\"system\",\"content\":\"Call get_weather for every requested city in parallel, then answer in one short sentence.\"},{\"role\":\"user\",\"content\":\"What is the weather in Paris and in Tokyo?\"},{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"id\":\"call_723181\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},\"extra_content\":{\"google\":{\"thought_signature\":\"ErYCCrMCAWkUfRNGh+/Zbc8YUSzk1yfWAfROcjA4HyF1x69jz6167w8zd4n6kZQQ5FDeBZ5HZMEbEkQ4ENOpzsQL8roCR6wONkhXpiduWrTD6XwbP8KGNkf6D1tX/JlBh7G5Cl+0rdjiSOl/mdY1lcjbkfyCRFs5T8odNWMG7WD3rCrXJDFQ/5QfOl+tqVTceKGz2yyXBhOhvsQDU33ulR9tHQJo/Fmx3HNyDdwvmyKUXm+kgqHsYZnkdv6y6xZwy9zBXGUO4QwxelMw3Rrc24Mp2rNbEDZS3YEeP72Jn/hIP5a8XWOx6+zop/4/CjYxxSfN/tJRY5d48NAKRNFzUN1Az8SvAs/N5X2I3/5ZMaDnQWW/BVdS9fm2KFYJ2baqtBiRRnJxMq4ELArkKjGZzHpd97XG8YjV6g==\"}}},{\"id\":\"call_723184\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Tokyo\\\"}\"}}]},{\"role\":\"tool\",\"tool_call_id\":\"call_723181\",\"content\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"},{\"role\":\"tool\",\"tool_call_id\":\"call_723184\",\"content\":\"{\\\"temperature\\\":0,\\\"condition\\\":\\\"unknown\\\"}\"}],\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}"
},
"response": {
"status": 200,
"headers": {
"content-type": "text/event-stream"
},
"body": "data: {\"choices\":[{\"delta\":{\"content\":\"It is currently sunny and 22°C in Paris,\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":13,\"prompt_tokens\":150,\"total_tokens\":226}}\n\ndata: {\"choices\":[{\"delta\":{\"content\":\" while Tokyo is 0°C.\",\"role\":\"assistant\"},\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":150,\"total_tokens\":234}}\n\ndata: {\"choices\":[{\"delta\":{\"extra_content\":{\"google\":{\"thought_signature\":\"EpcDCpQDAWkUfRM325TAUtfFiOeQIEWn/TsCU9oi2js4VPHFeeLfAu+2k8PJm0fN/OaF0y4ovau7S9QIAsuOPI2w2aIyQ2kMGj1XvUyRTvz30DOZgtq1km6W6YGzZyyTCNSeBcpwtJtziHZVVWq9xEI/HHB8Ta1Ot215xnFyDL7iUGEwgGu45/mInpk+SOCYBy9biDddpDxcDi14BGoleArY9XEFAzYLxXssl7HMWjpfee5095im7gD125Nripq1Jf3nGY/2TxqjgQAdJpQybwct63p74O1szGHQxrkBt7AwphDgbOWtLpUP/QJBRdl8qhrozqRe611NQ6V5lMSwpO7OhQ/IDRtWMwOyrrKblZfmMnnPl2/9xDfZRsYnfmWq+7PeAptJl1cDRlMBKhj5iRn31xvN43EiuvWwWPsSndiWvxrMvVBd89TR4u0+z0pYCYZcsFkYKFlPA7pZsdnh6CON24AA5WUkO4dQNoqYdik6aO5hE9jLHG5FHTdr0W69qgPNozbxnO0ptRcOtkGpB9xYUVoTxcSXXZE=\"}},\"role\":\"assistant\"},\"finish_reason\":\"stop\",\"index\":0}],\"created\":1790565125,\"id\":\"BNu5auWeB-2fz7IPxY6l4QY\",\"model\":\"gemini-3.8-flash\",\"object\":\"chat.completion.chunk\",\"usage\":{\"completion_tokens\":21,\"prompt_tokens\":192,\"total_tokens\":276}}\n\ndata: [DONE]\n\n"
}
}
]
}
-24
View File
@@ -842,30 +842,6 @@ describe("Image", () => {
),
)
const falDetail = { detail: [{ loc: ["body", "prompt"], msg: "Invalid input", type: "value_error" }] }
it.effect(
"fails a fal await whose COMPLETED status carries an error with the response_url body and HTTP context",
() =>
Effect.gen(function* () {
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
expect(generation.status).toBe("failed")
const error = yield* generation.await().pipe(Effect.flip)
expect(error.reason._tag).toBe("InvalidRequest")
expect(error.reason.body).toBe(JSON.stringify(falDetail))
expect(error.reason.http).toMatchObject({ url: falToken.responseURL, status: 422 })
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.request.url === falToken.statusURL
? json(input, { status: "COMPLETED", error: "Invalid input", error_type: "ValidationError" })
: json(input, falDetail, { status: 422 }),
),
),
),
),
)
const moderated = { id: "req_1", status: "Content Moderated" }
const prediction = {
id: "p_1",
+1 -85
View File
@@ -22,8 +22,7 @@ const chatBody = sseEvents(
/**
* Executor layer that answers chat completions with SSE text, image generations with one base64 PNG, Runway video
* tasks with a queued submission that succeeds on the second poll, speech with raw audio or SSE audio deltas, OpenAI
* transcription with JSON or SSE text deltas, AssemblyAI transcripts that complete on the first poll, and `slow.test`
* chat completions that send one text delta and never finish.
* transcription with JSON or SSE text deltas, and AssemblyAI transcripts that complete on the first poll.
*/
const executor = (seen: Array<string>) =>
RequestExecutor.layer.pipe(
@@ -56,18 +55,6 @@ const executor = (seen: Array<string>) =>
output: "https://replicate.test/a.webp",
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
})
if (web.url.startsWith("https://slow.test"))
return input.respond(
new ReadableStream({
start: (controller) =>
controller.enqueue(
new TextEncoder().encode(
`data: ${JSON.stringify({ choices: [{ delta: { content: "Hello" } }] })}\n\n`,
),
),
}),
{ headers: { "content-type": "text/event-stream" } },
)
if (web.url.endsWith("/chat/completions"))
return input.respond(chatBody, { headers: { "content-type": "text/event-stream" } })
if (web.url.endsWith("/audio/speech"))
@@ -317,77 +304,6 @@ describe("AI promise client", () => {
await ai.dispose()
})
test("aborted calls reject and aborted streams throw with the signal's reason", async () => {
const ai = AI.make({ layer: executor([]) })
const slow = OpenAI.configure({ apiKey: "test", baseURL: "https://slow.test/v1" }).chat("gpt-4o-mini")
const aborted = new AbortController()
aborted.abort()
const reason = new Error("mine")
const rejected = await ai.run(Effect.never, { signal: aborted.signal }).catch((error: unknown) => error)
expect(rejected).toBe(aborted.signal.reason)
expect(rejected).toMatchObject({ name: "AbortError" })
const inFlight = new AbortController()
setTimeout(() => inFlight.abort(reason), 10)
expect(
await ai.llm
.generate({ model: slow, prompt: "Hello" }, { signal: inFlight.signal })
.catch((error: unknown) => error),
).toBe(reason)
const preAborted = await Array.fromAsync(
ai.speech.stream({ model: openai.speech("gpt-4o-mini-tts"), text: "Hello" }, { signal: aborted.signal }),
).catch((error: unknown) => error)
expect(preAborted).toBe(aborted.signal.reason)
expect(preAborted).toMatchObject({ name: "AbortError" })
const midStream = new AbortController()
const deltas: Array<string> = []
const midStreamFailure = await Array.fromAsync(
ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: midStream.signal }),
(event) => {
if (!LLMEvent.is.textDelta(event)) return
deltas.push(event.text)
midStream.abort()
},
).catch((error: unknown) => error)
expect(deltas).toEqual(["Hello"])
expect(midStreamFailure).toBe(midStream.signal.reason)
expect(midStreamFailure).toMatchObject({ name: "AbortError" })
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
const generation = await ai.video.start({ model, prompt: "A kite" })
const polling = new AbortController()
const events: Array<string> = []
const eventsFailure = await Array.fromAsync(
generation.events({ poll: { interval: 60_000 }, signal: polling.signal }),
(event) => {
events.push(event.type)
polling.abort(reason)
},
).catch((error: unknown) => error)
expect(events).toEqual(["generation-progress"])
expect(eventsFailure).toBe(reason)
await ai.dispose()
})
test("breaking out of an abortable stream cleans up without throwing", async () => {
const ai = AI.make({ layer: executor([]) })
const slow = OpenAI.configure({ apiKey: "test", baseURL: "https://slow.test/v1" }).chat("gpt-4o-mini")
const controller = new AbortController()
const deltas: Array<string> = []
for await (const event of ai.llm.stream({ model: slow, prompt: "Hello" }, { signal: controller.signal })) {
if (!LLMEvent.is.textDelta(event)) continue
deltas.push(event.text)
break
}
controller.abort()
expect(deltas).toEqual(["Hello"])
await ai.dispose()
})
test("the default client is created lazily and can be disposed", async () => {
expect(typeof AI.ai.llm.generate).toBe("function")
expect(typeof AI.ai.image.generate).toBe("function")
@@ -1,50 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect, Stream } from "effect"
import { Transcription } from "../../src/index.js"
import { ElevenLabs } from "../../src/providers.js"
import { recordedTests } from "../recorded-test.js"
import { TRANSCRIPT, audio, audioRecording, dialog } from "./transcription-recording.js"
const model = ElevenLabs.configure({ apiKey: process.env.ELEVENLABS_API_KEY ?? "fixture" }).transcription("scribe_v2")
const recorded = recordedTests({
prefix: "elevenlabs-transcription",
provider: "elevenlabs",
protocol: "elevenlabs-transcription",
requires: ["ELEVENLABS_API_KEY"],
options: audioRecording,
})
describe("ElevenLabs Transcription recorded", () => {
recorded.effect("transcribes audio with word timestamps", () =>
Effect.gen(function* () {
const request = Transcription.request({ model, audio: yield* audio, timestamps: "word" })
const response = yield* Transcription.generate(request)
expect(response.text).toMatch(TRANSCRIPT)
expect(response.words?.map((word) => word.text)).toEqual(["Hello", "from", "OpenCode"])
expect(response.words?.every((word) => word.speaker === undefined && (word.confidence ?? 0) > 0)).toBe(true)
expect(response.segments).toBeUndefined()
expect(response.language).toBe("eng")
expect(response.durationSeconds).toBeGreaterThan(0)
expect(response.usage).toEqual({ type: "seconds", seconds: response.durationSeconds })
expect(response.providerMetadata?.elevenlabs?.transcriptionId).toEqual(expect.any(String))
const events = Array.from(yield* Stream.runCollect(Transcription.stream(request)))
expect(events.map((event) => event.type)).toEqual(["finish"])
}),
)
recorded.effect("groups diarized words into speaker turns", () =>
Effect.gen(function* () {
const response = yield* Transcription.generate({ model, audio: yield* dialog, diarize: true })
expect(response.segments?.map((segment) => segment.speaker)).toEqual(["speaker_0", "speaker_1"])
expect(response.segments?.[0].text).toMatch(/^Did the release ship\?$/)
expect(response.segments?.[1].text).toMatch(/^Yes, it shipped this morning\.?$/)
expect(response.segments?.map((segment) => segment.text).join(" ")).toBe(response.text)
expect(response.words?.some((word) => word.text.trim() === "")).toBe(false)
expect(new Set(response.words?.map((word) => word.speaker))).toEqual(new Set(["speaker_0", "speaker_1"]))
}),
)
})
@@ -92,6 +92,5 @@ const assertEvaluation = <Options extends EvaluationOptions>(
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
expect(response.usage?.inputTokens).toBeGreaterThan(0)
expect(response.usage?.outputTokens).toBeGreaterThan(0)
expect(response.answers.department.confidence).toBeGreaterThan(0)
expect(response.answers.urgency.confidence).toBeGreaterThan(0)
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
})
@@ -1,68 +0,0 @@
import { describe, expect } from "bun:test"
import { Effect } from "effect"
import { LLM, LLMEvent, LLMRequest, Message, ToolRuntime, toDefinitions } from "../../src/index.js"
import * as OpenAICompatible from "../../src/providers/openai-compatible.js"
import { LLMClient } from "../../src/route.js"
import { compileRequest } from "../../src/route/client.js"
import { recordedTests } from "../recorded-test.js"
import { weatherRuntimeTool, weatherToolName } from "../recorded-scenarios.js"
const model = OpenAICompatible.configure({
provider: "google",
baseURL: "https://generativelanguage.googleapis.com/v1beta/openai",
apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY ?? "fixture",
}).model("gemini-3.8-flash")
const recorded = recordedTests({
prefix: "openai-compatible-chat",
provider: "google",
protocol: "openai-chat",
requires: ["GOOGLE_GENERATIVE_AI_API_KEY"],
tags: ["tool", "tool-loop", "continuation"],
metadata: { model: model.id },
})
describe("Gemini OpenAI-compatible Chat recorded", () => {
recorded.effect.with(
"replays thought signatures through a parallel tool loop",
{ cassette: "openai-compatible-chat/gemini-parallel-tool-signatures" },
() =>
Effect.gen(function* () {
const tools = { [weatherToolName]: weatherRuntimeTool }
const request = LLM.request({
model,
system: "Call get_weather for every requested city in parallel, then answer in one short sentence.",
prompt: "What is the weather in Paris and in Tokyo?",
tools: toDefinitions(tools),
cache: "none",
})
const first = yield* LLMClient.generate(request)
const calls = first.events.filter(LLMEvent.is.toolCall)
expect(calls.map((call) => call.input)).toEqual([{ city: "Paris" }, { city: "Tokyo" }])
const extraContent = calls[0]?.providerMetadata?.google?.extraContent
expect(extraContent).toEqual({ google: { thought_signature: expect.any(String) } })
const results = yield* Effect.forEach(calls, (call) => ToolRuntime.dispatch(tools, call))
const continuation = LLMRequest.update(request, {
messages: [
...request.messages,
first.message,
...calls.map((call, index) =>
Message.tool({ id: call.id, name: call.name, result: results[index]!.result }),
),
],
})
const prepared = yield* compileRequest(continuation)
const assistant = prepared.body.messages.find((message) => message.role === "assistant")
expect(assistant?.role === "assistant" ? assistant.tool_calls?.[0]?.extra_content : undefined).toEqual(
extraContent,
)
const second = yield* LLMClient.generate(continuation)
expect(second.events.filter(LLMEvent.is.toolCall)).toHaveLength(0)
expect(second.text).toMatch(/Paris/)
expect(second.text).toMatch(/Tokyo/)
}),
60_000,
)
})
@@ -472,45 +472,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("replays Gemini thought signatures as tool call extra content", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Weather in Paris and Tokyo?"),
Message.assistant([
ToolCallPart.make({
id: "call_1",
name: "lookup",
input: { city: "Paris" },
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
}),
ToolCallPart.make({ id: "call_2", name: "lookup", input: { city: "Tokyo" } }),
]),
Message.tool({ id: "call_1", name: "lookup", result: "Sunny" }),
Message.tool({ id: "call_2", name: "lookup", result: "Rainy" }),
],
}),
)
const assistant = prepared.body.messages[1]
expect(assistant?.role === "assistant" ? assistant.tool_calls : undefined).toEqual([
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: encodeJson({ city: "Paris" }) },
extra_content: { google: { thought_signature: "sig_1" } },
},
{
id: "call_2",
type: "function",
function: { name: "lookup", arguments: encodeJson({ city: "Tokyo" }) },
},
])
}),
)
it.effect("limits OpenAI and Azure Chat tool call IDs to 40 characters", () =>
Effect.gen(function* () {
const id = `call_${"a".repeat(48)}`
@@ -1844,78 +1805,6 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves Gemini thought signatures on streamed parallel tool calls", () =>
Effect.gen(function* () {
// Gemini's OpenAI-compatible endpoint omits `index`, streams each call whole,
// and signs only the first call of a parallel batch.
const body = sseEvents(
deltaChunk({
role: "assistant",
tool_calls: [
{
extra_content: { google: { thought_signature: "sig_1" } },
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"city":"Paris"}' },
},
],
}),
deltaChunk({
role: "assistant",
tool_calls: [{ id: "call_2", type: "function", function: { name: "lookup", arguments: '{"city":"Tokyo"}' } }],
}),
deltaChunk({}, "stop"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter(LLMEvent.is.toolCall)).toEqual([
{
type: "tool-call",
id: "call_1",
name: "lookup",
input: { city: "Paris" },
providerExecuted: undefined,
providerMetadata: { openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
},
{
type: "tool-call",
id: "call_2",
name: "lookup",
input: { city: "Tokyo" },
providerExecuted: undefined,
providerMetadata: undefined,
},
])
}),
)
it.effect("keeps extra content that arrives before the tool identity", () =>
Effect.gen(function* () {
const body = sseEvents(
deltaChunk({
tool_calls: [
{ index: 0, extra_content: { google: { thought_signature: "sig_1" } }, function: { arguments: "{" } },
],
}),
deltaChunk({ tool_calls: [{ index: 0, id: "call_1", function: { name: "lookup", arguments: "}" } }] }),
deltaChunk({}, "tool_calls"),
)
const response = yield* LLMClient.generate(
LLMRequest.update(request, {
tools: [ToolDefinition.make({ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } })],
}),
).pipe(Effect.provide(fixedResponse(body)))
expect(response.events.filter(LLMEvent.is.toolCall).map((event) => event.providerMetadata)).toEqual([
{ openai: { extraContent: { google: { thought_signature: "sig_1" } } } },
])
}),
)
it.effect("does not finalize streamed tool calls when content is filtered", () =>
Effect.gen(function* () {
const body = sseEvents(
+2 -322
View File
@@ -2,11 +2,10 @@ import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { HttpClientRequest } from "effect/unstable/http"
import { Media, Transcription, TranscriptionClient, type TranscriptionEvent } from "../src/index.js"
import { AssemblyAI, Deepgram, ElevenLabs, Google, OpenAI } from "../src/providers.js"
import { Media, Transcription, TranscriptionClient } from "../src/index.js"
import { AssemblyAI, Deepgram, Google, OpenAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json, observe, type Call } from "./lib/http.js"
import { sseEvents } from "./lib/sse.js"
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
TranscriptionClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
@@ -17,27 +16,9 @@ const deepgram = Deepgram.configure({ apiKey: "test", baseURL: "https://deepgram
const google = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).transcription(
"gemini-3.5-transcribe",
)
/**
* Multipart fields of a recorded request, with repeated names collected in order. The boundary comes from the body:
* each conversion of a FormData request to a web request picks a fresh one, so the recorded headers may not match.
*/
const formFields = (call: Call) =>
Effect.promise(() =>
new Response(call.body, {
headers: { "content-type": `multipart/form-data; boundary=${call.body.slice(2, call.body.indexOf("\r\n"))}` },
}).formData(),
).pipe(
Effect.map((form) =>
Object.fromEntries([...new Set(form.keys())].map((key) => [key, form.getAll(key).map((value) => String(value))])),
),
)
const assemblyai = AssemblyAI.configure({ apiKey: "aai-key", baseURL: "https://assemblyai.test" }).transcription(
"universal-3-5-pro",
)
const elevenlabs = ElevenLabs.configure({ apiKey: "test", baseURL: "https://elevenlabs.test" }).transcription(
"scribe_v2",
)
describe("Transcription", () => {
it.effect("rejects what a route cannot honor before sending anything", () =>
@@ -148,198 +129,6 @@ describe("Transcription", () => {
}),
)
it.effect("streams diarized segments and finishes with the accumulated segments", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const body = sseEvents(
{ type: "transcript.text.segment", id: "seg_0", text: " Hello", start: 0.25, end: 0.7, speaker: "A" },
{ type: "transcript.text.segment", id: "seg_1", text: " there.", start: 0.7, end: 1.25, speaker: "B" },
{ type: "transcript.text.done", text: "Hello there.", usage: { type: "duration", seconds: 2 } },
)
const events = Array.from(
yield* Stream.runCollect(
Transcription.stream({ model: openai.transcription("gpt-4o-transcribe-diarize"), audio, diarize: true }),
).pipe(
Effect.provide(
layer((input) =>
observe(calls, input).pipe(
Effect.as(input.respond(body, { headers: { "content-type": "text/event-stream" } })),
),
),
),
),
)
const form = yield* formFields(calls[0])
expect(form).toMatchObject({
model: ["gpt-4o-transcribe-diarize"],
response_format: ["diarized_json"],
chunking_strategy: ["auto"],
stream: ["true"],
})
const segments = [
{ text: "Hello", startSeconds: 0.25, endSeconds: 0.7, speaker: "A" },
{ text: "there.", startSeconds: 0.7, endSeconds: 1.25, speaker: "B" },
]
expect(events).toEqual([
{ type: "segment", segment: segments[0] },
{ type: "segment", segment: segments[1] },
expect.objectContaining({
type: "finish",
text: "Hello there.",
segments,
usage: { type: "seconds", seconds: 2 },
}),
])
}),
)
it.effect("requests whisper-1 segment timestamps as verbose_json", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const response = yield* Transcription.generate({
model: openai.transcription("whisper-1"),
audio,
timestamps: "segment",
}).pipe(
Effect.provide(
layer((input) =>
observe(calls, input).pipe(
Effect.as(
json(input, {
text: "Hello there.",
language: "English",
duration: 1.25,
segments: [
{ id: 0, text: " Hello", start: 0.25, end: 0.7 },
{ id: 1, text: " there.", start: 0.7, end: 1.25 },
],
usage: { type: "duration", seconds: 2 },
}),
),
),
),
),
)
const form = yield* formFields(calls[0])
expect(form).toMatchObject({
model: ["whisper-1"],
response_format: ["verbose_json"],
"timestamp_granularities[]": ["segment"],
})
expect(form.stream).toBeUndefined()
expect(response).toMatchObject({
text: "Hello there.",
segments: [
{ text: "Hello", startSeconds: 0.25, endSeconds: 0.7 },
{ text: "there.", startSeconds: 0.7, endSeconds: 1.25 },
],
language: "english",
durationSeconds: 1.25,
usage: { type: "seconds", seconds: 2 },
})
}),
)
it.effect("fails an OpenAI stream that ends without transcript.text.done as incomplete", () =>
Effect.gen(function* () {
const events: Array<TranscriptionEvent> = []
const error = yield* Transcription.stream({ model: openai.transcription("gpt-4o-mini-transcribe"), audio }).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
Effect.provide(
layer((input) =>
Effect.succeed(
input.respond(sseEvents({ type: "transcript.text.delta", delta: "Hel" }), {
headers: { "content-type": "text/event-stream" },
}),
),
),
),
)
expect(events).toEqual([{ type: "text-delta", delta: "Hel" }])
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", classification: "incomplete-stream" })
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("sends a Deepgram URL source as a JSON body and repeats array query parameters", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const response = yield* Transcription.generate({
model: deepgram,
audio: Media.url("https://a.test/call.mp3", { mediaType: "audio/mpeg" }),
language: "en",
providerOptions: { keyterm: ["OpenCode", "Effect"] },
}).pipe(
Effect.provide(
layer((input) =>
observe(calls, input).pipe(
Effect.as(
json(input, {
metadata: { request_id: "dg_1", duration: 2 },
results: { channels: [{ alternatives: [{ transcript: "Hello there." }] }] },
}),
),
),
),
),
)
expect(calls).toHaveLength(1)
const url = new URL(calls[0].url)
expect(url.origin + url.pathname).toBe("https://deepgram.test/v1/listen")
expect([...url.searchParams]).toEqual([
["model", "nova-3"],
["smart_format", "true"],
["language", "en"],
["keyterm", "OpenCode"],
["keyterm", "Effect"],
])
expect(calls[0].headers.get("content-type")).toBe("application/json")
expect(JSON.parse(calls[0].body)).toEqual({ url: "https://a.test/call.mp3" })
expect(response).toMatchObject({
text: "Hello there.",
usage: { type: "seconds", seconds: 2 },
providerMetadata: { deepgram: { requestId: "dg_1" } },
})
}),
)
it.effect("transcribes an AssemblyAI URL source without uploading it first", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const response = yield* Transcription.generate({
model: assemblyai,
audio: Media.url("https://a.test/call.mp3", { mediaType: "audio/mpeg" }),
}).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "tr_1", status: "queued" })
return json(input, { id: "tr_1", status: "completed", text: "Hello there.", audio_duration: 2 })
}),
),
),
)
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
"POST https://assemblyai.test/v2/transcript",
"GET https://assemblyai.test/v2/transcript/tr_1",
"GET https://assemblyai.test/v2/transcript/tr_1",
])
expect(JSON.parse(calls[0].body)).toEqual({
audio_url: "https://a.test/call.mp3",
speech_models: ["universal-3-5-pro"],
language_detection: true,
})
expect(response).toMatchObject({ text: "Hello there.", usage: { type: "seconds", seconds: 2 } })
}),
)
it.effect(
"uploads inline audio to AssemblyAI, resumes polling from a persisted token, and surfaces failed transcripts",
() =>
@@ -462,115 +251,6 @@ describe("Transcription", () => {
}),
)
it.effect("rejects ElevenLabs prompts, webhooks, per-channel transcripts, and untimed diarization", () =>
Effect.gen(function* () {
const errors = yield* Effect.all(
[
Transcription.generate({ model: elevenlabs, audio, prompt: "OpenCode" }),
Transcription.generate({ model: elevenlabs, audio, providerOptions: { webhook: true } }),
Transcription.generate({ model: elevenlabs, audio, http: { body: { use_multi_channel: true } } }),
Transcription.generate({
model: elevenlabs,
audio,
diarize: true,
providerOptions: { timestamps_granularity: "none" },
}),
Transcription.generate({
model: elevenlabs,
audio: Media.ref("file_1", { provider: "elevenlabs", mediaType: "audio/mpeg" }),
}),
].map((effect) => Effect.flip(effect)),
)
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
[
["UnsupportedOperation", "media.prompt"],
["UnsupportedOperation", "transcription.webhook"],
["UnsupportedOperation", "transcription.multichannel"],
["UnsupportedOperation", "media.timestamps"],
["InvalidRequest", false],
],
)
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
)
it.effect("sends ElevenLabs URL audio as source_url and groups diarized words into speaker turns", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const token = (text: string, type: string, start: number, end: number, speaker_id?: string) => ({
text,
type,
start,
end,
speaker_id,
logprob: 0,
})
const response = yield* Transcription.generate({
model: elevenlabs,
audio: Media.url("https://a.test/call.mp3"),
language: "en",
speakers: 2,
providerOptions: { keyterms: ["OpenCode", "Scribe"], tag_audio_events: true, diarize: false },
}).pipe(
Effect.provide(
layer((input) =>
observe(calls, input).pipe(
Effect.as(
json(input, {
language_code: "ENG",
text: "Ready? (laughs) Yes. Go",
words: [
token("Ready?", "word", 0, 0.5, "speaker_0"),
token(" ", "spacing", 0.5, 0.6, "speaker_0"),
token("(laughs)", "audio_event", 0.6, 1, "speaker_0"),
token(" ", "spacing", 1, 1.1, "speaker_0"),
token("Yes.", "word", 1.2, 1.5, "speaker_1"),
token(" ", "spacing", 1.5, 1.6, "speaker_1"),
token("Go", "word", 1.6, 1.9, "speaker_0"),
],
transcription_id: "tr_1",
audio_duration_secs: 2,
}),
),
),
),
),
)
// `observe` re-encodes the FormData with a new boundary, so read the boundary from the sent body.
const boundary = /^--(\S+)/.exec(calls[0].body)?.[1]
const form = yield* Effect.promise(() =>
new Response(calls[0].body, {
headers: { "content-type": `multipart/form-data; boundary=${boundary}` },
}).formData(),
)
expect(calls[0].url).toBe("https://elevenlabs.test/v1/speech-to-text")
expect(calls[0].headers.get("xi-api-key")).toBe("test")
expect(Array.from(form.entries())).toEqual([
["model_id", "scribe_v2"],
["source_url", "https://a.test/call.mp3"],
["language_code", "en"],
["diarize", "true"],
["num_speakers", "2"],
["keyterms", "OpenCode"],
["keyterms", "Scribe"],
["tag_audio_events", "true"],
])
expect(response.segments).toEqual([
{ text: "Ready?", startSeconds: 0, endSeconds: 0.5, speaker: "speaker_0" },
{ text: "Yes.", startSeconds: 1.2, endSeconds: 1.5, speaker: "speaker_1" },
{ text: "Go", startSeconds: 1.6, endSeconds: 1.9, speaker: "speaker_0" },
])
expect(response.words?.map((word) => [word.text, word.speaker, word.confidence])).toEqual([
["Ready?", "speaker_0", 1],
["Yes.", "speaker_1", 1],
["Go", "speaker_0", 1],
])
expect(response.language).toBe("eng")
expect(response.usage).toEqual({ type: "seconds", seconds: 2 })
expect(response.providerMetadata).toEqual({ elevenlabs: { transcriptionId: "tr_1" } })
}),
)
it.effect("rejects reading an AssemblyAI result before the transcript finishes", () =>
Effect.gen(function* () {
const generation = yield* Transcription.resume(assemblyai, { transcriptID: "tr_1" })
+26 -421
View File
@@ -1,10 +1,9 @@
import { describe, expect } from "bun:test"
import { Effect, Fiber, Layer, Stream } from "effect"
import * as TestClock from "effect/testing/TestClock"
import { Media, Video, VideoClient, type GenerationEvent, type VideoEvent } from "../src/index.js"
import { Effect, Layer, Stream } from "effect"
import { Media, Video, VideoClient, type GenerationEvent } from "../src/index.js"
import { Fal, Google, Runway, XAI } from "../src/providers.js"
import { it } from "./lib/effect.js"
import { dynamicResponse, json, observe, settle, type Call, type HandlerInput } from "./lib/http.js"
import { dynamicResponse, json, observe, settle, type Call } from "./lib/http.js"
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
VideoClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
@@ -163,39 +162,27 @@ describe("Video / Google Veo", () => {
),
)
for (const terminal of [
{ error: { code: 3, message: "Prompt violates policy", status: "INVALID_ARGUMENT" }, tag: "InvalidRequest" },
{ error: { code: 9, message: "Unsupported resolution", status: "FAILED_PRECONDITION" }, tag: "InvalidRequest" },
{ error: { code: 11, message: "Duration out of range", status: "OUT_OF_RANGE" }, tag: "InvalidRequest" },
{ error: { code: 7, message: "Permission denied", status: "PERMISSION_DENIED" }, tag: "Authentication" },
{ error: { code: 16, message: "Invalid credentials", status: "UNAUTHENTICATED" }, tag: "Authentication" },
{ error: { code: 8, message: "Quota exceeded", status: "RESOURCE_EXHAUSTED" }, tag: "RateLimit" },
{ error: { code: 13, message: "Internal error", status: "INTERNAL" }, tag: "ProviderInternal" },
{ error: { code: 14, message: "Service unavailable", status: "UNAVAILABLE" }, tag: "ProviderInternal" },
{ error: { message: "Something broke" }, tag: "ProviderInternal" },
]) {
it.effect(
`surfaces ${terminal.error.status ?? "an uncoded"} operation error as ${terminal.tag} with the provider body`,
() =>
Effect.gen(function* () {
const failure = { name: operation, done: true, error: terminal.error }
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
Effect.flip,
Effect.provide(
layer((input) =>
Effect.succeed(
input.request.method === "POST" ? json(input, { name: operation }) : json(input, failure),
),
),
),
)
expect(error.reason._tag).toBe(terminal.tag)
expect(error.message).toBe(`Google Veo operation failed: ${terminal.error.message}`)
expect(error.reason.body).toBe(JSON.stringify(failure))
expect(error.reason.http?.status).toBe(200)
}),
)
}
it.effect("surfaces an operation error as a failed generation with the provider body", () =>
Effect.gen(function* () {
const failure = {
name: operation,
done: true,
error: { code: 3, message: "Prompt violates policy", status: "INVALID_ARGUMENT" },
}
const error = yield* Video.generate({ model, prompt: "nope" }).pipe(
Effect.flip,
Effect.provide(
layer((input) =>
Effect.succeed(input.request.method === "POST" ? json(input, { name: operation }) : json(input, failure)),
),
),
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("Google Veo operation failed: Prompt violates policy")
expect(error.reason.body).toBe(JSON.stringify(failure))
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("reports fully filtered output as a content policy failure", () =>
Effect.gen(function* () {
@@ -345,37 +332,12 @@ describe("Video / xAI", () => {
for (const terminal of [
{
body: { status: "failed", error: { code: "invalid_argument", message: "Prompt cannot be empty." } },
tag: "InvalidRequest",
tag: "ProviderInternal",
message: "xAI Video generation failed (invalid_argument): Prompt cannot be empty.",
},
{
body: { status: "failed", error: { code: "failed_precondition", message: "Extension is not supported." } },
tag: "InvalidRequest",
message: "xAI Video generation failed (failed_precondition): Extension is not supported.",
},
{
body: { status: "failed", error: { code: "permission_denied", message: "Team lacks access." } },
tag: "Authentication",
message: "xAI Video generation failed (permission_denied): Team lacks access.",
},
{
body: { status: "failed", error: { code: "service_unavailable", message: "Overloaded." } },
tag: "ProviderInternal",
message: "xAI Video generation failed (service_unavailable): Overloaded.",
},
{
body: { status: "failed", error: { code: "internal_error", message: "Generation failed." } },
tag: "ProviderInternal",
message: "xAI Video generation failed (internal_error): Generation failed.",
},
{
body: { status: "failed", error: { code: "constructor", message: "Future code." } },
tag: "ProviderInternal",
message: "xAI Video generation failed (constructor): Future code.",
},
{ body: { status: "expired" }, tag: "InvalidRequest", message: "xAI Video request req_1 expired" },
]) {
it.effect(`surfaces ${terminal.body.error?.code ?? terminal.body.status} generations with the provider body`, () =>
it.effect(`surfaces ${terminal.body.status} generations with the provider body`, () =>
Effect.gen(function* () {
const error = yield* Video.generate({ model, prompt: "x" }).pipe(Effect.flip)
expect(error.reason._tag).toBe(terminal.tag)
@@ -580,45 +542,6 @@ describe("Video / fal", () => {
),
)
for (const failure of [
{
name: "a COMPLETED status carrying an error",
status: { status: "COMPLETED", error: "Invalid input", error_type: "ValidationError" },
result: { status: 422, body: { detail: [{ loc: ["body", "prompt"], msg: "Invalid input" }] } },
tag: "InvalidRequest",
},
{
name: "a failing response_url",
status: { status: "COMPLETED" },
result: { status: 500, body: { detail: "Internal error" } },
tag: "ProviderInternal",
},
]) {
it.effect(`fails await for ${failure.name} with the response_url body and HTTP context`, () =>
Effect.gen(function* () {
// A transient 500 on the result fetch is retried first; the body and HTTP context survive the final failure.
const fiber = yield* Effect.forkChild(Video.generate({ model, prompt: "x" }).pipe(Effect.flip))
yield* TestClock.adjust("5 minutes")
const error = yield* Fiber.join(fiber)
expect(error.reason._tag).toBe(failure.tag)
expect(error.reason.body).toBe(JSON.stringify(failure.result.body))
expect(error.reason.http).toMatchObject({ url: urls.response, status: failure.result.status })
}).pipe(
Effect.provide(
layer((input) =>
Effect.succeed(
input.request.method === "POST"
? json(input, submitted)
: input.request.url === urls.response
? json(input, failure.result.body, { status: failure.result.status })
: json(input, failure.status),
),
),
),
),
)
}
it.effect("rejects model-specific common fields and points at providerOptions", () =>
Effect.gen(function* () {
const errors = yield* Effect.forEach(
@@ -806,11 +729,6 @@ describe("Video / Runway", () => {
tag: "ProviderInternal",
message: "Runway task failed (INTERNAL.BAD_OUTPUT.CODE01): Something broke",
},
{
body: { status: "FAILED", failure: "Unsupported dimensions", failureCode: "ASSET.INVALID" },
tag: "InvalidRequest",
message: "Runway task failed (ASSET.INVALID): Unsupported dimensions",
},
{ body: { status: "CANCELLED" }, tag: "InvalidRequest", message: "Runway task task_1 was cancelled" },
]) {
it.effect(`surfaces ${terminal.body.failureCode ?? terminal.body.status} with the task body`, () =>
@@ -900,39 +818,6 @@ describe("Video / Runway", () => {
}),
)
it.effect("streams the observations of a failed task and then fails with the task body", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const events: Array<VideoEvent> = []
const failed = { status: "FAILED", failure: "Something broke", failureCode: "INTERNAL.BAD_OUTPUT.CODE01" }
const program = Video.stream({ model, prompt: "x" }, { poll: { interval: "1 second" } }).pipe(
Stream.runForEach((event) => Effect.sync(() => events.push(event))),
Effect.flip,
)
const error = yield* settle(program, 3).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
if (nth === 1) return json(input, { status: "PENDING" })
if (nth === 2) return json(input, { status: "RUNNING", progress: 0.5 })
return json(input, failed)
}),
),
),
)
expect(events).toEqual([
{ type: "generation-queued", id: "task_1", position: undefined },
{ type: "generation-progress", id: "task_1", progress: 0.5 },
])
expect(error.reason._tag).toBe("ProviderInternal")
expect(error.message).toBe("Runway task failed (INTERNAL.BAD_OUTPUT.CODE01): Something broke")
expect(error.reason.body).toBe(JSON.stringify(failed))
expect(error.reason.http?.status).toBe(200)
}),
)
it.effect("fails a stream with a Timeout reason once polling passes the poll deadline", () =>
Effect.gen(function* () {
const program = Video.stream(
@@ -954,169 +839,6 @@ describe("Video / Runway", () => {
)
})
// ---------------------------------------------------------------------------
// Transient read failures
// ---------------------------------------------------------------------------
describe("Video / transient read failures", () => {
const model = Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5")
const succeeded = { id: "task_1", status: "SUCCEEDED", output: ["https://runway.test/out.mp4"] }
const failure = (input: HandlerInput, status: number, headers?: Record<string, string>) =>
json(input, { error: `HTTP ${status}` }, { status, headers })
const methods = (calls: ReadonlyArray<Call>) => calls.map((call) => call.method)
it.effect("retries a 503 status poll and a 503 result read, then returns the result", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const response = yield* settle(
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }),
5,
).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
// 1: status fails, 2: status succeeds, 3: result fails, 4: result succeeds.
if (nth === 1 || nth === 3) return failure(input, 503)
return json(input, succeeded)
}),
),
),
)
expect(response.video.source).toMatchObject({ type: "url", url: "https://runway.test/out.mp4" })
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET", "GET"])
}),
)
it.effect("waits for a 429 retry-after before polling again", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const fiber = yield* Effect.forkChild(
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second" } }).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
if (nth === 1) return failure(input, 429, { "retry-after": "10" })
return json(input, succeeded)
}),
),
),
),
)
yield* TestClock.adjust("9 seconds")
expect(methods(calls)).toEqual(["POST", "GET"])
yield* TestClock.adjust("1 second")
yield* Fiber.join(fiber)
expect(methods(calls)).toEqual(["POST", "GET", "GET", "GET"])
}),
)
it.effect("fails a 400 status poll without retrying", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
Effect.flip,
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 400)
}),
),
),
)
expect(error.reason._tag).toBe("InvalidRequest")
expect(methods(calls)).toEqual(["POST", "GET"])
}),
)
it.effect("stops retrying at poll.timeout with a Timeout reason", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* settle(
Video.generate({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
Effect.flip,
),
6,
).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
return call.method === "POST" ? json(input, { id: "task_1" }) : failure(input, 503)
}),
),
),
)
expect(error.reason._tag).toBe("Timeout")
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(1)
}),
)
it.effect("bounds a streamed result read's retries by poll.timeout", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* settle(
Video.stream({ model, prompt: "x" }, { poll: { interval: "1 second", timeout: "5 seconds" } }).pipe(
Stream.runCollect,
Effect.flip,
),
6,
).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
return nth === 1 ? json(input, succeeded) : failure(input, 503)
}),
),
),
)
expect(error.reason._tag).toBe("Timeout")
expect(calls.filter((call) => call.method === "GET").length).toBeGreaterThan(2)
}),
)
it.effect("never retries a failed submit", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* Video.generate({ model, prompt: "x" }).pipe(
Effect.flip,
Effect.provide(layer((input) => observe(calls, input).pipe(Effect.map(() => failure(input, 503))))),
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(methods(calls)).toEqual(["POST"])
}),
)
it.effect("never retries a failed cancel", () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const error = yield* Effect.gen(function* () {
const generation = yield* Video.start({ model, prompt: "x" })
return yield* generation.cancel().pipe(Effect.flip)
}).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
if (call.method === "POST") return json(input, { id: "task_1" })
if (call.method === "DELETE") return failure(input, 503)
return json(input, { id: "task_1", status: "RUNNING" })
}),
),
),
)
expect(error.reason._tag).toBe("ProviderInternal")
expect(methods(calls)).toEqual(["POST", "GET", "DELETE"])
}),
)
})
// ---------------------------------------------------------------------------
// Shared queued behavior
// ---------------------------------------------------------------------------
@@ -1156,123 +878,6 @@ describe("Video / queued result", () => {
)
}
const veoOperation = "models/veo-3.1/operations/op_1"
const falURLs = {
status: "https://queue.fal.test/fal-ai/veo3.1/requests/r1/status",
response: "https://queue.fal.test/fal-ai/veo3.1/requests/r1",
cancel: "https://queue.fal.test/fal-ai/veo3.1/requests/r1/cancel",
}
for (const queued of [
{
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
submitted: { name: veoOperation },
token: { operation: veoOperation },
submitURL: "https://google.test/v1beta/models/veo-3.1:predictLongRunning",
statusURL: `https://google.test/v1beta/${veoOperation}`,
resultURL: `https://google.test/v1beta/${veoOperation}`,
running: { name: veoOperation, done: false },
done: {
name: veoOperation,
done: true,
response: { generateVideoResponse: { generatedSamples: [{ video: { uri: "https://google.test/out.mp4" } }] } },
},
result: undefined,
url: "https://google.test/out.mp4",
},
{
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
submitted: { request_id: "req_1" },
token: { requestID: "req_1" },
submitURL: "https://xai.test/v1/videos/generations",
statusURL: "https://xai.test/v1/videos/req_1",
resultURL: "https://xai.test/v1/videos/req_1",
running: { status: "pending", progress: 40 },
done: { status: "done", video: { url: "https://vidgen.x.ai/out.mp4", respect_moderation: true } },
result: undefined,
url: "https://vidgen.x.ai/out.mp4",
},
{
model: Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" }).video("fal-ai/veo3.1"),
submitted: {
request_id: "r1",
status_url: falURLs.status,
response_url: falURLs.response,
cancel_url: falURLs.cancel,
},
token: { requestID: "r1", statusURL: falURLs.status, responseURL: falURLs.response, cancelURL: falURLs.cancel },
submitURL: "https://queue.fal.test/fal-ai/veo3.1",
statusURL: falURLs.status,
resultURL: falURLs.response,
running: { status: "IN_PROGRESS" },
done: { status: "COMPLETED" },
result: { video: { url: "https://v3.fal.media/out.mp4" } },
url: "https://v3.fal.media/out.mp4",
},
]) {
it.effect(`resumes a ${queued.model.provider} generation from a JSON round-tripped token`, () =>
Effect.gen(function* () {
const calls: Array<Call> = []
const response = yield* Effect.gen(function* () {
const started = yield* Video.start({ model: queued.model, prompt: "x" })
const resumed = yield* Video.resume(queued.model, JSON.parse(JSON.stringify(started.token)))
expect(resumed.status).toBe("running")
expect(resumed.token).toEqual(queued.token)
return yield* resumed.await()
}).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call, nth } = yield* observe(calls, input)
if (call.method === "POST") return json(input, queued.submitted)
if (call.url === queued.resultURL && queued.result !== undefined) return json(input, queued.result)
return json(input, nth === 1 ? queued.running : queued.done)
}),
),
),
)
expect(response.video.source).toEqual(expect.objectContaining({ type: "url", url: queued.url }))
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
`POST ${queued.submitURL}`,
`GET ${queued.statusURL}`,
`GET ${queued.statusURL}`,
`GET ${queued.resultURL}`,
])
}),
)
}
for (const queued of [
{
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
submitted: { name: veoOperation },
},
{
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
submitted: { request_id: "req_1" },
},
]) {
it.effect(`cancels a ${queued.model.provider} generation without sending a request`, () =>
Effect.gen(function* () {
const calls: Array<Call> = []
yield* Effect.gen(function* () {
const generation = yield* Video.start({ model: queued.model, prompt: "x" })
yield* generation.cancel()
}).pipe(
Effect.provide(
layer((input) =>
Effect.gen(function* () {
const { call } = yield* observe(calls, input)
if (call.method !== "POST") return yield* Effect.die(`cancel sent ${call.method} ${call.url}`)
return json(input, queued.submitted)
}),
),
),
)
expect(calls.map((call) => call.method)).toEqual(["POST"])
}),
)
}
it.effect("rejects a status that only matches an inherited property", () =>
Effect.gen(function* () {
const error = yield* Video.resume(
@@ -39,6 +39,30 @@ test("restores review mode and selected file per session", async ({ page }) => {
await expectSelectedFile(page, "gamma.ts")
})
test("shows and restores last turn changes from the session diff", async ({ page }) => {
await setup(page)
await page.route(`**/api/session/${sessionA}/diff**`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ data: [diff("src/delta.ts")] }),
}),
)
await page.goto(sessionHref(sessionA))
await expectSessionTitle(page, titleA)
await page.getByRole("button", { name: "Toggle review" }).click()
await page.getByRole("button", { name: "Git changes" }).click()
await page.getByRole("option", { name: "Last turn changes" }).click()
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
await expectSelectedFile(page, "delta.ts")
await page.reload()
await expectSessionTitle(page, titleA)
await expect(page.getByRole("button", { name: "Last turn changes" })).toBeVisible()
await expectSelectedFile(page, "delta.ts")
})
for (const tab of ["Context", "Open file", "README.md"]) {
test(`restores the selected ${tab} pane tab after switching sessions and reloading`, async ({ page }) => {
await setup(page)
+42 -26
View File
@@ -21,7 +21,6 @@ import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from ".
import type { DiffStyle } from "./review-tab"
export type ChangeMode = "git" | "branch" | "turn"
type VcsMode = "git" | "branch"
export function createSessionReview(input: {
session: SessionModel
@@ -64,15 +63,13 @@ export function createSessionReview(input: {
) {
list.push("branch")
}
// Turn diffs compare session snapshots, which the server only captures in Git projects.
if (project?.vcs === "git" && input.session.identity.sessionID()) list.push("turn")
return list
})
const mobileChanges = createMemo(
() => !input.session.isDesktop() && !input.screen.terminal.open() && state.mobileTab === "changes",
)
const vcsMode = createMemo<VcsMode | undefined>(() => {
const value = mode()
return value === "git" || value === "branch" ? value : undefined
})
const vcsKey = createMemo(
() =>
[
@@ -90,22 +87,28 @@ export function createSessionReview(input: {
(input.session.tabs.activeTab() === "review" || !!input.session.tabs.activeFileTab()))
: mobileChanges(),
)
const vcsQuery = createQuery(() => {
const value = vcsMode()
const turnKey = createMemo(() => [server.scope, "session-turn", input.session.identity.sessionID()] as const)
const diffQuery = createQuery(() => {
const value = mode()
const sessionID = input.session.identity.sessionID()
const turn = value === "turn"
return {
queryKey: [...vcsKey(), value] as const,
queryKey: turn ? turnKey() : ([...vcsKey(), value] as const),
enabled: server.connection.status() === "connected" && wantsReview() && !!input.session.project()?.vcs,
refetchOnMount: "always" as const,
refetchOnWindowFocus: true,
queryFn: value
? () =>
// A finished turn's diff is immutable and expensive, so only the idle transition refreshes it.
refetchOnWindowFocus: !turn,
queryFn: turn
? sessionID
? () => server.api.session.diff({ sessionID })
: skipToken
: () =>
server.api.vcs
.diff({
location: { directory: location().directory },
mode: value === "git" ? "working" : value,
})
.then((result) => result.data)
: skipToken,
.then((result) => result.data),
}
})
const detailsQuery = createQuery(() => ({
@@ -136,16 +139,13 @@ export function createSessionReview(input: {
on(
() => input.screen.review.open() || mobileChanges(),
(open, previous) => {
if (!open || previous || !input.screen.files.open() || vcsQuery.isFetching) return
if (!open || previous || !input.screen.files.open() || diffQuery.isFetching) return
refresh()
},
{ defer: true },
),
)
const diffs = () => {
if (mode() === "git" || mode() === "branch") return vcsQuery.isFetched ? (vcsQuery.data ?? []) : []
return []
}
const diffs = () => (diffQuery.isFetched ? (diffQuery.data ?? []) : [])
const activeFile = () => {
const list = diffs()
const selected = selectedFile()
@@ -155,17 +155,12 @@ export function createSessionReview(input: {
const count = () => diffs().length
const hasChanges = () => count() > 0
const ready = () => {
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
// A project without VCS never enables diffQuery, so its status stays "pending" forever.
const project = input.session.project()
if (project && !project.vcs) return true
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
return true
return !diffQuery.isPending
}
const loadDiff = async (path: string, version?: number): Promise<FileDiffInfo | undefined> => {
const value = vcsMode()
if (!value) return undefined
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
const directory = reviewDiffDirectory(root, path)
const source = diffs().find((diff) => diff.file === path)
const valid = (diff: FileDiffInfo | undefined): FileDiffInfo | undefined => {
if (!diff || !source) return undefined
@@ -173,6 +168,26 @@ export function createSessionReview(input: {
if (reviewDiffNeedsLoad(diff)) return undefined
return diff
}
const value = mode()
// Full-file patches past the server's output budget come back empty; bounded context usually fits.
if (value === "turn") {
const sessionID = input.session.identity.sessionID()
if (!sessionID) return undefined
return queryClient
.fetchQuery({
queryKey: [...turnKey(), "bounded", version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () => server.api.session.diff({ sessionID, context: 3 }),
})
.then((result) => valid(result.find((diff) => diff.file === path)))
.catch((error) => {
console.debug("[session-review] failed to load bounded turn diff", { path, error })
return undefined
})
}
const root = reviewRootDirectory(input.session.project()?.worktree ?? location().directory)
const directory = reviewDiffDirectory(root, path)
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
@@ -357,6 +372,7 @@ export function createSessionReview(input: {
(next, previous) => {
if (next !== "idle" || previous === undefined || previous === "idle") return
refresh()
void queryClient.invalidateQueries({ queryKey: turnKey() })
},
{ defer: true },
),
@@ -402,7 +418,7 @@ export function createSessionReview(input: {
open: () => state.detailsOpen,
setOpen: (open: boolean) => setState("detailsOpen", open),
},
diffVersion: () => vcsQuery.dataUpdatedAt,
diffVersion: () => diffQuery.dataUpdatedAt,
diffStyle: {
current: layout.review.diffStyle,
set: (style: DiffStyle) => layout.review.setDiffStyle(style),
+2 -4
View File
@@ -252,7 +252,6 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
const text = () => {
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
@@ -261,7 +260,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
}
return (
<Switch>
<Match when={loading()}>
<Match when={!props.review.ready()}>
<div class={props.loadingClass}>{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
@@ -285,11 +284,10 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
const language = useLanguage()
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
const noGit = () => props.review.noGit()
return (
<Switch>
<Match when={loading()}>
<Match when={!props.review.ready()}>
<div class="px-6 py-4 text-text-weak">{language.t("session.review.loadingChanges")}</div>
</Match>
<Match when={noGit()}>
+3 -3
View File
@@ -51,7 +51,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
prompt: Flag.string("prompt").pipe(Flag.withDescription("Prompt to use"), Flag.optional),
@@ -328,7 +328,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
@@ -368,7 +368,7 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
),
session: Flag.string("session").pipe(
Flag.withAlias("s"),
Flag.withDescription("Session ID to continue, or to create if it does not exist"),
Flag.withDescription("Session ID to continue"),
Flag.optional,
),
fork: Flag.boolean("fork").pipe(
+1 -15
View File
@@ -11,10 +11,6 @@ import { UpdatePreflight } from "../../services/update-preflight"
import { Npm } from "@opencode/util/npm"
import { OPENCODE_ARTIFACT, OPENCODE_CHANNEL, OPENCODE_VERSION } from "../../version"
import { Env } from "../../env"
import { Service } from "@opencode/client/effect/service"
import { OpenCode } from "@opencode/client/promise"
import { findSession } from "../../session-target"
import { errorMessage } from "../../util/error"
export default Runtime.handler(Commands, (input) =>
Effect.gen(function* () {
@@ -50,15 +46,6 @@ export default Runtime.handler(Commands, (input) =>
Effect.promise(() => preflight.fail("OpenCode update could not start the new background service")),
),
)
const session = Option.getOrUndefined(input.session)
// A missing --session ID becomes the ID of the session the first prompt creates.
const sessionExists =
session !== undefined &&
(yield* Effect.tryPromise({
try: () =>
findSession(OpenCode.make({ baseUrl: server.endpoint.url, headers: Service.headers(server.endpoint) }), session),
catch: (cause) => new Error(errorMessage(cause)),
})) !== undefined
const updater = yield* Updater.Service
let installing: string | undefined
const updateListeners = new Set<(version: string) => void>()
@@ -94,8 +81,7 @@ export default Runtime.handler(Commands, (input) =>
},
args: {
continue: input.continue,
sessionID: sessionExists ? session : undefined,
newSessionID: sessionExists ? undefined : session,
sessionID: Option.getOrUndefined(input.session),
prompt: Option.getOrUndefined(input.prompt),
auto: input.auto || input.yolo || input.dangerouslySkipPermissions,
},
+2 -6
View File
@@ -135,7 +135,6 @@ export async function runNonInteractivePrompt(input: Input) {
const replyPermission = async (request: { id: string; action: string; resources: ReadonlyArray<string> }) => {
if (!input.auto) {
permissionRejected = true
if (input.compatibility !== "v1") process.exitCode = 1
UI.println(
UI.Style.TEXT_WARNING_BOLD + "!",
UI.Style.TEXT_NORMAL +
@@ -494,8 +493,7 @@ export async function runNonInteractivePrompt(input: Input) {
if (event.type === "session.execution.interrupted") {
if (input.compatibility === "v1" && (permissionRejected || formCancelled)) return
if (event.data.reason === "user" && interrupted) process.exitCode = 130
// A declined tool call ends the step with an interruption; it was already reported above.
if (event.data.reason !== "user" && !emittedError && !permissionRejected && !formCancelled) {
if (event.data.reason !== "user" && !emittedError) {
emittedError = true
process.exitCode = 1
const error = { type: "aborted" as const, message: `Session interrupted: ${event.data.reason}` }
@@ -622,9 +620,7 @@ export async function runNonInteractivePrompt(input: Input) {
UI.error(item.state.error.message)
}
// A declined tool call ends its step with an interrupted-step error that is
// only a consequence of our own rejection; it was already reported above.
if (message.error && !emittedError && !permissionRejected && !formCancelled) {
if (message.error && !emittedError) {
emittedError = true
process.exitCode = 1
if (!emit("error", timestamp, { error: message.error })) UI.error(message.error.message)
+8 -13
View File
@@ -63,7 +63,6 @@ export async function resolveSessionTarget(input: {
(await input.client.session
.create(
{
id: input.session,
agent: prepared.agent,
model: prepared.model,
location: { directory: location.directory },
@@ -102,11 +101,14 @@ async function selectSession(input: {
fork?: boolean
signal?: AbortSignal
}) {
const explicit = input.session ? await findSession(input.client, input.session, input.signal) : undefined
if (input.session && !explicit) {
if (input.fork) throw new Error("Session not found")
return { session: undefined }
}
const explicit = input.session
? await input.client.session.get({ sessionID: input.session }, ...requestOptions(input.signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError")
return undefined
throw error
})
: undefined
if (input.session && !explicit) throw new Error("Session not found")
if (explicit)
return {
session: input.fork
@@ -131,13 +133,6 @@ async function selectSession(input: {
}
}
export function findSession(client: OpenCodeClient, sessionID: string, signal?: AbortSignal) {
return client.session.get({ sessionID }, ...requestOptions(signal)).catch((error) => {
if (error && typeof error === "object" && "_tag" in error && error._tag === "SessionNotFoundError") return undefined
throw error
})
}
async function latestSession(
client: OpenCodeClient,
location: LocationGetOutput,
-22
View File
@@ -42,28 +42,6 @@ describe("session target resolver", () => {
})
})
test("creates a missing explicit Session with its ID", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
const create = spyOn(client.session, "create").mockResolvedValue(session("ses_chosen", "/project"))
const target = await resolveSessionTarget({ client, session: "ses_chosen", prepare })
expect(create).toHaveBeenCalledWith(expect.objectContaining({ id: "ses_chosen" }))
expect(target).toMatchObject({ session: { id: "ses_chosen" }, resume: false })
})
test("does not create a missing explicit Session to fork", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.session, "get").mockRejectedValue({ _tag: "SessionNotFoundError" })
const create = spyOn(client.session, "create")
await expect(resolveSessionTarget({ client, session: "ses_chosen", fork: true, prepare })).rejects.toThrow(
"Session not found",
)
expect(create).not.toHaveBeenCalled()
})
test("paginates to continue the exact directory", async () => {
const client = OpenCode.make({ baseUrl: "https://opencode.test" })
spyOn(client.location, "get").mockResolvedValue(location("/project"))
+6
View File
@@ -66,6 +66,12 @@
"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",
"node": "./src/util/process-lock-ffi.node.ts",
"default": "./src/util/process-lock-ffi.bun.ts"
},
"#v1-migration": {
"types": "./src/database/v1-migration.bun.ts",
"bun": "./src/database/v1-migration.bun.ts",
+1
View File
@@ -26,6 +26,7 @@ const result = await Bun.build({
"#fff",
"#photon-wasm",
"#shell-parser-wasm",
"#process-lock-ffi",
"#v1-migration",
],
splitting: true,
+6
View File
@@ -30,6 +30,12 @@ export interface ExternalDirectoryAuthorization {
readonly save: string
}
export const externalDirectoryPermission = (input: ExternalDirectoryAuthorization) => ({
action: input.action,
resources: [input.resource],
save: [input.save],
})
export interface Target {
readonly absolute: AbsolutePath
/** Location-relative for internal paths, absolute for external paths. */
+6
View File
@@ -0,0 +1,6 @@
export * as File from "./file.js"
import { FileDiff } from "@opencode/schema/file-diff"
export const Diff = FileDiff.Info
export type Diff = typeof Diff.Type
+9 -19
View File
@@ -1,7 +1,6 @@
export * as Generate from "./generate.js"
import { LLM, LLMClient, AIError } from "@opencode/ai"
import { SessionID } from "@opencode/schema/session-id"
import { Context, Effect, Layer, Schema } from "effect"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import { llmClient } from "./effect/app-node-platform.js"
@@ -61,24 +60,15 @@ export const layer = Layer.effect(
? `Model unavailable: ${input.model.providerID}/${input.model.id}`
: "No model specified and no supported model is available",
})
const response = yield* llm
.generate(
LLM.request({
model: resolved.model,
prompt: input.prompt,
// Gateways require session attribution even for a stateless call; no Session is stored.
http: { headers: { "x-opencode-session": SessionID.create() } },
}),
)
.pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
const response = yield* llm.generate(LLM.request({ model: resolved.model, prompt: input.prompt })).pipe(
Effect.mapError(
(error: AIError) =>
new UnavailableError({
message: error.message,
service: resolved.ref.providerID,
}),
),
)
return response.text
})
+3 -3
View File
@@ -7,7 +7,7 @@ import { AbsolutePath, RelativePath } from "./schema.js"
import { FSUtil } from "@opencode/util/fs-util"
import { AppProcess } from "@opencode/util/process"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { FileDiff } from "@opencode/schema/file-diff"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
import { gitExecutable } from "./util/git-executable.js"
@@ -152,7 +152,7 @@ export interface Interface {
to: TreeID
context?: number
paths?: readonly RelativePath[]
}) => Effect.Effect<readonly FileDiff.Info[], OperationError>
}) => Effect.Effect<readonly File.Diff[], OperationError>
readonly restore: (input: {
repository: Repository
files: ReadonlyMap<RelativePath, TreeID>
@@ -571,7 +571,7 @@ const layer = Layer.effect(
additions: stat?.additions ?? 0,
deletions: stat?.deletions ?? 0,
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
} satisfies FileDiff.Info
} satisfies File.Diff
})
})
+1 -1
View File
@@ -152,7 +152,7 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer<Ser
const replacements: LayerNode.Replacements = [
...(options.discovery === false ? vanillaReplacements : []),
...(options.replacements ?? []),
Location.node.replace(Location.boundNode(ref)),
Location.node.replace(Location.boundNode(ref, { discovery: options.discovery })),
InstancePlugins.node.replace(InstancePlugins.bound(options.plugins ?? [])),
]
+3
View File
@@ -0,0 +1,3 @@
/** @deprecated Use FileAccess for path resolution and authorization. */
export { FileAccess as LocationMutation } from "./file-access.js"
export * from "./file-access.js"
+1
View File
@@ -8,6 +8,7 @@ import { LocationServiceMap } from "./location-service-map.js"
export { LocationServiceMap } from "./location-service-map.js"
export type LocationServices = Instance.Services
export type LocationError = Instance.Error
export function buildLocationServiceMap(
replacements: LayerNode.Replacements = [],
+4 -4
View File
@@ -16,12 +16,12 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/Lo
export const node = LayerNode.unbound(Service, tags.values.location)
const layer = (ref: Ref) =>
const layer = (ref: Ref, options?: { readonly discovery?: boolean }) =>
Layer.effect(
Service,
Effect.gen(function* () {
const project = yield* Project.Service
const resolved = yield* project.resolve(ref.directory)
const resolved = yield* project.resolve(ref.directory, options)
return Service.of({
directory: ref.directory,
workspaceID: ref.workspaceID,
@@ -31,9 +31,9 @@ const layer = (ref: Ref) =>
}),
)
export const boundNode = (ref: Ref) =>
export const boundNode = (ref: Ref, options?: { readonly discovery?: boolean }) =>
makeLocationNode({
service: Service,
layer: layer(ref),
layer: layer(ref, options),
deps: [Project.node],
})
+2
View File
@@ -45,6 +45,8 @@ export const ResourceTemplate = Mcp.ResourceTemplate
export type ResourceTemplate = Mcp.ResourceTemplate
export const ResourceCatalog = Mcp.ResourceCatalog
export type ResourceCatalog = Mcp.ResourceCatalog
export const ResourceContentPart = Mcp.ResourceContentPart
export type ResourceContentPart = Mcp.ResourceContentPart
export const ResourceContent = Mcp.ResourceContent
export type ResourceContent = Mcp.ResourceContent
File diff suppressed because one or more lines are too long
+5 -2
View File
@@ -65,7 +65,7 @@ export interface Interface {
/** Records Project activity for recency ordering, at most once per minute per Project. */
readonly activate: (projectID: ID) => Effect.Effect<void>
/** Resolves and persists the owning Project. */
readonly resolve: (input: AbsolutePath) => Effect.Effect<Resolved>
readonly resolve: (input: AbsolutePath, options?: { readonly discovery?: boolean }) => Effect.Effect<Resolved>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Project") {}
@@ -334,7 +334,10 @@ const layer = Layer.effect(
}
})
const resolve = Effect.fn("Project.resolve")(function* (input: AbsolutePath) {
const resolve = Effect.fn("Project.resolve")(function* (
input: AbsolutePath,
_options?: { readonly discovery?: boolean },
) {
const directory = AbsolutePath.make(yield* fs.resolve(input))
const native = yield* fs.up({ targets: [".git", ".hg"], start: directory, mode: "first" }).pipe(
Effect.map((matches) => matches[0]),
+18 -20
View File
@@ -6,6 +6,7 @@ import { Context, Effect, Layer, Schema, Types } from "effect"
import { Pty } from "@opencode/schema/pty"
import { Bus } from "./bus.js"
import { Location } from "./location.js"
import { PtyID } from "./pty/schema.js"
import { ShellSelect } from "./shell/select.js"
import { lazy } from "./util/lazy.js"
@@ -34,9 +35,6 @@ type Active = {
listeners: Disp[]
}
export const ID = Pty.ID
export type ID = Pty.ID
export const Info = Pty.Info
export type Info = Types.DeepMutable<typeof Info.Type>
@@ -71,21 +69,21 @@ export type Attachment = {
}
export class NotFoundError extends Schema.TaggedError<NotFoundError>()("Pty.NotFoundError", {
ptyID: ID,
ptyID: PtyID,
}) {}
export class ExitedError extends Schema.TaggedError<ExitedError>()("Pty.ExitedError", {
ptyID: ID,
ptyID: PtyID,
}) {}
export interface Interface {
readonly list: () => Effect.Effect<Info[]>
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
readonly get: (id: PtyID) => Effect.Effect<Info, NotFoundError>
readonly create: (input: CreateInput) => Effect.Effect<Info>
readonly update: (id: ID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: ID) => Effect.Effect<void, NotFoundError>
readonly write: (id: ID, data: string) => Effect.Effect<void, NotFoundError>
readonly attach: (id: ID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
readonly update: (id: PtyID, input: UpdateInput) => Effect.Effect<Info, NotFoundError>
readonly remove: (id: PtyID) => Effect.Effect<void, NotFoundError>
readonly write: (id: PtyID, data: string) => Effect.Effect<void, NotFoundError>
readonly attach: (id: PtyID, input: AttachInput) => Effect.Effect<Attachment, NotFoundError | ExitedError>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/Pty") {}
@@ -98,8 +96,8 @@ const layer = Layer.effect(
const shell = yield* ShellSelect.Service
const context = yield* Effect.context()
const runFork = Effect.runForkWith(context)
const sessions = new Map<ID, Active>()
const exitOrder: ID[] = []
const sessions = new Map<PtyID, Active>()
const exitOrder: PtyID[] = []
function notifyEnd(session: Active, event: { exitCode?: number }) {
for (const subscriber of session.subscribers.values()) {
@@ -133,13 +131,13 @@ const layer = Layer.effect(
}),
)
const requireSession = Effect.fn("Pty.requireSession")(function* (id: ID) {
const requireSession = Effect.fn("Pty.requireSession")(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return yield* new NotFoundError({ ptyID: id })
return session
})
const removeSession = Effect.fnUntraced(function* (id: ID) {
const removeSession = Effect.fnUntraced(function* (id: PtyID) {
const session = sessions.get(id)
if (!session) return
sessions.delete(id)
@@ -150,7 +148,7 @@ const layer = Layer.effect(
yield* bus.publish(Pty.Event.Deleted, { id: session.info.id })
})
const remove = Effect.fn("Pty.remove")(function* (id: ID) {
const remove = Effect.fn("Pty.remove")(function* (id: PtyID) {
yield* requireSession(id)
yield* removeSession(id)
})
@@ -159,12 +157,12 @@ const layer = Layer.effect(
return Array.from(sessions.values()).map((session) => session.info)
})
const get = Effect.fn("Pty.get")(function* (id: ID) {
const get = Effect.fn("Pty.get")(function* (id: PtyID) {
return (yield* requireSession(id)).info
})
const create = Effect.fn("Pty.create")(function* (input: CreateInput) {
const id = ID.ascending()
const id = PtyID.ascending()
const command = input.command || (yield* shell.resolve({ priority: "config" }))
const args = ShellSelect.login(command) ? [...(input.args ?? []), "-l"] : [...(input.args ?? [])]
const cwd = input.cwd || location.directory
@@ -244,7 +242,7 @@ const layer = Layer.effect(
return info
})
const update = Effect.fn("Pty.update")(function* (id: ID, input: UpdateInput) {
const update = Effect.fn("Pty.update")(function* (id: PtyID, input: UpdateInput) {
const session = yield* requireSession(id)
if (input.title) session.info.title = input.title
if (input.size && session.info.status === "running") session.process.resize(input.size.cols, input.size.rows)
@@ -252,12 +250,12 @@ const layer = Layer.effect(
return session.info
})
const write = Effect.fn("Pty.write")(function* (id: ID, data: string) {
const write = Effect.fn("Pty.write")(function* (id: PtyID, data: string) {
const session = yield* requireSession(id)
if (session.info.status === "running") session.process.write(data)
})
const attach = Effect.fn("Pty.attach")(function* (id: ID, input: AttachInput) {
const attach = Effect.fn("Pty.attach")(function* (id: PtyID, input: AttachInput) {
const session = yield* requireSession(id)
if (session.info.status !== "running") return yield* new ExitedError({ ptyID: id })
yield* Effect.logInfo("client attached to session", { id, directory: location.directory })
+1
View File
@@ -0,0 +1 @@
export { ID as PtyID } from "@opencode/schema/pty"
+2 -2
View File
@@ -2,7 +2,7 @@ export * as PtyTicket from "./ticket.js"
import type { Workspace } from "@opencode/schema/workspace"
import { PtyTicket } from "@opencode/schema/pty-ticket"
import type { Pty } from "@opencode/schema/pty"
import { PtyID } from "./schema.js"
import { Cache, Context, Duration, Effect, Layer } from "effect"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -12,7 +12,7 @@ const CAPACITY = 10_000
export const ConnectToken = PtyTicket.ConnectToken
export type Scope = {
readonly ptyID: Pty.ID
readonly ptyID: PtyID
readonly directory?: string
readonly workspaceID?: Workspace.ID
}
+1 -2
View File
@@ -145,9 +145,8 @@ function parts(input: string) {
.filter(Boolean)
}
// cachePath makes each `:`-separated host part a directory.
function safeHost(input: string) {
return Boolean(input) && !input.startsWith("-") && input.split(":").every(safeSegment)
return Boolean(input) && !input.startsWith("-") && !/[\s/\\]/.test(input)
}
function safeSegment(input: string) {
+9
View File
@@ -50,6 +50,15 @@ export class StepFailedError extends Schema.TaggedError<StepFailedError>()("Sess
}
}
export class UserInterruptedError extends Schema.TaggedError<UserInterruptedError>()(
"Session.UserInterruptedError",
{},
) {
override get message() {
return "Session interrupted by user"
}
}
export class PromptConflictError extends Schema.TaggedError<PromptConflictError>()("Session.PromptConflictError", {
sessionID: SessionSchema.ID,
messageID: SessionMessage.ID,
+4 -1
View File
@@ -12,6 +12,7 @@ import { SessionRunner } from "./runner/index.js"
import { SessionSchema } from "./schema.js"
import { SessionStore } from "./store.js"
import { toSessionError } from "./to-session-error.js"
import { UserInterruptedError } from "./error.js"
import { SessionInbox } from "./inbox.js"
export interface Interface {
@@ -50,7 +51,9 @@ type InterruptReason = "user" | "shutdown" | "inactivity"
export function terminal(exit: Exit.Exit<void, SessionRunner.RunError>, reason?: InterruptReason) {
if (Exit.isSuccess(exit)) return { type: "succeeded" as const }
if (Cause.hasInterrupts(exit.cause)) return { type: "interrupted" as const, reason: reason ?? "shutdown" }
return { type: "failed" as const, error: toSessionError(Cause.squash(exit.cause)) }
const failure = Cause.squash(exit.cause)
if (failure instanceof UserInterruptedError) return { type: "interrupted" as const, reason: "user" as const }
return { type: "failed" as const, error: toSessionError(failure) }
}
/** Process-local execution: drains run in this process using the selected instance. */
+3 -3
View File
@@ -273,12 +273,12 @@ export const layer = Layer.effect(
model: model.model,
http: {
headers: {
"x-session-affinity": affinity,
"X-Session-Id": affinity,
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
"User-Agent": App.useragent(app),
"x-opencode-project": session.projectID,
"x-opencode-session": affinity,
"x-opencode-session": session.id,
"x-opencode-client": app.name,
},
},
+2 -1
View File
@@ -4,7 +4,7 @@ import type { AIError } from "@opencode/ai"
import { Context, Data, Effect } from "effect"
import { SessionSchema } from "../schema.js"
import type { Promotable } from "../inbox.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError } from "../error.js"
import type { AgentNotFoundError, MessageDecodeError, StepFailedError, UserInterruptedError } from "../error.js"
import { SessionRunnerModel } from "./model.js"
import type { Instructions } from "../../instructions/index.js"
@@ -14,6 +14,7 @@ export type RunError =
| MessageDecodeError
| AgentNotFoundError
| StepFailedError
| UserInterruptedError
| Instructions.InitializationBlocked
export type Continuation = { readonly step: number }
+13
View File
@@ -29,6 +29,19 @@ export class ModelUnavailableError extends Schema.TaggedError<ModelUnavailableEr
return `Model unavailable: ${this.providerID}/${this.modelID}`
}
}
export const VariantUnavailableError = ModelResolver.VariantUnavailableError
export type VariantUnavailableError = ModelResolver.VariantUnavailableError
export const UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export type UnsupportedPackageError = ModelResolver.UnsupportedPackageError
export const ModelConfigurationError = ModelResolver.ModelConfigurationError
export type ModelConfigurationError = ModelResolver.ModelConfigurationError
export const ModelInitializationError = ModelResolver.ModelInitializationError
export type ModelInitializationError = ModelResolver.ModelInitializationError
export const UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export type UnresolvedProviderVariablesError = ModelResolver.UnresolvedProviderVariablesError
export const UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type UnsupportedCompactionError = ModelResolver.UnsupportedCompactionError
export type Error = ModelNotSelectedError | ModelUnavailableError | ModelResolver.Error
export type Resolved = ModelResolver.Resolved
+57 -3
View File
@@ -1,6 +1,6 @@
export * as SessionRunnerRetry from "./retry.js"
import { AIError, isRetryable } from "@opencode/ai"
import { AIError, isContextOverflowFailure } from "@opencode/ai"
import { Agent } from "@opencode/schema/agent"
import { Model } from "@opencode/schema/model"
import { SessionError } from "@opencode/schema/session-error"
@@ -10,8 +10,7 @@ import type { PluginHooks } from "../../plugin/hooks.js"
import { SessionEvent } from "../event.js"
import { SessionMessage } from "../message.js"
import { SessionSchema } from "../schema.js"
export { isRetryable }
import { toSessionError } from "../to-session-error.js"
interface Input {
readonly cause: AIError
@@ -28,6 +27,43 @@ export interface Decision {
readonly delay: number
}
export function isRetryable(error: AIError) {
const override = error.reason.http?.headers["x-should-retry"]
if (override === "true") return true
if (override === "false") return false
switch (error.reason._tag) {
case "RateLimit":
case "ProviderInternal":
return true
// A WebSocket acknowledgment marks delivery accepted before model output may exist.
// Read failures can still recover; the Step chooses retry versus continuation from durable output.
case "Transport":
return (
error.reason.delivery !== "rejected" &&
(error.reason.delivery !== "accepted" || error.reason.operation === "read")
)
case "InvalidProviderOutput":
return error.reason.classification === "incomplete-stream"
// Unrecognized failures retry: classification records affirmative
// deterministic evidence, and transient failures are exactly the ones
// that arrive in shapes no classifier anticipates.
case "UnknownProvider":
return true
case "Authentication":
case "QuotaExceeded":
case "ContentPolicy":
case "InvalidRequest":
case "UnsupportedOperation":
case "NoRoute":
case "Timeout":
return false
default: {
const exhaustive: never = error.reason
return exhaustive
}
}
}
/** Bound provider-requested delays so a hostile or buggy retry-after cannot stall a session for hours. */
const RETRY_AFTER_MAX = Duration.toMillis("15 minutes")
@@ -83,6 +119,24 @@ export const policy = (sessionID: SessionSchema.ID) =>
})
})
/**
* Retries one auxiliary request's transient failures under a shared `policy` allowance, letting the
* session retry hook adjust each decision. Context overflow is never transient: callers recover it.
*/
export const transient =
(decide: Effect.Success<ReturnType<typeof policy>>, input: Pick<Input, "agent" | "model" | "hook">) =>
<A, R>(effect: Effect.Effect<A, AIError, R>) =>
Effect.retry(effect, {
while: (cause) =>
Effect.gen(function* () {
if (isContextOverflowFailure(cause)) return false
const decision = yield* decide({ ...input, cause, error: toSessionError(cause), retry: isRetryable(cause) })
if (!decision.retry) return false
yield* Effect.sleep(decision.delay)
return true
}),
})
export const make = (bus: Bus.Interface, sessionID: SessionSchema.ID) =>
Effect.gen(function* () {
const decide = yield* policy(sessionID)
@@ -3,8 +3,7 @@ import { Tool } from "@opencode/schema/tool"
import { SessionError } from "@opencode/schema/session-error"
import { Permission } from "../permission.js"
import { Integration } from "../integration.js"
import { AgentNotFoundError, StepFailedError } from "./error.js"
import { ModelResolver } from "../model-resolver.js"
import { AgentNotFoundError, StepFailedError, UserInterruptedError } from "./error.js"
import { SessionRunnerModel } from "./runner/model.js"
export function toSessionError(cause: unknown): SessionError.Error {
@@ -49,17 +48,18 @@ export function toSessionError(cause: unknown): SessionError.Error {
return unwrapped.message === "" ? { ...unwrapped, type: "tool.execution", message: cause.message } : unwrapped
}
if (cause instanceof StepFailedError) return cause.error
if (cause instanceof ModelResolver.UnsupportedCompactionError)
if (cause instanceof SessionRunnerModel.UnsupportedCompactionError)
return { type: "provider.unsupported-operation", message: cause.message }
if (cause instanceof AgentNotFoundError) return { type: "unknown", message: cause.message }
if (cause instanceof UserInterruptedError) return { type: "aborted", message: cause.message }
if (
cause instanceof SessionRunnerModel.ModelNotSelectedError ||
cause instanceof SessionRunnerModel.ModelUnavailableError ||
cause instanceof ModelResolver.VariantUnavailableError ||
cause instanceof ModelResolver.UnsupportedPackageError ||
cause instanceof ModelResolver.ModelConfigurationError ||
cause instanceof ModelResolver.ModelInitializationError ||
cause instanceof ModelResolver.UnresolvedProviderVariablesError
cause instanceof SessionRunnerModel.VariantUnavailableError ||
cause instanceof SessionRunnerModel.UnsupportedPackageError ||
cause instanceof SessionRunnerModel.ModelConfigurationError ||
cause instanceof SessionRunnerModel.ModelInitializationError ||
cause instanceof SessionRunnerModel.UnresolvedProviderVariablesError
)
return { type: "provider.no-route", message: cause.message }
if (cause instanceof Integration.AuthorizationError) return { type: "provider.auth", message: cause.message }
+2 -2
View File
@@ -3,7 +3,7 @@ export * as Snapshot from "./snapshot.js"
import { makeLocationNode } from "@opencode/util/effect/app-node"
import path from "path"
import { Context, Effect, Fiber, Layer, Schema, Scope } from "effect"
import { FileDiff } from "@opencode/schema/file-diff"
import { File } from "./file.js"
import { FSUtil } from "@opencode/util/fs-util"
import { Git } from "./git.js"
import { Global } from "@opencode/util/global"
@@ -58,7 +58,7 @@ export interface Interface extends State.Transformable<Editor> {
* Generate structured per-file diffs between two captured trees. `context`
* controls unchanged lines around each unified diff hunk.
*/
readonly diff: (input: DiffInput) => Effect.Effect<readonly FileDiff.Info[], Error>
readonly diff: (input: DiffInput) => Effect.Effect<readonly File.Diff[], Error>
/**
* Restore selected project-relative paths from their associated trees. A path
+10
View File
@@ -0,0 +1,10 @@
export function findLast<T>(
items: readonly T[],
predicate: (item: T, index: number, items: readonly T[]) => boolean,
): T | undefined {
for (let i = items.length - 1; i >= 0; i -= 1) {
const item = items[i]
if (predicate(item, i, items)) return item
}
return undefined
}
@@ -0,0 +1,50 @@
import { dlopen, read, type Pointer } from "bun:ffi"
import { existsSync } from "node:fs"
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
export function lockDarwin(fd: number): LockResult {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { args: ["i32", "i32"], returns: "i32" },
__error: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__error())
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
export function lockLinux(fd: number): LockResult {
const musl = `/lib/libc.musl-${process.arch === "arm64" ? "aarch64" : "x86_64"}.so.1`
const library = dlopen(existsSync(musl) ? musl : "libc.so.6", {
flock: { args: ["i32", "i32"], returns: "i32" },
__errno_location: { args: [], returns: "ptr" },
})
try {
const result = library.symbols.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : errorCode(library.symbols.__errno_location())
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.close()
}
}
function errorCode(pointer: Pointer | bigint | null) {
if (pointer === null) throw new Error("Failed to read process lock error code")
return read.i32(pointer, 0)
}
@@ -0,0 +1,43 @@
import { dlopen, getInt32 } from "node:ffi"
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
const LOCK_EX = 2
const LOCK_NB = 4
const DARWIN_EWOULDBLOCK = 35
const LINUX_EWOULDBLOCK = 11
export function lockDarwin(fd: number): LockResult {
const library = dlopen("/usr/lib/libSystem.B.dylib", {
flock: { arguments: ["int32", "int32"], return: "int32" },
__error: { arguments: [], return: "pointer" },
})
try {
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : getInt32(library.functions.__error(), 0)
if (result === 0) return { acquired: true }
if (code === DARWIN_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.lib.close()
}
}
export function lockLinux(fd: number): LockResult {
const library = dlopen("libc.so.6", {
flock: { arguments: ["int32", "int32"], return: "int32" },
__errno_location: { arguments: [], return: "pointer" },
})
try {
const result = library.functions.flock(fd, LOCK_EX | LOCK_NB)
const code = result === 0 ? 0 : getInt32(library.functions.__errno_location(), 0)
if (result === 0) return { acquired: true }
if (code === LINUX_EWOULDBLOCK) return { acquired: false, held: true }
return { acquired: false, held: false, code }
} finally {
library.lib.close()
}
}
@@ -0,0 +1,14 @@
export type LockResult =
| { readonly acquired: true }
| { readonly acquired: false; readonly held: true }
| { readonly acquired: false; readonly held: false; readonly code: number }
// workerd has no FFI and no cross-process file locking; a Durable Object is
// already single-threaded per instance, so nothing on this runtime should
// reach these.
const unavailable = (_fd: number): LockResult => {
throw new Error("Process locks are unavailable on the workerd runtime")
}
export const lockDarwin = unavailable
export const lockLinux = unavailable
+131
View File
@@ -0,0 +1,131 @@
import { lockDarwin, lockLinux, type LockResult } from "#process-lock-ffi"
import { closeSync, mkdirSync, openSync } from "node:fs"
import { connect, createServer, type Server, type Socket } from "node:net"
import path from "node:path"
import { Effect, Schema } from "effect"
import { Hash } from "@opencode/util/hash"
export namespace ProcessLock {
export class HeldError extends Schema.TaggedError<HeldError>()("ProcessLockHeldError", {
file: Schema.String,
}) {
override get message() {
return `Process lock is already held: ${this.file}`
}
}
export class SystemError extends Schema.TaggedError<SystemError>()("ProcessLockSystemError", {
file: Schema.String,
operation: Schema.Literals(["open", "acquire"]),
code: Schema.String,
}) {
override get message() {
return `Process lock ${this.operation} failed for ${this.file}: ${this.code}`
}
}
export type LockError = HeldError | SystemError
const acquirePosix = Effect.fnUntraced(function* (file: string) {
const fd = yield* Effect.try({
try: () => {
mkdirSync(path.dirname(file), { recursive: true })
return openSync(file, "a+", 0o600)
},
catch: (cause) =>
new SystemError({
file,
operation: "open",
code: cause instanceof Error ? cause.message : String(cause),
}),
})
const result = yield* Effect.try({
try: () => lock(fd),
catch: (cause) =>
new SystemError({
file,
operation: "acquire",
code: cause instanceof Error ? cause.message : String(cause),
}),
}).pipe(
Effect.tapError(() =>
Effect.sync(() => {
closeSync(fd)
}),
),
)
if (result.acquired) return fd
closeSync(fd)
return yield* result.held
? new HeldError({ file })
: new SystemError({ file, operation: "acquire", code: String(result.code) })
})
export const acquire = Effect.fn("ProcessLock.acquire")(function* (file: string) {
if (process.platform === "win32") {
yield* Effect.acquireRelease(acquireWindows(file), closeWindows)
return
}
yield* Effect.acquireRelease(acquirePosix(file), (fd) =>
Effect.sync(() => {
closeSync(fd)
}),
)
})
}
function lock(fd: number): LockResult {
if (process.platform === "darwin") return lockDarwin(fd)
if (process.platform === "linux") return lockLinux(fd)
throw new Error(`Unsupported process lock platform: ${process.platform}`)
}
function acquireWindows(file: string) {
return Effect.callback<Server, ProcessLock.LockError>((resume) => {
const server = createServer()
let probe: Socket | undefined
const pipe = `\\\\.\\pipe\\opencode-process-lock-${Hash.sha256(path.resolve(file).toLowerCase())}`
const onError = (cause: NodeJS.ErrnoException) => {
server.off("listening", onListening)
probe = connect(pipe)
const onProbeError = () => {
probe?.off("connect", onConnect)
resume(
Effect.fail(
new ProcessLock.SystemError({
file,
operation: "acquire",
code: cause.code ?? cause.message,
}),
),
)
}
const onConnect = () => {
probe?.off("error", onProbeError)
probe?.destroy()
resume(Effect.fail(new ProcessLock.HeldError({ file })))
}
probe.once("connect", onConnect)
probe.once("error", onProbeError)
}
const onListening = () => {
server.off("error", onError)
resume(Effect.succeed(server))
}
server.once("error", onError)
server.once("listening", onListening)
server.on("connection", (socket) => socket.destroy())
server.listen(pipe)
return Effect.sync(() => {
probe?.destroy()
server.close()
})
})
}
function closeWindows(server: Server) {
return Effect.callback<void>((resume) => {
if (!server.listening) return resume(Effect.void)
server.close((error) => resume(error ? Effect.die(error) : Effect.void))
})
}
@@ -0,0 +1,17 @@
import { ProcessLock } from "@opencode/core/util/process-lock"
import { Effect, Schema } from "effect"
import fs from "node:fs/promises"
const input = Schema.decodeUnknownSync(
Schema.fromJsonString(Schema.Struct({ file: Schema.String, ready: Schema.String })),
)(process.argv[2])
await Effect.runPromise(
Effect.scoped(
Effect.gen(function* () {
yield* ProcessLock.acquire(input.file)
yield* Effect.promise(() => fs.writeFile(input.ready, String(process.pid)))
return yield* Effect.never
}),
),
)
+1 -56
View File
@@ -1,6 +1,5 @@
import { expect } from "bun:test"
import { LanguageModel, LLMClient } from "@opencode/ai"
import { RequestExecutor } from "@opencode/ai/route"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { TestLLM } from "@opencode/ai/testing"
import { AISDK } from "@opencode/core/aisdk"
@@ -11,7 +10,6 @@ import { ID, Info, Model, Ref } from "@opencode/core/model"
import { Provider } from "@opencode/core/provider"
import { Npm } from "@opencode/util/npm"
import { Effect, Layer } from "effect"
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
import { testEffect } from "./lib/effect"
const selected = Info.make({
@@ -91,56 +89,3 @@ resolverIt.effect("resolves dynamic models with their catalog metadata", () =>
})
}),
)
testEffect(Layer.empty).effect("attributes each stateless completion without creating a stored session", () =>
Effect.gen(function* () {
const sessions: string[] = []
const http = Layer.succeed(
HttpClient.HttpClient,
HttpClient.make((request) =>
Effect.sync(() => {
const session = request.headers["x-opencode-session"]
if (!session)
return HttpClientResponse.fromWeb(
request,
Response.json(
{
error: { type: "MissingSessionID", message: "Session ID is required" },
},
{ status: 400 },
),
)
sessions.push(session)
return HttpClientResponse.fromWeb(
request,
new Response(
`data: ${JSON.stringify({
id: "completion",
object: "chat.completion.chunk",
created: 1,
model: "gemini",
choices: [{ index: 0, delta: { content: "OK" }, finish_reason: "stop" }],
})}\n\ndata: [DONE]\n\n`,
{ headers: { "content-type": "text/event-stream" } },
),
)
}),
),
)
const native = LLMClient.layer.pipe(Layer.provide(RequestExecutor.layer.pipe(Layer.provide(http))))
yield* Effect.gen(function* () {
const generate = yield* Generate.Service
for (let index = 0; index < 2; index++) {
expect(
yield* generate.text({
prompt: "Return exactly OK",
model: Ref.make({ providerID: selected.providerID, id: selected.id }),
}),
).toBe("OK")
}
}).pipe(Effect.provide(Generate.layer.pipe(Layer.provide(Layer.merge(resolver, native)))))
expect(sessions).toHaveLength(2)
expect(sessions[0]).toStartWith("ses_")
expect(sessions[1]).not.toBe(sessions[0])
}),
)
+5 -4
View File
@@ -5,12 +5,13 @@ import { LayerNode } from "@opencode/util/effect/layer-node"
import { Bus } from "@opencode/core/bus"
import { Location } from "@opencode/core/location"
import { Pty } from "@opencode/core/pty"
import { PtyID } from "@opencode/core/pty/schema"
import { AbsolutePath } from "@opencode/core/schema"
import { ShellSelect } from "@opencode/core/shell/select"
import { location } from "../fixture/location"
import { testEffect } from "../lib/effect"
type PtyEvent = { type: "created" | "exited" | "deleted"; id: Pty.ID }
type PtyEvent = { type: "created" | "exited" | "deleted"; id: PtyID }
const locationLayer = Layer.succeed(
Location.Service,
@@ -45,7 +46,7 @@ const createPty = Effect.fn("PtySessionTest.createPty")(function* (command: stri
)
})
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: Pty.ID, count: number) =>
const waitForEvents = (events: Queue.Queue<PtyEvent>, id: PtyID, count: number) =>
Effect.gen(function* () {
const picked: Array<PtyEvent["type"]> = []
while (picked.length < count) {
@@ -60,7 +61,7 @@ const waitForEvents = (events: Queue.Queue<PtyEvent>, id: Pty.ID, count: number)
}),
)
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: Pty.ID, cursor?: number) {
const attachCollecting = Effect.fn("PtySessionTest.attachCollecting")(function* (id: PtyID, cursor?: number) {
const pty = yield* Pty.Service
const output = yield* Queue.unbounded<string>()
const ended = yield* Deferred.make<{ exitCode?: number }>()
@@ -89,7 +90,7 @@ describe("pty", () => {
it.live("returns typed not found errors for missing sessions", () =>
Effect.gen(function* () {
const pty = yield* Pty.Service
const id = Pty.ID.make("pty_missing")
const id = PtyID.make("pty_missing")
for (const result of [
yield* pty.get(id).pipe(Effect.asVoid, Effect.exit),
+5 -5
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Pty } from "@opencode/core/pty"
import { PtyID } from "@opencode/core/pty/schema"
import { PtyTicket } from "@opencode/core/pty/ticket"
import { Workspace } from "@opencode/core/workspace"
import { testEffect } from "../lib/effect"
@@ -17,7 +17,7 @@ describe("PTY websocket tickets", () => {
it.live("consumes tickets once", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const scope = { ptyID: Pty.ID.ascending(), directory: "/tmp/a" }
const scope = { ptyID: PtyID.ascending(), directory: "/tmp/a" }
const issued = yield* tickets.issue(scope)
expect(yield* tickets.consume({ ...scope, ticket: issued.ticket })).toBe(true)
@@ -28,7 +28,7 @@ describe("PTY websocket tickets", () => {
it.live("rejects tickets scoped to a different request", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = Pty.ID.ascending()
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID, directory: "/tmp/a" })
expect(yield* tickets.consume({ ptyID, directory: "/tmp/b", ticket: issued.ticket })).toBe(false)
@@ -39,7 +39,7 @@ describe("PTY websocket tickets", () => {
itExpiring.live("rejects tickets after the TTL elapses", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = Pty.ID.ascending()
const ptyID = PtyID.ascending()
const issued = yield* tickets.issue({ ptyID })
yield* Effect.promise(() => new Promise((resolve) => setTimeout(resolve, 25)))
@@ -51,7 +51,7 @@ describe("PTY websocket tickets", () => {
it.live("rejects tickets scoped to a different workspace", () =>
Effect.gen(function* () {
const tickets = yield* PtyTicket.Service
const ptyID = Pty.ID.ascending()
const ptyID = PtyID.ascending()
const workspaceID = Workspace.ID.ascending()
const issued = yield* tickets.issue({ ptyID, workspaceID })
-18
View File
@@ -41,12 +41,6 @@ describe("Repository", () => {
})
})
test("caches a host with a port under a directory per host part", () => {
expect(Repository.cachePath("/cache", Repository.parseRemote("ssh://git@example.com:2222/owner/repo"))).toBe(
path.join("/cache", "example.com", "2222", "owner", "repo"),
)
})
test("keeps local file repositories distinct from remote repositories", () => {
const localPath = path.resolve("repo.git")
const reference = Repository.parse(pathToFileURL(localPath).href)
@@ -68,18 +62,6 @@ describe("Repository", () => {
expect(() => Repository.validateBranch("bad branch")).toThrow(Repository.InvalidBranchError)
})
test.each([
"..:repo",
"git@..:repo",
"../owner/repo",
"ssh://../repo",
"ssh://..:22/repo",
"https://%2e%2e/owner/repo",
".:repo",
])("rejects %s because its host contains a relative path segment", (input) => {
expect(() => Repository.parseRemote(input)).toThrow(Repository.InvalidReferenceError)
})
test("compares cache identity independent of input spelling", () => {
const shorthand = Repository.parseRemote("owner/repo")
@@ -445,12 +445,12 @@ it.effect("manual compaction summarizes short context instead of no-op", () =>
expect(requests).toHaveLength(1)
expect(requests[0]?.promptCacheKey).toBe(parentID)
expect(requests[0]?.http?.headers).toEqual({
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-session-affinity": sessionID,
"X-Session-Id": sessionID,
"x-parent-session-id": parentID,
"User-Agent": App.useragent(App.make()),
"x-opencode-project": Project.ID.global,
"x-opencode-session": parentID,
"x-opencode-session": sessionID,
"x-opencode-client": "opencode",
})
expect(requests[0]?.generation).toEqual(GenerationOptions.make({ maxTokens: 20_000 }))
@@ -15,6 +15,7 @@ import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionExecution } from "@opencode/core/session/execution"
import { SessionRestart } from "@opencode/core/session/execution/restart"
import { UserInterruptedError } from "@opencode/core/session/error"
import { SessionEvent } from "@opencode/core/session/event"
import { SessionInbox } from "@opencode/core/session/inbox"
import { SessionMessage } from "@opencode/core/session/message"
@@ -49,6 +50,10 @@ describe("SessionExecution lifecycle", () => {
const interrupted = Effect.runSyncExit(Effect.interrupt)
expect(SessionExecution.terminal(interrupted)).toEqual({ type: "interrupted", reason: "shutdown" })
expect(SessionExecution.terminal(interrupted, "user")).toEqual({ type: "interrupted", reason: "user" })
expect(SessionExecution.terminal(Exit.fail(new UserInterruptedError()))).toEqual({
type: "interrupted",
reason: "user",
})
})
it.effect("the sweep only lists claimed top-level Sessions", () =>
+1 -6
View File
@@ -4693,12 +4693,7 @@ describe("SessionRunnerLLM", () => {
.pipe(Effect.orDie)
yield* s.runPrompt("Run child request")
expect(s.requests[0]?.http?.headers).toMatchObject({
"x-session-affinity": parentID,
"X-Session-Id": parentID,
"x-parent-session-id": parentID,
"x-opencode-session": parentID,
})
expect(s.requests[0]?.http?.headers?.["x-parent-session-id"]).toBe(parentID)
expect(s.requests[0]?.promptCacheKey).toBe(parentID)
})
@@ -0,0 +1,71 @@
import { expect } from "bun:test"
import { ProcessLock } from "@opencode/core/util/process-lock"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { it } from "../lib/effect"
const worker = path.join(import.meta.dir, "../fixture/process-lock-worker.ts")
it.live(
"releases ownership when the scope closes",
Effect.gen(function* () {
const root = yield* temp("opencode-process-lock-")
const file = path.join(root, "service.lock")
yield* Effect.scoped(ProcessLock.acquire(file))
yield* Effect.scoped(ProcessLock.acquire(file))
}),
)
it.live(
"releases ownership when the process dies",
Effect.gen(function* () {
const root = yield* temp("opencode-process-lock-death-")
const file = path.join(root, "service.lock")
const ready = path.join(root, "ready")
const child = yield* Effect.acquireRelease(
Effect.sync(() =>
Bun.spawn([process.execPath, worker, JSON.stringify({ file, ready })], {
stdout: "ignore",
stderr: "pipe",
}),
),
(child) =>
Effect.promise(async () => {
kill(child)
await child.exited
}),
)
yield* Effect.promise(async () => {
for (let attempt = 0; attempt < 100 && !(await Bun.file(ready).exists()); attempt++) await Bun.sleep(20)
})
expect(yield* Effect.promise(() => Bun.file(ready).exists())).toBe(true)
const error = yield* Effect.scoped(ProcessLock.acquire(file)).pipe(Effect.flip)
expect(error._tag).toBe("ProcessLockHeldError")
if (process.platform !== "win32") {
process.kill(child.pid, "SIGSTOP")
const paused = yield* Effect.scoped(ProcessLock.acquire(file)).pipe(Effect.flip)
expect(paused._tag).toBe("ProcessLockHeldError")
process.kill(child.pid, "SIGCONT")
}
kill(child)
yield* Effect.promise(() => child.exited)
yield* Effect.scoped(ProcessLock.acquire(file))
}),
)
function temp(prefix: string) {
return Effect.acquireRelease(
Effect.promise(() => fs.mkdtemp(path.join(os.tmpdir(), prefix))),
(root) => Effect.promise(() => fs.rm(root, { recursive: true, force: true })),
)
}
function kill(child: Bun.Subprocess) {
if (process.platform === "win32") return child.kill()
return child.kill("SIGKILL")
}
+2 -2
View File
@@ -23,8 +23,8 @@ import type { ServerOptions } from "./options"
*
* - Database runs on the injected `DurableObjectStorage` SQLite.
* - Watcher and fff are disabled through their existing option flags; pty, fff,
* shell-parser, and photon native modules resolve to inert stubs under the
* `workerd` bundle condition.
* shell-parser, photon, and process-lock native modules resolve to inert
* stubs under the `workerd` bundle condition.
* - Bare locations use a typed no-execution-plane process spawner; FileSystem,
* FileSystemSearch, and Pty fail with a clear defect until a remote sandbox
* backs them; Snapshot and Vcs degrade to no-op results.
@@ -208,8 +208,6 @@ export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions:
const projectOptions = data.project
.list()
.filter((project) => project.canonical !== "/")
// Historical project identities can share a checkout. The list is newest-active first.
.filter((project, index, projects) => projects.findIndex((item) => item.canonical === project.canonical) === index)
.map((project) => ({ directory: project.canonical, project }))
.map((item) => {
const title =
@@ -53,7 +53,6 @@ import { usePromptMove } from "./move"
import { resolvePastedAttachments } from "./local-attachment"
import { locationKey, useData } from "../../context/data"
import { useLocation } from "../../context/location"
import { useArgs } from "../../context/args"
import { Keymap, type KeymapCommand } from "../../context/keymap"
import { useInteractivity } from "../../context/interactivity"
import { abbreviateHome } from "../../runtime"
@@ -207,7 +206,6 @@ export function Prompt(props: PromptProps) {
const directoryRecents = useDirectoryRecents()
const keymapCommands = Keymap.useCommands()
const currentLocation = useLocation()
const args = useArgs()
const config = useConfig().data
const dialog = useDialog()
const toast = useToast()
@@ -1231,9 +1229,7 @@ export function Prompt(props: PromptProps) {
// a local session record synchronously, so the navigation below happens
// immediately — enter feels sent even while the create round-trip is in
// flight. Sends against the new session gate on the request.
const newSessionID = args.takeNewSessionID()
const created = data.session.create({
id: newSessionID,
location: directory ? { directory } : location,
agent: agent.id,
model: {
@@ -1242,7 +1238,6 @@ export function Prompt(props: PromptProps) {
variant,
},
})
if (newSessionID !== undefined) created.request.catch(() => args.restoreNewSessionID(newSessionID))
sessionID = created.id
session = data.session.get(created.id)
newSession = {
+2 -6
View File
@@ -54,9 +54,6 @@ export const Plugin = Schema.Union([
}),
])
export const DiffSource = Schema.Union([Vcs.Mode, Schema.Literal("turn")])
export type DiffSource = Schema.Schema.Type<typeof DiffSource>
export const Cursor = Schema.Struct({
style: Schema.optional(Schema.Literals(["block", "underline", "line", "default"])).annotate({
description: "Cursor shape. Use 'default' to preserve the terminal setting",
@@ -111,9 +108,8 @@ export const Info = Schema.Struct({
).annotate({ description: "System notification and sound settings" }),
diffs: Schema.optional(
Schema.Struct({
source: Schema.optional(DiffSource).annotate({
description:
"Initial diff source; defaults to 'branch' (branch and uncommitted changes). 'turn' shows the session's last turn and falls back to 'branch' outside a session",
source: Schema.optional(Vcs.Mode).annotate({
description: "Initial diff source; defaults to 'branch' (branch and uncommitted changes)",
}),
wrap: Schema.optional(Schema.Literals(["word", "none"])).annotate({
description: "Line wrapping behavior in diff output",
+1 -16
View File
@@ -1,4 +1,3 @@
import { mergeProps } from "solid-js"
import { createSimpleContext } from "./helper"
export interface Args {
@@ -7,25 +6,11 @@ export interface Args {
prompt?: string
continue?: boolean
sessionID?: string
newSessionID?: string
fork?: boolean
auto?: boolean
}
export const { use: useArgs, provider: ArgsProvider } = createSimpleContext({
name: "Args",
init: (props: Args) => {
// The first new session created from home takes this ID; later ones mint their own.
let pending = props.newSessionID
return mergeProps(props, {
takeNewSessionID() {
const id = pending
pending = undefined
return id
},
restoreNewSessionID(id: string) {
pending ??= id
},
})
},
init: (props: Args) => props,
})
@@ -130,8 +130,8 @@ export function Answer(props: {
<text attributes={TextAttributes.BOLD} fg={theme.text.base} flexShrink={0}>
/btw
</text>
<text fg={theme.text.muted} wrapMode="none" flexGrow={1} truncate>
{props.question.replace(/\s+/g, " ")}
<text fg={theme.text.muted} wrapMode="word" flexGrow={1}>
{props.question}
</text>
<text fg={theme.text.muted} flexShrink={0} onMouseUp={() => dialog.clear()}>
esc
@@ -21,7 +21,7 @@ import { EmptyBorder } from "../../ui/border"
import { FilePath } from "../../ui/file-path"
import { getScrollAcceleration } from "../../util/scroll"
import { createDebouncedSignal } from "../../util/signal"
import { type DiffSource, useConfig } from "../../config"
import { useConfig } from "../../config"
import { locationKey } from "../../context/data"
import { useThemes } from "../../context/theme"
import { PatchDiff, type PatchDiffRef } from "../../component/patch-diff"
@@ -44,7 +44,7 @@ const FILE_TREE_MIN_WIDTH = 30
const FILE_TREE_MAX_WIDTH = 40
const FILE_HEADER_HEIGHT = 2
const VCS_DIFF_CONTEXT_LINES = 12
type DiffMode = DiffSource
type DiffMode = Vcs.Mode
type DiffView = "split" | "unified"
type SelectedHunk = { readonly fileIndex: number; readonly hunkIndex: number; readonly scrollTop: number }
type FileMenuState = { readonly fileIndex: number; readonly x: number; readonly y: number }
@@ -70,16 +70,11 @@ function storedView(value: unknown): DiffView | undefined {
if (value === "split" || value === "unified") return value
}
const DIFF_SOURCES = {
branch: { label: "All", description: "Branch + local changes" },
committed: { label: "Committed", description: "Branch commits only" },
working: { label: "Uncommitted", description: "Local changes only" },
turn: { label: "Last turn", description: "Latest session turn" },
} satisfies Record<DiffMode, { label: string; description: string }>
const VCS_SOURCES = ["branch", "committed", "working"] as const
const needsBase = (mode: DiffMode) => mode === "branch" || mode === "committed"
function diffSourceLabel(mode: DiffMode) {
if (mode === "branch") return "All"
if (mode === "committed") return "Committed"
return "Uncommitted"
}
function DiffViewer(props: { context: Plugin.Context }) {
const dimensions = useTerminalDimensions()
@@ -98,15 +93,12 @@ function DiffViewer(props: { context: Plugin.Context }) {
}
| undefined
}
const sessionID = () => params()?.sessionID
const sources = (): readonly DiffMode[] => (sessionID() ? [...VCS_SOURCES, "turn"] : VCS_SOURCES)
const initialMode = params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch"
const [mode, setMode] = createSignal(sources().includes(initialMode) ? initialMode : "branch")
const [mode, setMode] = createSignal(params()?.mode ?? memory.source ?? config.data.diffs?.source ?? "branch")
const location = createMemo(
() => {
const id = sessionID()
return id
? (props.context.data.session.get(id)?.location ?? props.context.data.location.default())
const sessionID = params()?.sessionID
return sessionID
? (props.context.data.session.get(sessionID)?.location ?? props.context.data.location.default())
: props.context.data.location.default()
},
undefined,
@@ -129,32 +121,19 @@ function DiffViewer(props: { context: Plugin.Context }) {
bases.set(key, pending)
return pending
}
const diffInput = createMemo(() => {
const current = mode()
const id = sessionID()
if (current === "turn" && id) return { mode: current, sessionID: id }
const vcs = current === "turn" ? "branch" : current
return {
mode: vcs,
location: location(),
key: baseKey(),
selected: needsBase(vcs) ? selectedBase() : undefined,
}
})
const [diff, { refetch }] = createResource(diffInput, async (input) => {
if (input.mode === "turn") {
return {
base: null,
files: normalizeDiffs(
await props.context.client.session.diff({ sessionID: input.sessionID, context: VCS_DIFF_CONTEXT_LINES }),
),
}
}
const base = !needsBase(input.mode)
? undefined
: input.selected
? { name: input.selected, ref: input.selected }
: (await loadBase(input.location, input.key)).data
const diffInput = createMemo(() => ({
mode: mode(),
location: location(),
key: baseKey(),
selected: mode() === "working" ? undefined : selectedBase(),
}))
const [diff] = createResource(diffInput, async (input) => {
const base =
input.mode === "working"
? undefined
: input.selected
? { name: input.selected, ref: input.selected }
: (await loadBase(input.location, input.key)).data
if (input !== diffInput() || (input.mode === "committed" && !base)) {
return { base: null, files: [] }
}
@@ -166,21 +145,12 @@ function DiffViewer(props: { context: Plugin.Context }) {
})
return { base, files: normalizeDiffs(result.data ?? []) }
})
// Each completed turn replaces the last one.
createEffect((previous) => {
if (mode() !== "turn") return undefined
const id = sessionID()
const status = id ? props.context.data.session.status(id) : undefined
if (previous === "running" && status === "idle") void refetch()
return status
})
const sourceBase = () => {
const ref = selectedBase()
return ref ? { name: ref, ref } : reportedBases().get(baseKey())
}
const result = () => (diff.error || diff.loading ? undefined : diff())
const sourceDetail = () => {
if (mode() === "turn") return diff.error ? "Diff unavailable" : undefined
if (mode() === "working") return "vs HEAD"
if (diff.error) return "Base or diff unavailable"
if (!result()) return "Resolving diff…"
@@ -197,7 +167,6 @@ function DiffViewer(props: { context: Plugin.Context }) {
loading={diff.loading}
error={diff.error}
mode={mode()}
sources={sources()}
sourceDetail={sourceDetail()}
sourceBase={sourceBase()}
unavailable={mode() === "committed" && !!result() && !result()?.base}
@@ -292,7 +261,6 @@ export function DiffViewerContent(props: {
loading?: boolean
error?: unknown
mode: DiffMode
sources: readonly DiffMode[]
sourceDetail?: string
sourceBase?: Pick<Vcs.Base, "name" | "ref"> | null
unavailable?: boolean
@@ -734,6 +702,20 @@ export function DiffViewerContent(props: {
]
const openSwitchDiffDialog = () => {
const options = [
{
value: "branch" as const,
description: "Branch + local changes",
},
{
value: "committed" as const,
description: "Branch commits only",
},
{
value: "working" as const,
description: "Local changes only",
},
]
dialog.show(() => (
<DialogSelect<DiffMode | "base">
title="Diff source"
@@ -741,14 +723,13 @@ export function DiffViewerContent(props: {
renderFilter={false}
current={mode()}
options={[
...props.sources.map((source) => ({
value: source,
title: DIFF_SOURCES[source].label,
titleView: DIFF_SOURCES[source].label.padEnd(11),
description: DIFF_SOURCES[source].description,
...options.map((option) => ({
...option,
title: diffSourceLabel(option.value),
titleView: diffSourceLabel(option.value).padEnd(11),
onSelect() {
dialog.clear()
props.onSwitchSource(source)
props.onSwitchSource(option.value)
},
})),
...(props.onChooseBase
@@ -830,7 +811,7 @@ export function DiffViewerContent(props: {
flexShrink={0}
wrapMode="none"
>
{DIFF_SOURCES[mode()].label}
{diffSourceLabel(mode())}
</text>
<Show when={props.sourceDetail}>
<text fg={theme.text.muted} selectable={false} flexGrow={1} minWidth={0} wrapMode="none" truncate>
@@ -853,7 +834,7 @@ export function DiffViewerContent(props: {
<Match when={!props.loading && props.error}>
<box flexGrow={1} padding={2}>
<text fg={theme.text.feedback.error.base}>
{!props.sourceBase && needsBase(mode())
{!props.sourceBase && mode() !== "working"
? "Could not load diff. Choose a base branch from Diff source, or select Uncommitted."
: "Could not load diff. Reopen the diff viewer to try again."}
</text>
@@ -887,7 +868,7 @@ export function DiffViewerContent(props: {
expandedNodes={expandedFileNodes()}
onRowClick={clickFileTreeRow}
onFileContextMenu={openFileMenu}
source={DIFF_SOURCES[mode()].label}
source={diffSourceLabel(mode())}
sourceDetail={props.sourceDetail}
onSwitchSource={openSwitchDiffDialog}
footer={<HelpShortcut />}
+2 -3
View File
@@ -65,13 +65,12 @@ export function Home() {
untrack(() => composer.set(prompt))
})
// Wait for everything submit needs before auto-submitting --prompt; it runs once.
// Wait for the model store to be ready before auto-submitting --prompt.
createEffect(() => {
const r = ref()
if (sent) return
if (!r) return
if (!local.model.ready || !local.model.catalogReady) return
if (!local.agent.current() || !local.model.current()) return
if (!local.model.ready) return
if (!args.prompt) return
if (r.current.text !== args.prompt) return
sent = true
+1 -1
View File
@@ -2808,7 +2808,7 @@ function Shell(props: ToolProps) {
command={stringValue(props.input.command)}
workdir={stringValue(props.input.workdir)}
status={props.part.state.status}
background={props.part.state.status === "completed" && props.metadata.status === "running"}
background={Boolean(stringValue(props.metadata.shellID)) && props.part.state.status !== "running"}
output={stringValue(props.metadata.shellID) ? undefined : props.output}
/>
)
-55
View File
@@ -876,61 +876,6 @@ test("session startup prompt is submitted exactly once", async () => {
}
})
test("home startup prompt is submitted exactly once", async () => {
await using state = await tmpdir()
const cwd = process.cwd()
const location = { directory: cwd, project: { id: "project", directory: cwd, canonical: cwd } }
const bodies: unknown[] = []
const submitted = Promise.withResolvers<void>()
let session: unknown
await using setup = await createAppFixture({
state: state.path,
args: { prompt: "HOME_READY" },
config: { animations: false, tabs: { mode: "off" } },
fetch: async (url, request) => {
if (url.pathname === "/api/location") return json(location)
if (url.pathname === "/api/fs/list") return json({ location, data: [] })
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "provider", name: "Model", variants: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "provider", name: "Provider" }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const input: unknown = await request.json()
if (typeof input !== "object" || input === null) throw new Error("Expected a session input")
session = {
...input,
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
return json({ data: session })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
bodies.push(await request.json())
submitted.resolve()
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (session && /^\/api\/session\/[^/]+$/.test(url.pathname)) return json({ data: session })
return undefined
},
})
await setup.ready
await Promise.race([
submitted.promise,
Bun.sleep(2000).then(() => {
throw new Error("startup prompt was not submitted")
}),
])
await Bun.sleep(20)
expect(bodies).toHaveLength(1)
expect(bodies[0]).toMatchObject({ text: "HOME_READY" })
})
test.each([false, true])("uses the resolved launch directory for new prompts (fallback: %s)", async (fallback) => {
await using state = await tmpdir()
const target = fallback ? directory : process.cwd()
+15 -100
View File
@@ -209,80 +209,6 @@ test("explicit route source overrides the configured default", async () => {
}
})
test("the turn source diffs the session's last turn without resolving a base", async () => {
const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true })
try {
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("Last turn") && frame.includes("const first"))
expect(viewer.app.captureCharFrame()).not.toContain("Base not reported")
expect(viewer.turnDiffRequests).toHaveLength(1)
expect(viewer.turnDiffRequests[0].searchParams.get("context")).toBe("12")
expect(viewer.turnDiffRequests[0].searchParams.has("from")).toBe(false)
expect(viewer.diffRequests).toHaveLength(1)
expect(viewer.baseRequests).toHaveLength(1)
await chooseSource(viewer, 2)
await viewer.app.waitForFrame((frame) => frame.includes("Uncommitted · vs HEAD") && frame.includes("const first"))
expect(viewer.vcsDiffInput()).toEqual({ location: session.location, mode: "working", context: "12" })
} finally {
viewer.app.renderer.destroy()
}
})
test("the turn source refreshes only when the session finishes a turn", async () => {
const viewer = await renderDiffViewer(hunkDiff, { source: "turn" })
try {
expect(viewer.turnDiffRequests).toHaveLength(1)
viewer.setSessionStatus("running")
await viewer.app.flush()
expect(viewer.turnDiffRequests).toHaveLength(1)
viewer.setSessionStatus("idle")
await viewer.app.waitFor(() => viewer.turnDiffRequests.length === 2)
await viewer.app.waitForFrame((frame) => frame.includes("const first"))
expect(viewer.diffRequests).toHaveLength(0)
} finally {
viewer.app.renderer.destroy()
}
})
test("an empty or failing turn diff uses the viewer's empty and error states", async () => {
const empty = await renderDiffViewer([], { source: "turn" })
try {
await empty.app.waitForFrame((frame) => frame.includes("No changes to show"))
expect(empty.baseRequests).toHaveLength(0)
expect(empty.diffRequests).toHaveLength(0)
expect(empty.turnDiffRequests).toHaveLength(1)
} finally {
empty.app.renderer.destroy()
}
const failing = await renderDiffViewer([], { source: "turn", fail: true })
try {
await failing.app.waitForFrame((frame) => frame.includes("Could not load diff. Reopen the diff viewer"))
expect(failing.app.captureCharFrame()).toContain("Last turn · Diff unavailable")
expect(failing.app.captureCharFrame()).not.toContain("Choose a base branch")
} finally {
failing.app.renderer.destroy()
}
})
test("the turn source is unavailable outside a session and falls back to the branch scope", async () => {
const viewer = await renderDiffViewer(hunkDiff, { source: "turn", height: 30, initialRoute: { type: "home" } })
try {
expect(viewer.vcsDiffInput()).toEqual({
location: { directory: "/repo/default" },
mode: "branch",
base: "refs/heads/v2",
context: "12",
})
expect(viewer.turnDiffRequests).toHaveLength(0)
viewer.app.mockInput.pressKey("d")
await viewer.app.waitForFrame((frame) => frame.includes("Diff source"))
expect(viewer.app.captureCharFrame()).not.toContain("Last turn")
expect(viewer.app.captureCharFrame()).toMatch(/Base\s+v2/)
} finally {
viewer.app.renderer.destroy()
}
})
test.each([50, 80, 160])(
"keeps scope, base, and review count on one row with a selectable base at %i columns",
async (width) => {
@@ -314,11 +240,9 @@ test.each([50, 80, 160])(
expect(rows[first]).toMatch(/All\s+Branch \+ local changes/)
expect(rows[first + 1]).toMatch(/Committed\s+Branch commits only/)
expect(rows[first + 2]).toMatch(/Uncommitted\s+Local changes only/)
expect(rows[first + 3]).toMatch(/Last turn\s+Latest session turn/)
expect(rows[first + 4]).toMatch(/Base\s+release/)
expect(rows[first + 3]).toMatch(/Base\s+release/)
expect(rows[first + 1].indexOf("Branch commits only")).toBe(rows[first].indexOf("Branch + local changes"))
expect(rows[first + 2].indexOf("Local changes only")).toBe(rows[first].indexOf("Branch + local changes"))
expect(rows[first + 3].indexOf("Latest session turn")).toBe(rows[first].indexOf("Branch + local changes"))
viewer.app.mockInput.pressEscape()
await viewer.app.waitForFrame((frame) => !frame.includes("Diff source"))
viewer.commands.get("diff.mark_reviewed")!.run()
@@ -368,7 +292,7 @@ test.each([50, 80, 100, 160])(
)
test("opening the source chooser from initial Uncommitted does not resolve a branch base", async () => {
const viewer = await renderDiffViewer(hunkDiff, { source: "working", height: 30 })
const viewer = await renderDiffViewer(hunkDiff, { source: "working" })
try {
viewer.app.mockInput.pressKey("d")
await viewer.app.waitForFrame((frame) => frame.includes("Diff source"))
@@ -389,7 +313,7 @@ test.each(["branch", "committed", "working"] as const)(
viewer.commands.get("diff.mark_reviewed")!.run()
await viewer.app.flush()
expect(viewer.app.captureCharFrame()).toContain("1/1")
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("Base branch") && frame.includes("origin/release"))
expect(viewer.app.captureCharFrame()).toMatch(/●\s+v2/)
expect(viewer.branchesRequests[0].searchParams.get("location[directory]")).toBe("/repo/session")
@@ -423,7 +347,7 @@ test.each(["branch", "committed", "working"] as const)(
expect(viewer.app.captureCharFrame()).toContain("0/1")
expect(viewer.diffRequests).toHaveLength(source === "working" ? 2 : 3)
if (source !== "working") expect(viewer.vcsDiffInput()).toMatchObject({ base: "origin/release" })
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => /●\s+origin\/release/.test(frame))
expect(viewer.baseRequests).toHaveLength(1)
} finally {
@@ -447,7 +371,7 @@ test.each(["branch", "committed"] as const)("an ambiguous base never requests a
expect(viewer.app.captureCharFrame()).toContain("Choose a base branch")
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
expect(viewer.diffRequests).toHaveLength(0)
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("Base branch") && frame.includes("origin/release"))
viewer.app.mockInput.pressKey("HOME")
viewer.app.mockInput.pressArrow("down")
@@ -464,7 +388,7 @@ test.each(["branch", "committed"] as const)("an ambiguous base never requests a
test("base and scope choices survive reopening but not a new TUI instance", async () => {
const viewer = await renderDiffViewer(hunkDiff, { source: "working", height: 30, kittyKeyboard: true })
try {
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
viewer.app.mockInput.pressArrow("down")
viewer.app.mockInput.pressEnter()
@@ -497,7 +421,7 @@ test("base and scope choices survive reopening but not a new TUI instance", asyn
test("base choices are isolated by branch within the same location", async () => {
const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true })
try {
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
viewer.app.mockInput.pressArrow("down")
viewer.app.mockInput.pressEnter()
@@ -525,13 +449,13 @@ test("an invalid comparison reports an error and allows another base choice", as
: json({ location: session.location, data: hunkDiff }),
})
try {
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
viewer.app.mockInput.pressArrow("down")
viewer.app.mockInput.pressEnter()
await viewer.app.waitForFrame((frame) => frame.includes("Base or diff unavailable"))
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
viewer.app.mockInput.pressKey("HOME")
viewer.app.mockInput.pressEnter()
@@ -550,7 +474,7 @@ test("base search failures are visible without changing the diff", async () => {
branchesResponse: async () => json({ message: "branches unavailable" }, { status: 503 }),
})
try {
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("Could not load branches"))
expect(viewer.app.captureCharFrame()).toContain("All · vs v2")
expect(viewer.mutationRequests).toHaveLength(0)
@@ -569,7 +493,7 @@ test("a late base lookup cannot overwrite an in-memory base choice", async () =>
baseResponse: () => pending.promise,
})
try {
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
viewer.app.mockInput.pressArrow("down")
viewer.app.mockInput.pressEnter()
@@ -598,7 +522,7 @@ test("dismissing the base picker leaves the comparison unchanged", async () => {
kittyKeyboard: true,
})
try {
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
viewer.app.mockInput.pressArrow("down")
viewer.app.mockInput.pressEscape()
@@ -616,7 +540,7 @@ test("dismissing the base picker leaves the comparison unchanged", async () => {
test("the base picker remembers its captured location without refreshing a moved session", async () => {
const viewer = await renderDiffViewer(hunkDiff, { height: 30, kittyKeyboard: true })
try {
await chooseSource(viewer, 4)
await chooseSource(viewer, 3)
await viewer.app.waitForFrame((frame) => frame.includes("origin/release"))
viewer.setSessionLocation({ directory: "/repo/moved" })
await viewer.app.flush()
@@ -1876,7 +1800,7 @@ async function renderDiffViewer(
onSessionTab?: () => void
keybinds?: TuiKeybind.KeybindOverrides
kittyKeyboard?: boolean
source?: "branch" | "committed" | "working" | "turn"
source?: "branch" | "committed" | "working"
base?: typeof baseFixture | null
open?: boolean
pending?: boolean
@@ -1890,7 +1814,6 @@ async function renderDiffViewer(
const [current, setCurrent] = createSignal<Route>(options.initialRoute ?? startRoute)
const [sessionLocation, setSessionLocation] = createSignal(session.location)
const [branch, setBranch] = createSignal("feature")
const [sessionStatus, setSessionStatus] = createSignal<"idle" | "running">("idle")
const state = options.state ?? path.join(temporary.path, crypto.randomUUID())
let renderDiff: Page["render"] | undefined
let renderCommands: SlotClaim<"app">["render"] | undefined
@@ -1903,7 +1826,6 @@ async function renderDiffViewer(
const writes: Info[] = []
const baseRequests: URL[] = []
const diffRequests: URL[] = []
const turnDiffRequests: URL[] = []
const branchesRequests: URL[] = []
const mutationRequests: URL[] = []
const config = createTuiResolvedConfig(stored.info)
@@ -1936,11 +1858,6 @@ async function renderDiffViewer(
),
})
}
if (url.pathname === "/api/session/session-1/diff") {
turnDiffRequests.push(url)
if (options.fail) return json({ message: "boom" }, { status: 500 })
return json({ data: vcsDiff })
}
if (url.pathname !== "/api/vcs/diff") return
diffRequests.push(url)
vcsDiffInput = {
@@ -1979,7 +1896,7 @@ async function renderDiffViewer(
storage: useStorage(),
client: createApi(transport.fetch),
data: {
session: { get: () => ({ ...session, location: sessionLocation() }), status: sessionStatus },
session: { get: () => ({ ...session, location: sessionLocation() }) },
location: {
default: () => ({ directory: "/repo/default" }),
vcs: { info: () => ({ branch: { current: branch() } }) },
@@ -2086,11 +2003,9 @@ async function renderDiffViewer(
imageReadInput: () => imageReadInput,
baseRequests,
diffRequests,
turnDiffRequests,
branchesRequests,
mutationRequests,
setSessionLocation,
setSessionStatus,
setBranch,
state,
writes,
+2 -2
View File
@@ -9,8 +9,8 @@ import { CommandMap, Definitions } from "../src/config/v1/keybind"
const decodeInfo = Schema.decodeUnknownSync(Info)
test("validates the explicit diff source defaults", () => {
for (const source of ["branch", "committed", "working", "turn"] as const) {
test("validates the three explicit diff source defaults", () => {
for (const source of ["branch", "committed", "working"] as const) {
expect(decodeInfo({ diffs: { source } })).toEqual({ diffs: { source } })
}
expect(decodeInfo({ diffs: {} })).toEqual({ diffs: {} })
-100
View File
@@ -1,100 +0,0 @@
import { expect, test } from "bun:test"
import { createAppFixture } from "./fixture/app"
import { json } from "./fixture/tui-client"
type SessionInput = { id?: string; location?: { directory: string } }
const location = {
directory: "/tmp/opencode/packages/tui",
project: { id: "project", directory: "/tmp/opencode", canonical: "/tmp/opencode" },
}
function sessionInfo(record: SessionInput) {
return {
...record,
location: record.location ?? { directory: location.directory },
projectID: "project",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
}
}
async function launch(options: { failCreates?: number } = {}) {
const attempts: (string | undefined)[] = []
const created: SessionInput[] = []
const prompts: string[] = []
let failures = options.failCreates ?? 0
const setup = await createAppFixture({
args: { newSessionID: "ses_chosen" },
config: { animations: false, keybinds: { "session.new": "f6" } },
fetch: async (url, request) => {
if (url.pathname === "/api/agent")
return json({ location, data: [{ id: "build", mode: "primary", hidden: false, permissions: [] }] })
if (url.pathname === "/api/provider") return json({ location, data: [{ id: "demo", name: "Demo" }] })
if (url.pathname === "/api/model")
return json({ location, data: [{ id: "model", providerID: "demo", name: "Demo Model", variants: [] }] })
if (url.pathname === "/api/session" && request.method === "POST") {
const record: SessionInput = await request.json()
attempts.push(record.id)
if (failures > 0) {
failures--
return json({ message: "create failed" }, { status: 500 })
}
created.push(record)
return json({ data: sessionInfo(record) })
}
if (/^\/api\/session\/[^/]+\/prompt$/.test(url.pathname)) {
prompts.push(url.pathname.split("/")[3] ?? "")
return json({ data: {} })
}
if (/^\/api\/session\/[^/]+\/(message|inbox|permission)$/.test(url.pathname))
return json({ data: [], cursor: {} })
if (/^\/api\/session\/[^/]+\/(agent|model)$/.test(url.pathname)) return new Response(null, { status: 204 })
if (/^\/api\/session\/[^/]+$/.test(url.pathname)) {
const record = created.find((item) => item.id === url.pathname.split("/")[3])
if (!record) return json({ message: "not found" }, { status: 404 })
return json({ data: sessionInfo(record) })
}
return undefined
},
})
return { setup, attempts, created, prompts }
}
test("the first new session uses the launch session ID and later ones mint their own", async () => {
const run = await launch()
await using setup = run.setup
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.prompts.length === 1)
expect(run.prompts[0]).toBe("ses_chosen")
expect(run.created.map((item) => item.id)).toEqual(["ses_chosen"])
setup.mockInput.pressKey("F6")
await setup.renderOnce()
await setup.mockInput.typeText("again")
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.created.length === 2)
expect(run.created[1]?.id).toMatch(/^ses/)
expect(run.created[1]?.id).not.toBe("ses_chosen")
})
test("a failed first create keeps the launch session ID for the retry", async () => {
const run = await launch({ failCreates: 1 })
await using setup = run.setup
await setup.ready
await setup.waitForFrame((frame) => frame.includes("Demo Model"))
await setup.mockInput.typeText("hello")
setup.mockInput.pressEnter()
await setup.waitForFrame((frame) => frame.includes("Creating a session failed") && frame.includes("hello"))
expect(run.attempts).toEqual(["ses_chosen"])
setup.mockInput.pressEnter()
await setup.waitForFrame(() => run.created.length === 1)
expect(run.attempts).toEqual(["ses_chosen", "ses_chosen"])
})

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