mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 01:57:38 +00:00
Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d5f2c978c | ||
|
|
2d8968d7e8 | ||
|
|
9ddd2e1a4c | ||
|
|
9810d98bc2 | ||
|
|
2caba90a63 | ||
|
|
eccf0b3b7b | ||
|
|
808588e9b9 | ||
|
|
c35c211460 | ||
|
|
efb8e23dcc | ||
|
|
ad53e39d2e | ||
|
|
3ddfc15df5 | ||
|
|
ac2426e103 | ||
|
|
0bc8b8dbeb | ||
|
|
dca73ba9e3 | ||
|
|
318a8c1aba |
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -216,7 +216,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -279,7 +279,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -296,7 +296,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -320,7 +320,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -340,7 +340,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -408,7 +408,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -457,7 +457,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -494,7 +494,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -510,7 +510,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -529,7 +529,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -542,7 +542,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -556,7 +556,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -571,7 +571,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -610,7 +610,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -640,7 +640,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -655,7 +655,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -679,7 +679,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -700,7 +700,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -722,7 +722,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -757,7 +757,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -777,7 +777,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -811,7 +811,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -830,7 +830,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -876,7 +876,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -890,7 +890,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -925,7 +925,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -960,7 +960,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -997,7 +997,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1038,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -55,7 +55,7 @@ const request = LLM.request({
|
||||
prompt: "Say hello.",
|
||||
})
|
||||
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const response = yield * LLMClient.generate(request) // inside Effect.gen
|
||||
```
|
||||
|
||||
`LLM.request(...)` builds an `LLMRequest`. `LLMClient.generate(...)` reads the executable route carried by `request.model.route`, builds the provider-native body, asks the route's transport for a real `HttpClientRequest.HttpClientRequest`, sends it through `RequestExecutor.Service`, parses the provider stream into common `LLMEvent`s, and finally returns an `LLMResponse`.
|
||||
@@ -98,7 +98,7 @@ When a provider supports multiple physical transports, selection remains executi
|
||||
|
||||
Media does not fit the SSE-frames-to-event-state-machine LLM route. `MediaRoute.make(...)` (`src/route/media.ts`) composes a `MediaProtocol` kind with `Endpoint` and `Auth` and owns 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)`; use `MediaProtocol.decodeJson` / `text` / `bytes` so decode failures retain the raw body and HTTP context. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`, `pollHint`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through `MediaInput.rejectUnsupported`.
|
||||
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, 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 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`. `Generation.AwaitOptions` (`{ 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`, 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 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 `MediaProtocol.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 `MediaProtocol.decodeFrame` and raise stream-time failures with `MediaProtocol.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.
|
||||
|
||||
|
||||
+171
-114
@@ -1,11 +1,10 @@
|
||||
# @opencode/ai
|
||||
|
||||
Schema-first language model and image-generation APIs built with Effect.
|
||||
Schema-first APIs for text, images, video, speech, and transcription, built with Effect.
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode/ai"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
import { Effect } from "effect"
|
||||
import { AIClient, LLM } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY })
|
||||
@@ -18,23 +17,23 @@ const request = LLM.request({
|
||||
})
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
const response = yield* LLM.generate(request)
|
||||
console.log(response.text)
|
||||
})
|
||||
|
||||
const llmLayer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
// Every modality client plus the HTTP request executor; `AIClient.layerWith(executor)` swaps the executor.
|
||||
await Effect.runPromise(program.pipe(Effect.provide(AIClient.layer)))
|
||||
```
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
Run `LLM.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses,
|
||||
Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
The same configured facade names image models. `Image.request` resolves the provider's image route from the ref and
|
||||
returns `Media.Asset`s with lazily decoded bytes:
|
||||
The same configured facade names image, video, speech, and transcription models. `Image.generate` resolves the
|
||||
provider's image route from the model and returns `Media.Asset`s with lazily decoded bytes:
|
||||
|
||||
```ts
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Image, ImageClient, Media } from "@opencode/ai"
|
||||
import { Image, Media } from "@opencode/ai"
|
||||
|
||||
const image = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
@@ -46,13 +45,28 @@ const image = Effect.gen(function* () {
|
||||
yield* Media.write(response.image, "./garden.png")
|
||||
})
|
||||
|
||||
// `asset.bytes()` / `Media.write` also need the executor, so merge it into the environment instead of hiding it.
|
||||
const imageLayer = ImageClient.layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
|
||||
|
||||
await Effect.runPromise(image.pipe(Effect.provide(imageLayer), Effect.provide(NodeFileSystem.layer)))
|
||||
// `Media.file` / `Media.write` use the Effect `FileSystem` service; provide your platform's layer.
|
||||
await Effect.runPromise(image.pipe(Effect.provide(AIClient.layer), Effect.provide(NodeFileSystem.layer)))
|
||||
```
|
||||
|
||||
Prefer promises? `@opencode/ai/promise` exposes the same LLM and image APIs over one managed runtime:
|
||||
Advanced: each client also has its own `layer`, which requires `RequestExecutor.Service`. Compose client layers with
|
||||
`Layer.provideMerge`, not `Layer.provide`: `asset.bytes()`, `Media.write`, and Gemini's `media` output parts need the
|
||||
executor too, and hiding it fails type-checking with `RequestExecutorService` left in the requirements.
|
||||
|
||||
To share a policy such as logging across every client, wrap the executor once with `RequestExecutor.middleware`:
|
||||
|
||||
```ts
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
|
||||
const logged = RequestExecutor.middleware((request, next) =>
|
||||
Effect.log(`${request.method} ${request.url}`).pipe(Effect.andThen(next(request))),
|
||||
)
|
||||
|
||||
const everything = AIClient.layerWith(logged) // or AI.make({ layer: logged })
|
||||
```
|
||||
|
||||
Prefer promises? `@opencode/ai/promise` exposes the same LLM and media APIs over one managed runtime, plus asset
|
||||
helpers; `ai.file` and `ai.write` load `node:fs/promises` on first use, so no Effect `FileSystem` is needed:
|
||||
|
||||
```ts
|
||||
import { AI } from "@opencode/ai/promise"
|
||||
@@ -60,6 +74,7 @@ import { AI } from "@opencode/ai/promise"
|
||||
const ai = AI.make()
|
||||
const text = await ai.llm.generate({ model: openai.responses("gpt-4o-mini"), prompt: "Say hello." })
|
||||
const generated = await ai.image.generate({ model: openai.image("gpt-image-2"), prompt: "A lighthouse" })
|
||||
await ai.write(generated.image, "./lighthouse.png") // also ai.file(path), ai.bytes(asset), ai.base64(asset), ai.materialize(asset)
|
||||
for await (const event of ai.llm.stream({ model: openai.responses("gpt-4o-mini"), prompt: "Stream hello." })) {
|
||||
// LLMEvent
|
||||
}
|
||||
@@ -322,10 +337,9 @@ and `moonshot/responses`; each exports `model(modelID, settings)`.
|
||||
MiniMax defaults to its Messages API and reads `MINIMAX_API_KEY` when `apiKey` is omitted:
|
||||
|
||||
```ts
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLM, LLMClient } from "@opencode/ai"
|
||||
import { Effect } from "effect"
|
||||
import { AIClient, LLM } from "@opencode/ai"
|
||||
import { MiniMax } from "@opencode/ai/providers"
|
||||
import { RequestExecutor } from "@opencode/ai/route"
|
||||
|
||||
const minimax = MiniMax.configure({ apiKey: process.env.MINIMAX_API_KEY })
|
||||
const request = LLM.request({
|
||||
@@ -335,8 +349,7 @@ const request = LLM.request({
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
|
||||
const layer = LLMClient.layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
const response = await Effect.runPromise(LLMClient.generate(request).pipe(Effect.provide(layer)))
|
||||
const response = await Effect.runPromise(LLM.generate(request).pipe(Effect.provide(AIClient.layer)))
|
||||
console.log(response.text)
|
||||
```
|
||||
|
||||
@@ -405,14 +418,14 @@ Use `Image.generate` for one-off generation or editing:
|
||||
import { Image, Media } from "@opencode/ai"
|
||||
|
||||
const generation = Image.generate({
|
||||
model: meta("muse-image-1.0"),
|
||||
model: meta.image("muse-image-1.0"),
|
||||
prompt: "A flat black square on a white background.",
|
||||
n: 1,
|
||||
providerOptions: { reasoningStrength: "low" },
|
||||
})
|
||||
|
||||
const edit = Image.generate({
|
||||
model: meta("muse-image-1.0"),
|
||||
model: meta.image("muse-image-1.0"),
|
||||
prompt: "Make the square purple.",
|
||||
images: [Media.bytes(imageBytes, "image/webp")],
|
||||
format: "png",
|
||||
@@ -459,6 +472,25 @@ const program = Effect.gen(function* () {
|
||||
})
|
||||
```
|
||||
|
||||
Common fields are portable in shape, not in support. Unsupported fields fail with a typed `AIError` before any network
|
||||
call rather than being dropped, so check this table before swapping only the `model`:
|
||||
|
||||
| Provider | `n` | `size` | `aspectRatio` | `seed` | `format` | `images` | `mask` |
|
||||
| --------------------- | --- | --------- | ------------- | ------ | -------- | ------------------------- | ------------------- |
|
||||
| OpenAI | ✓¹ | ✓ | ✗ | ✗ | ✓ | ✓ | ✓ |
|
||||
| Google (Gemini) | 1 | ✗ | ✓ | ✓ | ✗ | ✓ (no public URLs) | ✗ |
|
||||
| xAI | ✓ | ✗ | ✓ | ✗ | ✗ | ✓ | ✗ |
|
||||
| Z.ai | ✗ | ✓ | ✗ | ✗ | ✗ | ✗ | ✗ |
|
||||
| Meta | ✓ | ✓ (hint) | ✗ | ✗ | ✓ | ✓ | ✗ |
|
||||
| Black Forest Labs | 1 | per model | per model | ✓ | ✓ | per model (1–8) | `flux-pro-1.0-fill` |
|
||||
| fal | ✓ | per model | per model | ✓ | ✓ | 1 (several on `/edit`) | ✓ |
|
||||
| Replicate | ✗ | ✗ | ✗ | ✗ | ✗ | ✗ (use `providerOptions`) | ✗ |
|
||||
| Stability `image` | 1 | ✗ | ✓ | ✓ | ✓ | 1 (not on `core`) | ✗ |
|
||||
| Stability `upscale()` | ✗ | ✗ | ✗ | ✓ | ✓ | exactly 1 (required) | ✗ |
|
||||
|
||||
✓ lowers natively; ✗ fails whenever the field is set (including `n: 1`); `1` means `n > 1` fails. ¹ `Image.stream` on OpenAI generates one image. fal
|
||||
rejects `size` and `aspectRatio` together; which one a fal or BFL model takes depends on the model.
|
||||
|
||||
`Media.Asset` is the one asset type shared by image requests, image responses, LLM messages, and tool results.
|
||||
`asset.source` is the serializable `Media.Source` (`bytes`, `base64`, `url`, or `ref`); `asset.bytes()`,
|
||||
`asset.base64()`, and `asset.dataUrl()` decode or download lazily and cache; `asset.materialize()` pulls a `url`
|
||||
@@ -468,9 +500,8 @@ asset into owned bytes before the provider URL expires. Construct assets with `M
|
||||
Pass ordered image inputs to the same method for editing, composition, or image-conditioned generation:
|
||||
|
||||
```ts
|
||||
const response =
|
||||
yield *
|
||||
Image.generate({
|
||||
const composed = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model,
|
||||
prompt: "Combine these product photos into one studio scene",
|
||||
images: [
|
||||
@@ -481,23 +512,25 @@ const response =
|
||||
providerOptions,
|
||||
http,
|
||||
})
|
||||
return response.images
|
||||
})
|
||||
```
|
||||
|
||||
`Media.ref(provider, id)` represents provider file handles such as OpenAI file IDs or Gemini Files URIs; routes
|
||||
only forward refs that belong to their own provider. Raw strings are not accepted as image inputs, avoiding
|
||||
ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
|
||||
non-empty array selects the provider's edit behavior without enforcing provider image-count limits locally. OpenAI
|
||||
only forward refs that belong to their own provider (OpenAI, xAI, and Gemini images accept them). No shipped route
|
||||
returns a ref yet, and `asset.bytes()` / `materialize()` on a ref fail by design. Raw strings are not accepted as
|
||||
image inputs, avoiding ambiguity between base64, URLs, and provider IDs. Empty or omitted `images` uses text-to-image generation; a
|
||||
non-empty array selects the provider's edit behavior (see the table above for routes that limit the count). OpenAI
|
||||
uses multipart for byte/data-URL edits and its JSON reference body for URL or file-ID edits. The common `mask`
|
||||
field selects inpainting; routes that cannot honor it fail with `UnsupportedOperation`:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
images: [Media.bytes(sourceBytes, "image/png")],
|
||||
mask: Media.bytes(maskBytes, "image/png"),
|
||||
})
|
||||
const inpainted = Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
images: [Media.bytes(sourceBytes, "image/png")],
|
||||
mask: Media.bytes(maskBytes, "image/png"),
|
||||
})
|
||||
```
|
||||
|
||||
On multipart requests, `http.body` can override option fields but not structural `model`, `prompt`, `image[]`,
|
||||
@@ -508,31 +541,31 @@ not accept image inputs. These cases fail with a typed `AIError` before network
|
||||
Provider-native image options belong to each request. Raw `http.body` fields have final precedence over them:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
providerOptions: { quality: "medium" },
|
||||
http,
|
||||
})
|
||||
const medium = Image.generate({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt,
|
||||
providerOptions: { quality: "medium" },
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
xAI image models use the same request API with xAI-native controls:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: XAI.configure({ apiKey })("any-model-id"),
|
||||
prompt,
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
providerOptions: {
|
||||
resolution: "1k",
|
||||
responseFormat: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
import { XAI } from "@opencode/ai/providers"
|
||||
|
||||
const xai = Image.generate({
|
||||
model: XAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
n: 2,
|
||||
aspectRatio: "16:9",
|
||||
providerOptions: {
|
||||
resolution: "1k",
|
||||
responseFormat: "b64_json",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Google's current Gemini image models use the same direct API:
|
||||
@@ -542,7 +575,7 @@ import { Google } from "@opencode/ai/providers"
|
||||
|
||||
const googleProgram = Effect.gen(function* () {
|
||||
const response = yield* Image.generate({
|
||||
model: Google.configure({ apiKey })("any-model-id"),
|
||||
model: Google.configure({ apiKey }).image("any-model-id"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
aspectRatio: "16:9",
|
||||
seed: 42,
|
||||
@@ -567,17 +600,18 @@ their mapped aliases, and `http.body` is the final deep overlay. The selected mo
|
||||
Z.ai image models infer open Z.ai-native options from the selected model:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.generate({
|
||||
model: ZAI.configure({ apiKey })("any-model-id"),
|
||||
prompt,
|
||||
providerOptions: {
|
||||
quality: "hd",
|
||||
userID: "user-123",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
import { ZAI } from "@opencode/ai/providers"
|
||||
|
||||
const zai = Image.generate({
|
||||
model: ZAI.configure({ apiKey }).image("any-model-id"),
|
||||
prompt,
|
||||
providerOptions: {
|
||||
quality: "hd",
|
||||
userID: "user-123",
|
||||
future_option: true,
|
||||
},
|
||||
http,
|
||||
})
|
||||
```
|
||||
|
||||
Z.ai does not include trustworthy MIME metadata for output URLs, so generated images use
|
||||
@@ -591,12 +625,14 @@ and emits `image-partial` events before each final `image`; `Image.generate` kee
|
||||
`dall-e-*` models do not stream and fail typed:
|
||||
|
||||
```ts
|
||||
yield *
|
||||
Image.stream({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A lighthouse at dusk",
|
||||
providerOptions: { partialImages: 2 },
|
||||
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
|
||||
import { Stream } from "effect"
|
||||
import { ImageEvent } from "@opencode/ai"
|
||||
|
||||
const previews = Image.stream({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A lighthouse at dusk",
|
||||
providerOptions: { partialImages: 2 },
|
||||
}).pipe(Stream.runForEach((event) => (ImageEvent.is.imagePartial(event) ? showPreview(event.image) : Effect.void)))
|
||||
```
|
||||
|
||||
The provider may send fewer previews than requested when the final image is ready first.
|
||||
@@ -612,13 +648,20 @@ import { BlackForestLabs, Stability } from "@opencode/ai/providers"
|
||||
|
||||
const bfl = BlackForestLabs.configure({ apiKey: process.env.BFL_API_KEY })
|
||||
|
||||
const generation = yield * Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
|
||||
persist(generation.token)
|
||||
const submit = Effect.gen(function* () {
|
||||
const generation = yield* Image.start({ model: bfl.image("flux-2-pro"), prompt, size: "1024x768" })
|
||||
persist({ provider: "black-forest-labs", modelID: "flux-2-pro", token: generation.token })
|
||||
})
|
||||
|
||||
const resumed = yield * Image.resume(bfl.image("flux-2-pro"), loadToken())
|
||||
const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
|
||||
const finish = Effect.gen(function* () {
|
||||
const saved = load()
|
||||
const resumed = yield* Image.resume(bfl.image(saved.modelID), saved.token)
|
||||
return yield* resumed.await({ poll: { interval: "2 seconds" } })
|
||||
})
|
||||
```
|
||||
|
||||
The token carries no route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
|
||||
- **Black Forest Labs** — results are downloaded before returning, because `result.sample` expires in 10 minutes.
|
||||
- **Replicate** — inputs are model-defined, so only `prompt` lowers: sizing, count, seed, format, and files go in
|
||||
`providerOptions` under the model's names, with files as `Media.Asset` (data URLs up to 256 KB, larger by URL).
|
||||
@@ -628,12 +671,13 @@ const response = yield * resumed.await({ poll: { interval: "2 seconds" } })
|
||||
|
||||
```ts
|
||||
const stability = Stability.configure({ apiKey: process.env.STABILITY_API_KEY })
|
||||
const upscaled =
|
||||
yield *
|
||||
Image.generate(
|
||||
{ model: stability.upscale(), prompt: "A lighthouse", images: [yield * Media.file("./small.png")] },
|
||||
const upscaled = Effect.gen(function* () {
|
||||
const small = yield* Media.file("./small.png")
|
||||
return yield* Image.generate(
|
||||
{ model: stability.upscale(), prompt: "A lighthouse", images: [small] },
|
||||
{ poll: { interval: "5 seconds" } },
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Imagen is not available: Google shut it down on the Gemini API, and Vertex discontinued the Imagen 4 models on
|
||||
@@ -666,8 +710,8 @@ Common fields (`frames`, `references`, `video`, `durationSeconds`, `aspectRatio`
|
||||
under `providerOptions`, inferred from the selected model.
|
||||
|
||||
```ts
|
||||
import { Video, VideoClient } from "@opencode/ai"
|
||||
import { Google } from "@opencode/ai/providers"
|
||||
import { Video } from "@opencode/ai"
|
||||
import { Google, Runway } from "@opencode/ai/providers"
|
||||
|
||||
const google = Google.configure({ apiKey: process.env.GOOGLE_GENERATIVE_AI_API_KEY })
|
||||
|
||||
@@ -696,6 +740,7 @@ const controlled = Effect.gen(function* () {
|
||||
generation.id // provider operation / task / request id
|
||||
generation.status // "queued" | "running" | "completed" | "failed" | "cancelled" | "expired"
|
||||
generation.token // route-owned JSON: `{ operation }`, `{ requestID }`, `{ taskID }`, or fal's follow-up URLs
|
||||
// The token carries no route identity: persist the provider and model ID alongside it, since `resume` needs the model.
|
||||
const saved = JSON.stringify(generation.token)
|
||||
|
||||
const resumed = yield* Video.resume(google.video("veo-3.1-generate-preview"), JSON.parse(saved))
|
||||
@@ -706,8 +751,8 @@ const controlled = Effect.gen(function* () {
|
||||
const events = Video.stream({ model: Runway.configure({ apiKey }).video("gen4.5"), prompt }, { poll })
|
||||
```
|
||||
|
||||
`VideoClient.layer` needs `RequestExecutor.Service`, and status polls, result fetches, cancels, and asset downloads
|
||||
all run through the same executor with the route's auth. `Generation.await` and `Generation.events` fail with a
|
||||
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. 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
|
||||
@@ -726,15 +771,18 @@ Provider notes:
|
||||
- **Runway** expects pixel ratios in `aspectRatio` for most models (`"1280:720"`), pins `X-Runway-Version`, reports
|
||||
`usage: { type: "credits" }`, and its output URLs expire after 24–48 hours.
|
||||
|
||||
The promise client exposes the same surface: `ai.video.start(...)` resolves to a handle with `await`, `refresh`,
|
||||
`cancel`, and `token`; `ai.video.generate`, `ai.video.resume(model, token)`, and `ai.video.stream` mirror the Effect
|
||||
API.
|
||||
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.
|
||||
|
||||
```ts
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
|
||||
const generation = await ai.video.start({ model, prompt })
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
for await (const event of generation.events({ poll: { interval: 10_000 } })) console.log(event.type)
|
||||
const video = await generation.result({ signal })
|
||||
await ai.write(video.video, "./kite.mp4")
|
||||
```
|
||||
|
||||
## Speech generation
|
||||
@@ -786,7 +834,6 @@ ElevenLabs and Cartesia. `{ id }` selects an OpenAI custom voice (`{ id: "voice_
|
||||
plain string elsewhere. There is no cross-provider voice catalog. `format` is the container-level word (`mp3`, `wav`,
|
||||
`pcm`, `opus`, `aac`, `flac`); sample rates and bitrates live under `providerOptions`, and a value the route cannot
|
||||
produce fails as `UnsupportedOperation`. Streams buffer every chunk so `finish` can carry the whole clip.
|
||||
`SpeechClient.layer` needs `RequestExecutor.Service`.
|
||||
|
||||
Provider notes:
|
||||
|
||||
@@ -814,7 +861,7 @@ The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterab
|
||||
import { ai } from "@opencode/ai/promise"
|
||||
|
||||
const response = await ai.speech.generate({ model, text: "Hello from OpenCode.", voice: "coral" })
|
||||
await Bun.write("hello.mp3", await ai.run(response.audio.bytes()))
|
||||
await ai.write(response.audio, "hello.mp3")
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode.", voice: "coral" })) {
|
||||
if (event.type === "audio-delta") player.write(event.chunk)
|
||||
@@ -831,6 +878,7 @@ facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" |
|
||||
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"
|
||||
import { Media, Transcription, TranscriptionEvent } from "@opencode/ai"
|
||||
import { AssemblyAI, Deepgram, OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
@@ -857,7 +905,7 @@ const program = Effect.gen(function* () {
|
||||
Stream.runDrain,
|
||||
)
|
||||
|
||||
// Queued: persist the token, resume from another process, and await.
|
||||
// Queued: persist the token with the provider and model ID (the token alone cannot pick the model), resume, and await.
|
||||
const model = AssemblyAI.configure({ apiKey }).transcription("universal-3-5-pro")
|
||||
const generation = yield* Transcription.start({ model, audio })
|
||||
const resumed = yield* Transcription.resume(model, JSON.parse(JSON.stringify(generation.token)))
|
||||
@@ -866,7 +914,7 @@ const program = Effect.gen(function* () {
|
||||
```
|
||||
|
||||
Inline routes emit only `finish` from `stream` (no faked deltas); queued routes emit `generation-queued` /
|
||||
`generation-progress` before it. `TranscriptionClient.layer` needs `RequestExecutor.Service`.
|
||||
`generation-progress` before it.
|
||||
|
||||
Provider notes:
|
||||
|
||||
@@ -878,6 +926,7 @@ Provider notes:
|
||||
The promise client mirrors the Effect API:
|
||||
|
||||
```ts
|
||||
const audio = await ai.file("./call.mp3")
|
||||
const text = (await ai.transcription.generate({ model, audio })).text
|
||||
for await (const event of ai.transcription.stream({ model, audio })) if (event.type === "text-delta") write(event.delta)
|
||||
const generation = await ai.transcription.start({ model: assemblyai, audio })
|
||||
@@ -897,7 +946,8 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
|
||||
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
|
||||
- **`Speech.request` / `Speech.generate` / `Speech.stream`** — text-to-speech through a provider-neutral request; `SpeechClient` is its Effect service and layer.
|
||||
- **`Transcription.request` / `generate` / `stream` / `start` / `resume`** — speech-to-text over inline, streaming, and queued routes; `TranscriptionClient` is its Effect service and layer.
|
||||
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs.
|
||||
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
|
||||
- **`@opencode/ai/promise`** — `AI.make({ layer? })` and a default `ai` client exposing `llm`, `image`, `video`, `speech`, and `transcription` as Promise / `AsyncIterable` APIs, plus `file`, `write`, `bytes`, `base64`, and `materialize` for assets.
|
||||
|
||||
## Testing
|
||||
|
||||
@@ -960,11 +1010,13 @@ This is different from prompt caching, server-side history storage, or truncatio
|
||||
Prefer this operation, where supported, when the application owns compaction policy and durable context updates.
|
||||
|
||||
```ts
|
||||
const result = yield * LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
const compacted = Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: result.replacement,
|
||||
})
|
||||
return yield* LLMClient.generate(next)
|
||||
})
|
||||
const response = yield * LLMClient.generate(next)
|
||||
```
|
||||
|
||||
`replacement` replaces the complete input window. Do not append it to the original transcript or extract only the encrypted item: the provider may retain additional messages in its output. Retained user and assistant messages remain ordinary messages with typed text, media, or reasoning parts, in their original order. Provider-specific message IDs, status, and phase use `providerMetadata`, not a raw output array hidden in an assistant message. Unsupported returned item types fail explicitly.
|
||||
@@ -980,16 +1032,16 @@ The input must still fit the model's context window. Explicit compaction is not
|
||||
OpenAI Responses also exposes a separate, explicitly selected mechanism:
|
||||
|
||||
```ts
|
||||
const result =
|
||||
yield *
|
||||
LLMClient.compact(request, {
|
||||
const checkpoint = Effect.gen(function* () {
|
||||
const result = yield* LLMClient.compact(request, {
|
||||
mechanism: "trigger",
|
||||
webSocket, // Optional: without it, the request uses HTTP/SSE.
|
||||
})
|
||||
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
result.checkpoint // Successful encrypted CompactionPart.
|
||||
result.responseID
|
||||
result.usage
|
||||
})
|
||||
```
|
||||
|
||||
This appends a native `compaction_trigger` control item to the full input and sends a normal Responses request, with tools and instructions retained, `stream: true`, `store: false`, and parallel tool calls enabled. It removes normal-answer text/output-format controls, forced tool choices, output-token/tool-call limits, and automatic `context_management`. Body overlays cannot replace `input` or supply `previous_response_id`/`conversation`; the complete canonical history is required for safe stateless replay. Request metadata, auth, headers, query parameters, service tier, and supported prompt-cache settings are preserved.
|
||||
@@ -1003,9 +1055,11 @@ The supplied WebSocket executor can reuse a compatible append baseline for the c
|
||||
Trigger support is separate from endpoint support. Only the OpenAI Responses route advertises it; Azure, xAI, Chat, and compatible Responses routes do not inherit it. Untyped calls still fail before sending: missing route capabilities return `UnsupportedOperation`, while unknown mechanism names and invalid inputs return `InvalidRequest`. Dynamic callers must narrow for the selected mechanism:
|
||||
|
||||
```ts
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield * LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
const narrowed = Effect.gen(function* () {
|
||||
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
|
||||
const result = yield* LLMClient.compact(request, { mechanism: "trigger" })
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
This capability describes protocol implementation, **not universal availability on OpenAI API deployments**. The host application owns subscription/deployment eligibility, OAuth, endpoint selection, and deployment-specific headers. Local protocol/socket tests do not establish live provider support.
|
||||
@@ -1014,9 +1068,10 @@ This capability describes protocol implementation, **not universal availability
|
||||
|
||||
`providerOptions.contextManagement` lets the provider decide when to compact during an ordinary `generate` or `stream` call. This is an advanced option for callers that own persistence and recovery: persist the complete assistant message, including its checkpoint, before continuing. Enabling the option does not provide durable checkpoint storage, interruption recovery, or model-switch policy. Keep the prior context until a successful checkpoint has been persisted.
|
||||
|
||||
Inside an `Effect.gen`, enable OpenAI compaction with typed provider options:
|
||||
Enable OpenAI compaction with typed provider options:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMClient, LLMRequest, Message } from "@opencode/ai"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
@@ -1027,9 +1082,11 @@ const request = LLM.request({
|
||||
contextManagement: [{ type: "compaction", compactThreshold: 200_000 }],
|
||||
},
|
||||
})
|
||||
const response = yield * LLMClient.generate(request)
|
||||
const next = LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
const continued = Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request)
|
||||
return LLMRequest.update(request, {
|
||||
messages: [...request.messages, response.message, Message.user("Continue")],
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
@@ -1257,7 +1314,7 @@ Compose a route with `Route.make({ protocol, endpoint, auth, framing, ... })`. T
|
||||
|
||||
## Effect
|
||||
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `LLMClient.layer` for LLM dispatch and `ImageClient.layer` for image dispatch, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
This package is built on Effect. Public methods return `Effect` or `Stream`; provide `AIClient.layer` (or `AIClient.layerWith(executor)`) for every modality, then import the provider/protocol modules for the routes you use. The example at `example/tutorial.ts` is a runnable walkthrough.
|
||||
|
||||
## See also
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–3 implemented (Speech and Transcription); phases 4–5 proposal.
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -54,7 +54,7 @@ Speech.request({ model: openai.speech("gpt-4o-mini-tts"), text })
|
||||
Transcription.request({ model: openai.transcription("gpt-4o-transcribe"), audio })
|
||||
```
|
||||
|
||||
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). Provider package entrypoints keep `model(modelID, settings)` per modality-specific path, e.g. `@opencode/ai/providers/openai/responses`.
|
||||
The request namespace and the selector share one word (`Image.request` + `.image(...)`). That redundancy is accepted: a callable facade returning a lazily resolved ref would be a second way to construct the same model, and the type machinery to infer `providerOptions` through it is not worth one word. Where a provider has two routes for one modality, the selectors stay explicit (`openai.chat`, `stability.image` inline vs `stability.upscale()` queued), and one default per modality per provider is part of the facade definition (OpenAI image → Images API, Google image → Gemini-native; Imagen is shut down, so there is no `google.imagen`). The facade selector (`openai.image(id)`) is the public path for media models. Modality-specific package entrypoints (`model(modelID, settings)` beside today's LLM paths such as `@opencode/ai/providers/openai/responses`) are deferred until Core has a modality-aware model resolver; Core's resolver accepts only `LanguageModel` today.
|
||||
|
||||
### `Media` — the asset type
|
||||
|
||||
@@ -86,10 +86,18 @@ class Media.Asset {
|
||||
|
||||
Media.bytes(data, mediaType?) Media.base64(data, mediaType?)
|
||||
Media.url(url, options?) Media.ref(provider, id)
|
||||
Media.file(path) // Bun/Node: reads + sniffs; Effect FileSystem variant for layers
|
||||
Media.write(asset, path) // convenience, uses FileSystem
|
||||
Media.file(path) // Effect<Asset, AIError, FileSystem>: reads + sniffs
|
||||
Media.write(asset, path) // Effect<void, AIError, FileSystem | RequestExecutor.Service>
|
||||
```
|
||||
|
||||
`Media.file` and `Media.write` stay Effect-only: bring your platform's `FileSystem` layer. The Promise client owns the
|
||||
runtime path: `ai.file(path)` and `ai.write(asset, path)` read and write through `node:fs/promises` (loaded on first
|
||||
use) with the same media-type sniffing and `InvalidRequest` failures, and `ai.bytes`, `ai.base64`, and
|
||||
`ai.materialize` run the asset methods in its runtime.
|
||||
|
||||
A `ref` source is accepted as input only by routes whose provider issues file handles. No shipped route produces one
|
||||
yet, so `bytes()` and `materialize()` on a ref fail by design until a producer exists.
|
||||
|
||||
Raw-PCM outputs (Gemini TTS, Cartesia raw, Deepgram WS) carry `info.encoding/sampleRate/channels` because there is no container header.
|
||||
|
||||
### Modality namespaces
|
||||
@@ -104,28 +112,32 @@ import { OpenAI, Google, ElevenLabs, Fal } from "@opencode/ai/providers"
|
||||
#### Image
|
||||
|
||||
```ts
|
||||
const request = Image.request({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
images: [Media.file("./ref.png")], // references / edit sources
|
||||
mask: Media.file("./mask.png"),
|
||||
n: 2,
|
||||
size: "1536x1024", // or aspectRatio: "3:2"
|
||||
seed: 7,
|
||||
format: "webp",
|
||||
providerOptions: { quality: "high", background: "transparent" }, // typed per model
|
||||
Effect.gen(function* () {
|
||||
const request = Image.request({
|
||||
model: openai.image("gpt-image-2"),
|
||||
prompt: "A robot tending a rooftop garden",
|
||||
images: [yield* Media.file("./ref.png")], // references / edit sources
|
||||
mask: yield* Media.file("./mask.png"),
|
||||
n: 2,
|
||||
size: "1536x1024", // OpenAI sizes by pixels; Gemini/xAI take aspectRatio instead
|
||||
format: "webp",
|
||||
providerOptions: { quality: "high", background: "transparent" }, // typed per model
|
||||
})
|
||||
|
||||
const response = yield* Image.generate(request) // ImageResponse
|
||||
response.image // Media.Asset (first)
|
||||
response.images // Media.Asset[]
|
||||
response.usage // Usage union (see below)
|
||||
response.notices // moderation / partial-result notices
|
||||
|
||||
Image.stream(request) // Stream<ImageEvent>
|
||||
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
|
||||
})
|
||||
|
||||
const response = yield* Image.generate(request) // ImageResponse
|
||||
response.image // Media.Asset (first)
|
||||
response.images // Media.Asset[]
|
||||
response.usage // Usage union (see below)
|
||||
response.notices // moderation / partial-result notices
|
||||
|
||||
yield* Image.stream(request) // Stream<ImageEvent>
|
||||
// ImageEvent: generation-queued | generation-progress | image-partial { index, image } | image { index, image } | finish { usage }
|
||||
```
|
||||
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
|
||||
portability matrix.
|
||||
|
||||
Editing is not a separate function; `images`/`mask` on the request select the edit path in the route (OpenAI `/images/edits`, Gemini multimodal parts, xAI `/images/edits`). Routes that cannot honor `mask` fail with `Unsupported`.
|
||||
|
||||
`ImageRoute` is the inline | stream | queued union, dispatched on `route.kind`. `Image.stream` on a streaming route emits `image-partial` previews before each `image`; on a queued route it emits `generation-queued` / `generation-progress` observations, then the result's `image` and `finish` events.
|
||||
@@ -135,40 +147,43 @@ Editing is not a separate function; `images`/`mask` on the request select the ed
|
||||
Shipped in phase 2 (`src/video.ts`, `src/video-client.ts`, protocols `google-video`, `xai-video`, `fal-video`, `runway-video`).
|
||||
|
||||
```ts
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: Media.file("./start.png"), last: Media.file("./end.png") },
|
||||
references: [Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
Effect.gen(function* () {
|
||||
const request = Video.request({
|
||||
model: google.video("veo-3.1-generate-preview"),
|
||||
prompt: "Panning wide shot of a calico kitten sleeping in the sunshine",
|
||||
frames: { first: yield* Media.file("./start.png"), last: yield* Media.file("./end.png") },
|
||||
references: [yield* Media.file("./style.png")],
|
||||
video: Media.bytes(previous, "video/mp4"), // edit / extend source
|
||||
durationSeconds: 8,
|
||||
aspectRatio: "16:9",
|
||||
resolution: "1080p",
|
||||
audio: true,
|
||||
n: 1,
|
||||
seed: 7,
|
||||
negativePrompt: "text, watermark", // common, not provider-native
|
||||
providerOptions: { personGeneration: "allow_adult" },
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once. It carries no
|
||||
// route identity, so persist the provider and model ID alongside it: `resume` needs the model.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
})
|
||||
|
||||
// Simple: wait for it.
|
||||
const response = yield* Video.generate(request, { poll: { interval: "10 seconds", timeout: "10 minutes" } })
|
||||
response.video // Media.Asset: url with expiresAt (+ transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
// Explicit generation control.
|
||||
const generation = yield* Video.start(request) // Generation<VideoResponse>
|
||||
generation.id; generation.status; generation.progress; generation.position; generation.token
|
||||
yield* generation.await({ poll }) // VideoResponse
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; no-op for Veo and xAI
|
||||
|
||||
// Resume from another process. The token is validated against the route's codec and refreshed once.
|
||||
const resumed = yield* Video.resume(model, JSON.parse(saved))
|
||||
|
||||
// Progress as a stream.
|
||||
yield* Video.stream(request, { poll }) // Stream<VideoEvent>: generation-queued { id, position } | generation-progress { id, progress } | video { index, video } | finish { usage, notices }
|
||||
```
|
||||
|
||||
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
|
||||
@@ -330,12 +345,12 @@ class Generation<Response> {
|
||||
readonly expiresAt?: number
|
||||
refresh(): Effect<Generation<Response>, AIError>
|
||||
result(): Effect<Response, AIError>
|
||||
await(options?: AwaitOptions): Effect<Response, AIError>
|
||||
await(options?: GenerationAwaitOptions): Effect<Response, AIError>
|
||||
cancel(): Effect<void, AIError>
|
||||
events(options?: AwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
events(options?: GenerationAwaitOptions): Stream<GenerationEvent, AIError> // fails with Timeout past poll.timeout, checked per observation
|
||||
}
|
||||
|
||||
AwaitOptions = { poll?: Poll }
|
||||
GenerationAwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration; schedule?: Schedule } // route may override from provider hints (`openai-poll-after-ms`)
|
||||
```
|
||||
|
||||
@@ -365,15 +380,17 @@ const ai = AI.make() // ManagedRuntime over Reque
|
||||
// AI.make({ layer }) to inject a custom executor / recorder / middleware
|
||||
|
||||
const image = await ai.image.generate({ model, prompt })
|
||||
await image.image.bytes()
|
||||
await ai.bytes(image.image) // also ai.base64, ai.materialize, ai.write(asset, path)
|
||||
const reference = await ai.file("./ref.png")
|
||||
|
||||
for await (const event of ai.speech.stream({ model, text, voice })) { … }
|
||||
|
||||
const generation = await ai.video.start({ model, prompt })
|
||||
const generation = await ai.video.start({ model, prompt }) // snapshot handle; refresh() returns a new one
|
||||
for await (const event of generation.events({ poll: { interval: 10_000 } })) { … }
|
||||
const video = await generation.await({ poll: { interval: 10_000 }, signal })
|
||||
const resumed = ai.video.resume(model, JSON.parse(saved))
|
||||
const resumed = await ai.video.resume(model, JSON.parse(saved)) // persist provider + model ID with the token
|
||||
|
||||
const text = await ai.llm.generate({ model, prompt }) // closes today's gap: LLM has no promise API either
|
||||
const text = await ai.llm.generate({ model, prompt })
|
||||
for await (const event of ai.llm.stream(request)) { … }
|
||||
|
||||
await ai.dispose()
|
||||
@@ -401,9 +418,9 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
|
||||
| `Runway` | | | ✓ | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
|
||||
|
||||
New facades follow the existing one-file-per-provider rule. Package entrypoints are modality-specific, such as `@opencode/ai/providers/openai/images`, and return the concrete model.
|
||||
New facades follow the existing one-file-per-provider rule. The facade selector is the public path for media models; modality-specific package entrypoints (for example `@opencode/ai/providers/openai/images`) are deferred until Core has a modality-aware model resolver.
|
||||
|
||||
`ImageModel<Options>` already gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, `TranscriptionModel` follow the same generic. A shared `MediaModel` union is what `Generation` and the promise client key on.
|
||||
`ImageModel<Options>` gives typed `providerOptions` per model; `VideoModel`, `SpeechModel`, and `TranscriptionModel` follow the same generic. They share an internal `MediaModel` base class (ids, route, `http` overlays) that is not part of the public exports; `Generation` and the promise client work with the concrete modality models.
|
||||
|
||||
### Routes and protocols
|
||||
|
||||
|
||||
@@ -1,18 +1,7 @@
|
||||
import { Config, Effect, Formatter, Layer, Schema, Stream } from "effect"
|
||||
import { Config, Effect, Formatter, Schema, Stream } from "effect"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import {
|
||||
Image,
|
||||
ImageClient,
|
||||
LLM,
|
||||
LLMClient,
|
||||
LLMRequest,
|
||||
Media,
|
||||
Message,
|
||||
ProviderID,
|
||||
Tool,
|
||||
ToolRuntime,
|
||||
} from "@opencode/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol, RequestExecutor } from "@opencode/ai/route"
|
||||
import { AIClient, Image, LLM, LLMRequest, Media, Message, ProviderID, Tool, ToolRuntime } from "@opencode/ai"
|
||||
import { Route, Auth, Endpoint, Framing, Protocol } from "@opencode/ai/route"
|
||||
import { OpenAI } from "@opencode/ai/providers"
|
||||
|
||||
/**
|
||||
@@ -243,12 +232,10 @@ const generateImage = Effect.gen(function* () {
|
||||
yield* Media.write(response.image, "tutorial-image.jpg").pipe(Effect.provide(NodeFileSystem.layer))
|
||||
})
|
||||
|
||||
// Provide the LLM runtime and the HTTP request executor once. Keep one path
|
||||
// enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// Provide every modality client and the HTTP request executor once with
|
||||
// `AIClient.layer` (`AIClient.layerWith(executor)` swaps the executor). Keep one
|
||||
// path enabled at a time so the tutorial can demonstrate generate, stream, or
|
||||
// tool-loop behavior without spending tokens on every example.
|
||||
const requestExecutorLayer = RequestExecutor.fetchLayer
|
||||
const llmClientLayer = LLMClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
const imageClientLayer = ImageClient.layer.pipe(Layer.provide(requestExecutorLayer))
|
||||
|
||||
const program = Effect.gen(function* () {
|
||||
// yield* generateOnce
|
||||
@@ -257,6 +244,6 @@ const program = Effect.gen(function* () {
|
||||
// yield* generateDynamicObject.pipe(Effect.andThen((response) => Effect.sync(() => console.log(response.object))))
|
||||
// yield* generateImage
|
||||
yield* streamWithTools
|
||||
}).pipe(Effect.provide(Layer.mergeAll(requestExecutorLayer, llmClientLayer, imageClientLayer)))
|
||||
}).pipe(Effect.provide(AIClient.layer))
|
||||
|
||||
Effect.runPromise(program)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Layer } from "effect"
|
||||
import { ImageClient } from "./image-client.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { SpeechClient } from "./speech-client.js"
|
||||
import { TranscriptionClient } from "./transcription-client.js"
|
||||
import { VideoClient } from "./video-client.js"
|
||||
|
||||
/** Every modality client over `executor`, which stays in the output so `asset.bytes()` and `Media.write` resolve. */
|
||||
export const layerWith = <E, R>(executor: Layer.Layer<RequestExecutor.Service, E, R>) =>
|
||||
Layer.mergeAll(
|
||||
LLMClient.layer,
|
||||
ImageClient.layer,
|
||||
VideoClient.layer,
|
||||
SpeechClient.layer,
|
||||
TranscriptionClient.layer,
|
||||
).pipe(Layer.provideMerge(executor))
|
||||
|
||||
/** Every modality client plus the executor over `RequestExecutor.fetchLayer`: the one layer most programs need. */
|
||||
export const layer = layerWith(RequestExecutor.fetchLayer)
|
||||
|
||||
export type Services = Layer.Success<typeof layer>
|
||||
|
||||
export * as AIClient from "./ai-client.js"
|
||||
@@ -86,7 +86,7 @@ export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
export const fetchLayer = layer.pipe(Layer.provideMerge(RequestExecutor.fetchLayer))
|
||||
|
||||
export const EvaluationClient = {
|
||||
Service,
|
||||
|
||||
@@ -30,7 +30,9 @@ export interface Interface {
|
||||
) => Effect.Effect<Generation<ImageResponse>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ImageClient") {}
|
||||
export class ImageClientService extends Context.Service<ImageClientService, Interface>()("@opencode/ImageClient") {}
|
||||
export const Service = ImageClientService
|
||||
export type Service = ImageClientService
|
||||
|
||||
export const generate = <Options extends ImageOptions>(
|
||||
request: ImageRequestFor<Options>,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export { AIClient } from "./ai-client.js"
|
||||
export { LLMClient } from "./route/client.js"
|
||||
export { ImageClient } from "./image-client.js"
|
||||
export { Auth } from "./route/auth.js"
|
||||
@@ -8,7 +9,7 @@ export type {
|
||||
RouteLanguageModelInput,
|
||||
RouteRoutedLanguageModelInput,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
LLMClientService,
|
||||
} from "./route/client.js"
|
||||
export * from "./schema/index.js"
|
||||
export {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProviderID } from "./schema/ids.js"
|
||||
import { AIError, HttpContext, InvalidProviderOutputError, InvalidRequestError } from "./schema/errors.js"
|
||||
import { ProviderMetadata } from "./schema/options.js"
|
||||
import { Service } from "./route/executor-service.js"
|
||||
import { detectMediaType, extensionMediaType } from "./utils/media-type.js"
|
||||
import { detectMediaType, fileMediaType } from "./utils/media-type.js"
|
||||
|
||||
export { detectMediaType } from "./utils/media-type.js"
|
||||
|
||||
@@ -309,7 +309,7 @@ export const file = (path: string, options?: AssetOptions): Effect.Effect<Asset,
|
||||
const data = yield* fs
|
||||
.readFile(path)
|
||||
.pipe(Effect.mapError((cause) => invalid(`Failed to read media file ${path}`, cause)))
|
||||
return bytes(data, detectMediaType(data) ?? extensionMediaType(path), options)
|
||||
return bytes(data, fileMediaType(data, path), options)
|
||||
})
|
||||
|
||||
/** Materialize an asset and write its bytes through `FileSystem`. */
|
||||
|
||||
+64
-30
@@ -1,14 +1,14 @@
|
||||
import { Effect, Layer, ManagedRuntime, Stream } from "effect"
|
||||
import type { AwaitOptions, Generation, Snapshot } from "./generation.js"
|
||||
import { AIClient } from "./ai-client.js"
|
||||
import type { AwaitOptions, Event, Generation, Snapshot } from "./generation.js"
|
||||
import { Image, ImageModel, ImageRequest, type ImageOptions, type ImageRequestInput } from "./image.js"
|
||||
import { ImageClient } from "./image-client.js"
|
||||
import { LLM } from "./index.js"
|
||||
import { LLMClient } from "./route/client.js"
|
||||
import { Media } from "./media.js"
|
||||
import { tryRequest } from "./media-model.js"
|
||||
import { RequestExecutor } from "./route/executor.js"
|
||||
import { LanguageModel, LLMRequest } from "./schema/index.js"
|
||||
import { AIError, InvalidRequestError, LanguageModel, LLMRequest } from "./schema/index.js"
|
||||
import type { RequestInput } from "./llm.js"
|
||||
import { Speech, SpeechModel, SpeechRequest, type SpeechRequestInput } from "./speech.js"
|
||||
import { SpeechClient } from "./speech-client.js"
|
||||
import {
|
||||
Transcription,
|
||||
TranscriptionModel,
|
||||
@@ -16,17 +16,16 @@ import {
|
||||
type TranscriptionOptions,
|
||||
type TranscriptionRequestInput,
|
||||
} from "./transcription.js"
|
||||
import { TranscriptionClient } from "./transcription-client.js"
|
||||
import { fileMediaType } from "./utils/media-type.js"
|
||||
import { Video, VideoModel, VideoRequest, type VideoOptions, type VideoRequestInput } from "./video.js"
|
||||
import { VideoClient } from "./video-client.js"
|
||||
|
||||
/**
|
||||
* Promise-first entrypoint for scripts and non-Effect callers. One `ManagedRuntime` hosts the LLM, image, video, speech,
|
||||
* and transcription clients over a request executor; every method runs the corresponding Effect API and rethrows
|
||||
* `AIError` unchanged.
|
||||
* `AIError` unchanged. `file` and `write` load `node:fs/promises` on first use, so importing this module does not.
|
||||
*/
|
||||
export interface Options {
|
||||
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or middleware here. */
|
||||
/** Executor layer; defaults to `RequestExecutor.fetchLayer`. Inject a recorder or `RequestExecutor.middleware(fn)` here. */
|
||||
readonly layer?: Layer.Layer<RequestExecutor.Service>
|
||||
}
|
||||
|
||||
@@ -34,19 +33,20 @@ export interface RunOptions {
|
||||
readonly signal?: AbortSignal
|
||||
}
|
||||
|
||||
export type Services =
|
||||
| Layer.Success<typeof LLMClient.layer>
|
||||
| Layer.Success<typeof ImageClient.layer>
|
||||
| Layer.Success<typeof VideoClient.layer>
|
||||
| Layer.Success<typeof SpeechClient.layer>
|
||||
| Layer.Success<typeof TranscriptionClient.layer>
|
||||
| RequestExecutor.Service
|
||||
export type Services = AIClient.Services
|
||||
|
||||
/** Promise view of a `Generation`: its snapshot plus `await`, `refresh`, and `cancel` returning promises. */
|
||||
/**
|
||||
* Promise view of a `Generation`. Its fields are a snapshot taken when the handle was created; `refresh()` resolves to a
|
||||
* new handle rather than updating this one.
|
||||
*/
|
||||
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 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>
|
||||
readonly refresh: (options?: RunOptions) => Promise<GenerationHandle<Response>>
|
||||
readonly cancel: (options?: RunOptions) => Promise<void>
|
||||
}
|
||||
@@ -65,17 +65,9 @@ const abortEffect = (signal: AbortSignal | undefined) =>
|
||||
})
|
||||
|
||||
export const make = (options: Options = {}) => {
|
||||
const runtime = ManagedRuntime.make(
|
||||
Layer.mergeAll(
|
||||
LLMClient.layer,
|
||||
ImageClient.layer,
|
||||
VideoClient.layer,
|
||||
SpeechClient.layer,
|
||||
TranscriptionClient.layer,
|
||||
).pipe(Layer.provideMerge(options.layer ?? RequestExecutor.fetchLayer)),
|
||||
)
|
||||
const runtime = ManagedRuntime.make(AIClient.layerWith(options.layer ?? RequestExecutor.fetchLayer))
|
||||
|
||||
/** Run any package Effect (for example `asset.bytes()`) inside this runtime. */
|
||||
/** 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, { signal: options?.signal })
|
||||
|
||||
@@ -95,12 +87,15 @@ export const make = (options: Options = {}) => {
|
||||
...generation.snapshot,
|
||||
token: generation.token,
|
||||
await: (options) => run(generation.await({ poll: options?.poll }), options),
|
||||
events: (options) => iterate(generation.events({ poll: options?.poll }), options),
|
||||
result: (options) => run(generation.result(), options),
|
||||
refresh: (options) => run(generation.refresh(), options).then(handle),
|
||||
cancel: (options) => run(generation.cancel(), options),
|
||||
})
|
||||
|
||||
// The typed `generate`/`stream` overloads take a concrete input or a request, not the union; normalize once here.
|
||||
const llmRequest = (input: RequestInput | LLMRequest) => (input instanceof LLMRequest ? input : LLM.request(input))
|
||||
const llmRequest = (input: RequestInput | LLMRequest) =>
|
||||
input instanceof LLMRequest ? Effect.succeed(input) : tryRequest(() => LLM.request(input))
|
||||
const imageRequest = (input: ImageRequestInput | ImageRequest) =>
|
||||
input instanceof ImageRequest ? input : Image.request(input)
|
||||
const videoRequest = (input: VideoRequestInput | VideoRequest) =>
|
||||
@@ -112,12 +107,48 @@ export const make = (options: Options = {}) => {
|
||||
|
||||
return {
|
||||
run,
|
||||
/** Decoded asset bytes, downloading `url` sources through the executor. */
|
||||
bytes: (asset: Media.Asset, options?: RunOptions) => run(asset.bytes(), options),
|
||||
base64: (asset: Media.Asset, options?: RunOptions) => run(asset.base64(), options),
|
||||
/** Pull a `url` asset into owned bytes before the provider URL expires. */
|
||||
materialize: (asset: Media.Asset, options?: RunOptions) => run(asset.materialize(), options),
|
||||
/** Read a file into an asset like `Media.file`: sniffed media type, then the extension's. */
|
||||
file: async (path: string, options?: Media.AssetOptions & RunOptions) => {
|
||||
const { readFile } = await import("node:fs/promises")
|
||||
return run(
|
||||
Effect.tryPromise({
|
||||
try: (signal) => readFile(path, { signal }),
|
||||
catch: (cause) => fileError(`Failed to read media file ${path}`, cause),
|
||||
}).pipe(
|
||||
Effect.map((buffer) => {
|
||||
const data = new Uint8Array(buffer)
|
||||
return Media.bytes(data, fileMediaType(data, path), options)
|
||||
}),
|
||||
),
|
||||
options,
|
||||
)
|
||||
},
|
||||
/** Write an asset's bytes like `Media.write`, downloading `url` sources through the executor. */
|
||||
write: async (asset: Media.Asset, path: string, options?: RunOptions) => {
|
||||
const { writeFile } = await import("node:fs/promises")
|
||||
return run(
|
||||
asset.bytes().pipe(
|
||||
Effect.flatMap((data) =>
|
||||
Effect.tryPromise({
|
||||
try: (signal) => writeFile(path, data, { signal }),
|
||||
catch: (cause) => fileError(`Failed to write media file ${path}`, cause),
|
||||
}),
|
||||
),
|
||||
),
|
||||
options,
|
||||
)
|
||||
},
|
||||
llm: {
|
||||
request: LLM.request,
|
||||
generate: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
run(LLM.generate(llmRequest(input)), options),
|
||||
run(Effect.flatMap(llmRequest(input), LLM.generate), options),
|
||||
stream: <const Model extends LanguageModel>(input: RequestInput<Model> | LLMRequest, options?: RunOptions) =>
|
||||
iterate(LLM.stream(llmRequest(input)), options),
|
||||
iterate(Stream.unwrap(Effect.map(llmRequest(input), LLM.stream)), options),
|
||||
},
|
||||
image: {
|
||||
request: Image.request,
|
||||
@@ -186,6 +217,9 @@ export const make = (options: Options = {}) => {
|
||||
|
||||
export type Client = ReturnType<typeof make>
|
||||
|
||||
const fileError = (message: string, cause: unknown) =>
|
||||
new AIError({ reason: new InvalidRequestError({ message, cause }) })
|
||||
|
||||
/** Default client over `RequestExecutor.fetchLayer` for scripts; the runtime builds its layer on first use. */
|
||||
export const ai = make()
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ const PCM_SAMPLE_RATE = 24000
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type OpenAISpeechOptions = Record<string, unknown>
|
||||
/** `voice`, `instructions`, `speed`, and `format` are common request fields; other native body fields pass through. */
|
||||
export type OpenAISpeechOptions = {
|
||||
/** Defaults to `"sse"` in `stream` mode on models that support it; the merged value selects the response framing. */
|
||||
readonly stream_format?: "sse" | "audio"
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<OpenAISpeechOptions>
|
||||
|
||||
|
||||
@@ -211,7 +211,8 @@ export const mediaReference = (
|
||||
if (provider !== undefined && asset.source.type === "ref" && asset.source.provider === provider)
|
||||
return Effect.succeed({ type: "ref", value: asset.source.id })
|
||||
const accepted = provider === undefined ? "" : `, and ${provider} references`
|
||||
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}`))
|
||||
const got = asset.source.type === "ref" ? `; got ${asset.source.provider}:${asset.source.id}` : ""
|
||||
return Effect.fail(invalidRequest(`${label} accepts inline bytes, data URLs, http(s) URLs${accepted}${got}`))
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Config, Effect, Redacted } from "effect"
|
||||
import { Config, Effect, Option, Redacted } from "effect"
|
||||
import { Headers } from "effect/unstable/http"
|
||||
import { AuthenticationError, InvalidRequestError, AIError, type HttpOptions } from "../schema/index.js"
|
||||
import { AuthenticationError, AIError, type HttpOptions } from "../schema/index.js"
|
||||
|
||||
export class MissingCredentialError extends Error {
|
||||
readonly _tag = "MissingCredentialError"
|
||||
|
||||
constructor(readonly source: string) {
|
||||
super(`Missing auth credential: ${source}`)
|
||||
constructor(
|
||||
readonly source: string,
|
||||
message = `Missing auth credential: ${source}`,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +92,14 @@ export const optional = (secret: Secret | undefined, source = "optional value")
|
||||
? credential(Effect.fail(new MissingCredentialError(source)))
|
||||
: credentialFromSecret(secret, source)
|
||||
|
||||
export const config = (name: string) => credentialFromSecret(Config.redacted(name), name)
|
||||
export const config = (name: string) =>
|
||||
credential(
|
||||
Effect.gen(function* () {
|
||||
const secret = yield* Config.option(Config.redacted(name))
|
||||
if (Option.isSome(secret) && Redacted.value(secret.value) !== "") return secret.value
|
||||
return yield* Effect.fail(new MissingCredentialError(name, `${name} is not set`))
|
||||
}),
|
||||
)
|
||||
|
||||
export const effect = (load: Effect.Effect<Redacted.Redacted, CredentialError>) => credential(load)
|
||||
|
||||
@@ -145,15 +155,10 @@ export function scheme(name: string, source?: Secret | Credential) {
|
||||
}
|
||||
|
||||
const toAIError = (error: AuthError): AIError => {
|
||||
if (error instanceof MissingCredentialError || error instanceof Config.ConfigError) {
|
||||
return new AIError({
|
||||
reason:
|
||||
error instanceof MissingCredentialError
|
||||
? new AuthenticationError({ message: error.message, cause: error })
|
||||
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),
|
||||
})
|
||||
}
|
||||
return error
|
||||
if (error instanceof AIError) return error
|
||||
const message =
|
||||
error instanceof MissingCredentialError ? error.message : `Failed to resolve auth config: ${error.message}`
|
||||
return new AIError({ reason: new AuthenticationError({ message, cause: error }) })
|
||||
}
|
||||
|
||||
export const toEffect =
|
||||
|
||||
@@ -261,7 +261,9 @@ const unsupportedCompaction = (request: LLMRequest, mechanism: string | undefine
|
||||
})
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/LLMClient") {}
|
||||
export class LLMClientService extends Context.Service<LLMClientService, Interface>()("@opencode/LLMClient") {}
|
||||
export const Service = LLMClientService
|
||||
export type Service = LLMClientService
|
||||
|
||||
const resolveRequestOptions = (request: LLMRequest) => {
|
||||
const messages = normalizeToolHistory(request.messages)
|
||||
|
||||
@@ -19,4 +19,8 @@ export type HttpMiddleware = (
|
||||
handler: HttpHandler,
|
||||
) => Effect.Effect<HttpClientResponse.HttpClientResponse, Error>
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/RequestExecutor") {}
|
||||
export class RequestExecutorService extends Context.Service<RequestExecutorService, Interface>()(
|
||||
"@opencode/AI/RequestExecutor",
|
||||
) {}
|
||||
export const Service = RequestExecutorService
|
||||
export type Service = RequestExecutorService
|
||||
|
||||
@@ -255,4 +255,20 @@ export const layer: Layer.Layer<Service, never, HttpClient.HttpClient> = Layer.e
|
||||
|
||||
export const fetchLayer = layer.pipe(Layer.provide(FetchHttpClient.layer))
|
||||
|
||||
/** Run `fn` on every request: it sees the raw response before status classification, inside middleware already on `executor`, and outside per-call middleware. */
|
||||
export const middleware = (fn: HttpMiddleware, executor: Layer.Layer<Service> = fetchLayer): Layer.Layer<Service> =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const inner = yield* Service
|
||||
return Service.of({
|
||||
execute: (request, next) =>
|
||||
inner.execute(
|
||||
request,
|
||||
next === undefined ? fn : (input, handler) => fn(input, (forwarded) => next(forwarded, handler)),
|
||||
),
|
||||
})
|
||||
}),
|
||||
).pipe(Layer.provide(executor))
|
||||
|
||||
export * as RequestExecutor from "./executor.js"
|
||||
|
||||
@@ -7,7 +7,7 @@ export type {
|
||||
RouteDefaultsInput,
|
||||
AnyRoute,
|
||||
Interface as LLMClientShape,
|
||||
Service as LLMClientService,
|
||||
LLMClientService,
|
||||
StreamOptions,
|
||||
CompactMethod,
|
||||
CompactionOperations,
|
||||
|
||||
@@ -12,7 +12,9 @@ export interface Interface {
|
||||
) => Stream.Stream<SpeechEvent, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/SpeechClient") {}
|
||||
export class SpeechClientService extends Context.Service<SpeechClientService, Interface>()("@opencode/SpeechClient") {}
|
||||
export const Service = SpeechClientService
|
||||
export type Service = SpeechClientService
|
||||
|
||||
export const generate = <Options extends SpeechOptions>(
|
||||
request: SpeechRequestFor<Options>,
|
||||
|
||||
@@ -30,7 +30,11 @@ export interface Interface {
|
||||
) => Effect.Effect<Generation<TranscriptionResponse>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/TranscriptionClient") {}
|
||||
export class TranscriptionClientService extends Context.Service<TranscriptionClientService, Interface>()(
|
||||
"@opencode/TranscriptionClient",
|
||||
) {}
|
||||
export const Service = TranscriptionClientService
|
||||
export type Service = TranscriptionClientService
|
||||
|
||||
export const generate = <Options extends TranscriptionOptions>(
|
||||
request: TranscriptionRequestFor<Options>,
|
||||
|
||||
@@ -48,9 +48,12 @@ const EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
csv: "text/csv",
|
||||
}
|
||||
|
||||
export const extensionMediaType = (path: string): string | undefined =>
|
||||
const extensionMediaType = (path: string): string | undefined =>
|
||||
EXTENSIONS[path.slice(path.lastIndexOf(".") + 1).toLowerCase()]
|
||||
|
||||
/** Media type of a file's contents: sniffed magic bytes, then the path's extension. */
|
||||
export const fileMediaType = (bytes: Uint8Array, path: string) => detectMediaType(bytes) ?? extensionMediaType(path)
|
||||
|
||||
const EXTENSION_ALIASES: Readonly<Record<string, string>> = {
|
||||
"audio/mp3": "mp3",
|
||||
"audio/m4a": "m4a",
|
||||
|
||||
@@ -29,7 +29,9 @@ export interface Interface {
|
||||
) => Stream.Stream<VideoEvent, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/VideoClient") {}
|
||||
export class VideoClientService extends Context.Service<VideoClientService, Interface>()("@opencode/VideoClient") {}
|
||||
export const Service = VideoClientService
|
||||
export type Service = VideoClientService
|
||||
|
||||
export const start = <Options extends VideoOptions>(
|
||||
request: VideoRequestFor<Options>,
|
||||
|
||||
@@ -92,6 +92,15 @@ describe("Auth", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports a missing config credential as Authentication naming the variable", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Auth.toEffect(Auth.config("OPENAI_API_KEY").bearer())(input).pipe(withEnv({}), Effect.flip)
|
||||
|
||||
expect(error.reason._tag).toBe("Authentication")
|
||||
expect(error.message).toContain("OPENAI_API_KEY is not set")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("can intentionally leave auth untouched", () =>
|
||||
Effect.gen(function* () {
|
||||
const headers = yield* Auth.none.apply(input)
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Ref, Stream } from "effect"
|
||||
import { Deferred, Effect, Fiber, Layer, Ref, Stream } from "effect"
|
||||
import { Headers, HttpClientError, HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, AIError, HttpContext, InvalidProviderOutputError, TransportError } from "../src/index.js"
|
||||
import { LLMClient, RequestExecutor, WebSocketTransport, type WebSocketChannelExecutor } from "../src/route.js"
|
||||
import { route } from "../src/protocols/openai-chat.js"
|
||||
import { configure } from "../src/providers/openai.js"
|
||||
import { dynamicResponse, fixedResponse, systemError } from "./lib/http.js"
|
||||
import { dynamicResponse, fixedResponse, handlerLayer, systemError } from "./lib/http.js"
|
||||
import { deltaChunk } from "./lib/openai-chunks.js"
|
||||
import { sseEvents, sseRaw } from "./lib/sse.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
@@ -195,6 +195,26 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("runs shared middleware outside per-call middleware", () => {
|
||||
const calls: Array<string> = []
|
||||
const record = (name: string) => Effect.sync(() => calls.push(name))
|
||||
const base = RequestExecutor.layer.pipe(
|
||||
Layer.provide(handlerLayer((input) => record("handler").pipe(Effect.as(input.respond("ok"))))),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
yield* executor.execute(request, (input, next) => record("per-call").pipe(Effect.andThen(next(input))))
|
||||
expect(calls).toEqual(["outer", "per-call", "handler"])
|
||||
calls.length = 0
|
||||
yield* executor.execute(request)
|
||||
expect(calls).toEqual(["outer", "handler"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
RequestExecutor.middleware((input, next) => record("outer").pipe(Effect.andThen(next(input))), base),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("classifies context overflow responses", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import {
|
||||
AIClient,
|
||||
AIError,
|
||||
Generation,
|
||||
Image,
|
||||
ImageClient,
|
||||
LanguageModel,
|
||||
LLM,
|
||||
LLMClient,
|
||||
@@ -11,10 +14,11 @@ import {
|
||||
Speech,
|
||||
SpeechClient,
|
||||
SpeechEvent,
|
||||
TranscriptionClient,
|
||||
Video,
|
||||
VideoClient,
|
||||
} from "@opencode/ai"
|
||||
import { Route, Protocol, WebSocketTransport } from "@opencode/ai/route"
|
||||
import { Route, Protocol, RequestExecutor, WebSocketTransport } from "@opencode/ai/route"
|
||||
import { Provider as ProviderSubpath } from "@opencode/ai/provider"
|
||||
import {
|
||||
AssemblyAI,
|
||||
@@ -76,6 +80,28 @@ describe("public exports", () => {
|
||||
expect(EvaluationClient.fetchLayer).toBeDefined()
|
||||
})
|
||||
|
||||
test("AIClient.layerWith shares one executor across every client", async () => {
|
||||
let built = 0
|
||||
const counting = Layer.effect(
|
||||
RequestExecutor.Service,
|
||||
Effect.sync(() => {
|
||||
built++
|
||||
return RequestExecutor.Service.of({ execute: () => Effect.die("unexpected request") })
|
||||
}),
|
||||
)
|
||||
await Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
yield* LLMClient.Service
|
||||
yield* ImageClient.Service
|
||||
yield* VideoClient.Service
|
||||
yield* SpeechClient.Service
|
||||
yield* TranscriptionClient.Service
|
||||
yield* RequestExecutor.Service
|
||||
}).pipe(Effect.provide(AIClient.layerWith(counting))),
|
||||
)
|
||||
expect(built).toBe(1)
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
expect(Route.make).toBeFunction()
|
||||
expect(Protocol.make).toBeFunction()
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { mkdtemp, rm } from "node:fs/promises"
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { AIError, LLMEvent, Media, SpeechEvent, TranscriptionEvent } from "../src/index.js"
|
||||
@@ -182,6 +185,36 @@ describe("AI promise client", () => {
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("observes a started generation's events and fetches its result", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
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 events: Array<string> = []
|
||||
for await (const event of generation.events({ poll: { interval: 10 } })) events.push(event.type)
|
||||
expect(events).toEqual(["generation-progress", "generation-finished"])
|
||||
expect(generation.status).toBe("queued")
|
||||
expect((await generation.result()).video.source).toMatchObject({ url: "https://runway.test/out.mp4" })
|
||||
await ai.dispose()
|
||||
})
|
||||
|
||||
test("reads, writes, and decodes assets without leaving promises", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
const dir = await mkdtemp(join(tmpdir(), "ai-promise-"))
|
||||
try {
|
||||
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3])
|
||||
await Bun.write(join(dir, "source.bin"), png)
|
||||
|
||||
const asset = await ai.file(join(dir, "source.bin"))
|
||||
expect(asset.mediaType).toBe("image/png")
|
||||
await ai.write(asset, join(dir, "copy.png"))
|
||||
expect(await ai.bytes(await ai.file(join(dir, "copy.png")))).toEqual(png)
|
||||
} finally {
|
||||
await rm(dir, { recursive: true })
|
||||
await ai.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("generates, streams, and starts transcriptions over the same runtime", async () => {
|
||||
const ai = AI.make({ layer: executor([]) })
|
||||
const audio = Media.url("https://audio.test/hello.mp3")
|
||||
@@ -233,6 +266,12 @@ describe("AI promise client", () => {
|
||||
expect(failure).toBeInstanceOf(AIError)
|
||||
expect(failure instanceof AIError && failure.reason.http?.status).toBe(404)
|
||||
|
||||
const invalid = await ai.llm
|
||||
// @ts-expect-error Invalid input must reject with AIError, not throw synchronously.
|
||||
.generate({ model: openai.responses("gpt-5"), messages: [{ role: "bogus" }] })
|
||||
.catch((error: unknown) => error)
|
||||
expect(invalid instanceof AIError && invalid.reason._tag).toBe("InvalidRequest")
|
||||
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const aborted = await ai.llm
|
||||
|
||||
@@ -533,6 +533,7 @@ describe("Video / fal", () => {
|
||||
"InvalidRequest",
|
||||
])
|
||||
expect(errors[1].message).toContain("end_image_url")
|
||||
expect(errors[4].message).toContain("; got fal:handle")
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("unsupported input reached the network")))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -94,9 +94,10 @@ In Vite development mode, `origin` uses `VITE_OPENCODE_SERVER_HOST` / `VITE_OPEN
|
||||
(default: `http://localhost:4096`) instead of the frontend origin. Both modes restore user-added servers
|
||||
from storage. Desktop provides the local server it discovers or starts through native initialization.
|
||||
|
||||
With no configured servers, the app shows a full-screen connection form. Enter a server address and password,
|
||||
or choose **Scan QR code** to open the camera and read the pairing code from `opencode pair`.
|
||||
Scanning fills the form and immediately attempts to connect. Failed connections leave the details available
|
||||
With no configured servers, or when the only server rejects the saved credentials, the app shows a full-screen
|
||||
connection form. Enter a server address and password, paste a link from `opencode pair`, or choose
|
||||
**Scan QR code** to read its QR code. Pairing links are single-use; the app exchanges them for a session token
|
||||
and immediately attempts to connect. Failed connections leave the details available
|
||||
to edit and retry with **Connect**. Credentials are checked before saving the server. Camera access requires
|
||||
HTTPS (or localhost) and browser permission. Saved offline servers continue to use the normal app UI.
|
||||
|
||||
@@ -106,7 +107,7 @@ When the service is exposed through an HTTPS reverse proxy, advertise its extern
|
||||
opencode pair --url https://opencode.example.com
|
||||
```
|
||||
|
||||
This replaces the addresses printed and encoded in the QR code while retaining the local service password.
|
||||
This replaces the addresses in the printed links and QR code.
|
||||
The proxy URL must reach the OpenCode API, not just the frontend. For separate frontend and API processes,
|
||||
route `/api` to the service while preserving the `/api` prefix. No machine-specific app or CLI build is required.
|
||||
|
||||
|
||||
@@ -214,6 +214,23 @@ for (const draft of ["empty-draft", "multiline-draft", "mixed-attachments"]) {
|
||||
})
|
||||
}
|
||||
|
||||
story.describe("touch attachments", () => {
|
||||
story.use({ hasTouch: true, isMobile: true, viewport: { width: 390, height: 844 } })
|
||||
|
||||
story("keeps attachment remove buttons visible without hover", async ({ mount, page }) => {
|
||||
const component = await mount("opencode-composer-flow--mixed-attachments")
|
||||
expect(await page.evaluate(() => matchMedia("(hover: none)").matches)).toBe(true)
|
||||
|
||||
const remove = component.getByRole("button", { name: "Remove attachment", exact: true })
|
||||
await expect(remove).not.toHaveCount(0)
|
||||
for (const button of await remove.all()) await expect(button).toHaveCSS("opacity", "1")
|
||||
|
||||
const count = await remove.count()
|
||||
await remove.first().tap()
|
||||
await expect(remove).toHaveCount(count - 1)
|
||||
})
|
||||
})
|
||||
|
||||
story("renders a draft once and supports editing, caret restoration, and failure recovery", async ({ mount, page }) => {
|
||||
await page.addInitScript(() => {
|
||||
const replace = Element.prototype.replaceChildren
|
||||
|
||||
@@ -105,6 +105,4 @@ test("renames, exports, and deletes a home session from its context menu", async
|
||||
await dialog.getByRole("button", { name: "Delete session" }).click()
|
||||
await removed
|
||||
await expect(renamedRow).toBeHidden()
|
||||
await expect(page.getByText("Session deleted", { exact: true })).toBeVisible()
|
||||
await expect(page.locator(`[data-slot="titlebar-tabs"] a[href$="/session/${fixture.targetID}"]`)).toHaveCount(0)
|
||||
})
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
|
||||
test("pairs locally without checking the server and authenticates subsequent requests", async ({ page, baseURL }) => {
|
||||
const origin = new URL(baseURL ?? "http://127.0.0.1:3000").origin
|
||||
const password = "pairing-secret"
|
||||
const authorization = `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`
|
||||
const requests: { origin: string; authorization: string | undefined }[] = []
|
||||
await page.addInitScript((origin) => {
|
||||
if (localStorage.getItem("opencode.global.dat:server")) return
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({ list: [{ type: "http", http: { url: origin, password: "old-password" } }] }),
|
||||
)
|
||||
}, origin)
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push({
|
||||
origin: new URL(route.request().url()).origin,
|
||||
authorization: route.request().headers().authorization,
|
||||
})
|
||||
// Pairing must succeed even when the API is unavailable.
|
||||
await route.fulfill({ status: 503, contentType: "application/json", body: "{}" })
|
||||
})
|
||||
|
||||
await page.goto(`/connect#${Buffer.from(JSON.stringify({ username: "opencode", password })).toString("base64url")}`)
|
||||
await expect(page).toHaveURL(`${origin}/`)
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem("opencode.global.dat:server") ?? "{}").list))
|
||||
.toEqual([{ type: "http", http: { url: origin, password } }])
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
|
||||
requests.length = 0
|
||||
await page.reload()
|
||||
await expect(page.getByRole("button", { name: "Home", exact: true })).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.origin === origin).length).toBeGreaterThan(0)
|
||||
expect(
|
||||
requests.filter((request) => request.origin === origin).every((request) => request.authorization === authorization),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test("the unpaired page loads without starting server requests", async ({ page }) => {
|
||||
const requests: string[] = []
|
||||
await page.route("**/api/**", async (route) => {
|
||||
requests.push(route.request().url())
|
||||
await route.abort()
|
||||
})
|
||||
await page.goto("/connect")
|
||||
await expect(page.getByRole("heading", { name: "Connect to a server" })).toBeVisible()
|
||||
await expect(page.getByLabel("Password", { exact: true })).toBeEditable()
|
||||
expect(requests).toEqual([])
|
||||
})
|
||||
@@ -1,113 +0,0 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import type { OpenCodeEvent } from "@opencode/client/promise"
|
||||
import { fixture } from "../smoke/session-timeline.fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
|
||||
test("external deletion leaves an open tab on the missing-session screen, toasts, and removes the Home row", async ({
|
||||
page,
|
||||
}) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
|
||||
await expect(row).toBeVisible()
|
||||
await row.locator('[data-component="home-session-row"]').click()
|
||||
await expect(page.getByRole("button", { name: "More options" })).toBeVisible()
|
||||
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href$="/session/${fixture.targetID}"]`)
|
||||
await expect(tab).toBeVisible()
|
||||
|
||||
sessions.splice(
|
||||
sessions.findIndex((session) => session.id === fixture.targetID),
|
||||
1,
|
||||
)
|
||||
events.push({
|
||||
id: "evt_target_deleted",
|
||||
created: 1700000003000,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: fixture.targetID, seq: 1, version: 2 },
|
||||
location: { directory: fixture.directory },
|
||||
data: { sessionID: fixture.targetID },
|
||||
})
|
||||
|
||||
await expect(page.getByText("This session cannot be found")).toBeVisible()
|
||||
await expect(tab).toBeVisible()
|
||||
await expect(page.getByText("Session deleted", { exact: true })).toBeVisible()
|
||||
await page.getByRole("button", { name: "Home" }).click()
|
||||
await expect(
|
||||
page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.sourceID}"]`),
|
||||
).toBeVisible()
|
||||
await expect(row).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("deletion event for a session without an open tab does not toast", async ({ page }) => {
|
||||
const sessions = fixture.sessions.map((session) => ({ ...session }))
|
||||
const events: OpenCodeEvent[] = []
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions,
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
events: () => events.splice(0),
|
||||
})
|
||||
await page.addInitScript((directory) => {
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
}, fixture.directory)
|
||||
|
||||
await page.goto("/")
|
||||
const row = page.locator(`[data-component="home-session-row-container"][data-session-id="${fixture.targetID}"]`)
|
||||
await expect(row).toBeVisible()
|
||||
const refill = Promise.withResolvers<void>()
|
||||
await page.route(/\/api\/session(?:\?|$)/, async (route) => {
|
||||
if (route.request().method() !== "GET") return route.fallback()
|
||||
await refill.promise
|
||||
return route.fallback()
|
||||
})
|
||||
sessions.splice(
|
||||
sessions.findIndex((session) => session.id === fixture.targetID),
|
||||
1,
|
||||
)
|
||||
const refetch = page.waitForRequest(
|
||||
(request) => request.method() === "GET" && /\/api\/session(?:\?|$)/.test(request.url()),
|
||||
)
|
||||
events.push({
|
||||
id: "evt_unopened_deleted",
|
||||
created: 1700000003000,
|
||||
type: "session.deleted",
|
||||
durable: { aggregateID: fixture.targetID, seq: 1, version: 2 },
|
||||
location: { directory: fixture.directory },
|
||||
data: { sessionID: fixture.targetID },
|
||||
})
|
||||
|
||||
try {
|
||||
await refetch
|
||||
await expect(row).toHaveCount(0)
|
||||
await expect(page.getByText("Session deleted", { exact: true })).toHaveCount(0)
|
||||
} finally {
|
||||
refill.resolve()
|
||||
}
|
||||
})
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import type { OpenCodeEvent, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import type { OpenCodeEvent, SessionInboxInfo, SessionMessageInfo } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
@@ -14,7 +14,12 @@ type InboxRow = {
|
||||
sessionID: string
|
||||
time: { created: number }
|
||||
type: "user"
|
||||
payload: { text: string; metadata?: Record<string, unknown> }
|
||||
payload: {
|
||||
text: string
|
||||
metadata?: Record<string, unknown>
|
||||
files?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["files"]
|
||||
agents?: Extract<SessionInboxInfo, { type: "user" }>["payload"]["agents"]
|
||||
}
|
||||
delivery: "steer" | "queue"
|
||||
}
|
||||
|
||||
@@ -29,7 +34,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
}))
|
||||
const events: OpenCodeEvent[] = []
|
||||
const prompts: Record<string, unknown>[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" }[] = []
|
||||
const changes: { inboxID: string; action: "cancel" | "steer" | "queue" }[] = []
|
||||
const log: string[] = []
|
||||
let sequence = 0
|
||||
const emit = <Type extends OpenCodeEvent["type"]>(
|
||||
@@ -234,6 +239,113 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
expect(mock.log[0]).toBe("prompt:queue")
|
||||
})
|
||||
|
||||
test("Move Back cancels only the selected queued prompt and focuses the restored input", async ({ page }) => {
|
||||
const mock = createQueueMock(["first queued prompt", "second queued prompt", "third queued prompt"])
|
||||
const view = await openSession(page, mock)
|
||||
await expect(view.rows).toHaveCount(3)
|
||||
|
||||
const row = view.rows.filter({ hasText: "second queued prompt" })
|
||||
const actions = row.locator('[data-slot="session-queue-actions"] button')
|
||||
await expect(actions).toHaveCount(3)
|
||||
expect(
|
||||
await actions.evaluateAll((buttons) =>
|
||||
buttons.map((button) => button.getAttribute("aria-label") ?? button.textContent?.trim()),
|
||||
),
|
||||
).toEqual(["Steer", "Move Back", "Remove"])
|
||||
const moveBack = row.getByRole("button", { name: "Move Back" })
|
||||
await expect(moveBack).toHaveText("")
|
||||
await expect(moveBack.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-undo-down")
|
||||
await moveBack.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
|
||||
await moveBack.click()
|
||||
await expect(view.rows.locator('[data-action="session-queue-edit"]')).toHaveText([
|
||||
"first queued prompt",
|
||||
"third queued prompt",
|
||||
])
|
||||
await expect(view.input).toHaveText("second queued prompt")
|
||||
await expect(view.input).toBeFocused()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_2", action: "cancel" }])
|
||||
expect(mock.prompts).toEqual([])
|
||||
})
|
||||
|
||||
test("Move Back preserves an existing draft and restores inline attachments", async ({ page }) => {
|
||||
const mock = createQueueMock(["queued with image"])
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScL/nwAAAABJRU5ErkJggg==",
|
||||
mime: "image/png",
|
||||
source: { type: "inline" },
|
||||
name: "shot.png",
|
||||
},
|
||||
]
|
||||
const view = await openSession(page, mock)
|
||||
await view.input.fill("my draft")
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.input).toHaveText("my draft")
|
||||
expect(mock.changes).toEqual([])
|
||||
|
||||
await view.input.fill("")
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(view.input).toHaveText("queued with image")
|
||||
await expect(view.input).toBeFocused()
|
||||
await expect(view.composer.getByRole("img", { name: "shot.png" })).toBeVisible()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
})
|
||||
|
||||
test("Move Back stays usable with a long queue on a narrow screen", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
const text = "Review the detailed error report and check every step of the retry path ".repeat(4)
|
||||
const mock = createQueueMock([text, ...Array.from({ length: 6 }, (_, index) => `queued follow-up ${index + 1}`)])
|
||||
const view = await openSession(page, mock)
|
||||
await expect(view.rows).toHaveCount(7)
|
||||
const row = view.rows.filter({ hasText: text })
|
||||
await row.getByRole("button", { name: "Move Back" }).hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Move Back")
|
||||
await page.screenshot({ path: testInfo.outputPath("move-back-narrow-queue.png") })
|
||||
await row.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.rows).toHaveCount(6)
|
||||
await expect(view.input).toHaveText(text)
|
||||
await expect(view.input).toBeFocused()
|
||||
expect(mock.changes).toEqual([{ inboxID: "inb_seed_1", action: "cancel" }])
|
||||
})
|
||||
|
||||
test("Move Back preserves mentioned file and agent references on resubmission", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect @main.ts with @build"])
|
||||
mock.rows[0].payload.files = [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri", uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { start: 8, end: 16, text: "@main.ts" },
|
||||
},
|
||||
]
|
||||
mock.rows[0].payload.agents = [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }]
|
||||
const view = await openSession(page, mock)
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(view.input).toHaveText("inspect @main.ts with @build")
|
||||
await view.input.press("Enter")
|
||||
await expect.poll(() => mock.prompts.length).toBe(1)
|
||||
expect(mock.prompts[0].files).toMatchObject([
|
||||
{ uri: "data:text/plain;base64,aGk=", mention: { text: "@main.ts", start: 8, end: 16 } },
|
||||
])
|
||||
expect(mock.prompts[0].agents).toMatchObject([{ name: "build", mention: { text: "@build" } }])
|
||||
})
|
||||
|
||||
test("Move Back does not discard hidden file context", async ({ page }) => {
|
||||
const mock = createQueueMock(["inspect this file"])
|
||||
mock.rows[0].payload.files = [
|
||||
{ data: "aGk=", mime: "text/plain", source: { type: "uri", uri: "file:///repo/main.ts" }, name: "main.ts" },
|
||||
]
|
||||
const view = await openSession(page, mock)
|
||||
await view.rows.getByRole("button", { name: "Move Back" }).click()
|
||||
await expect(page.getByText("Edit this prompt in the queue to preserve its file context")).toBeVisible()
|
||||
await expect(view.rows).toHaveCount(1)
|
||||
await expect(view.input).toHaveText("")
|
||||
expect(mock.changes).toEqual([])
|
||||
})
|
||||
|
||||
for (const delivery of ["steer", "queue"] as const) {
|
||||
test(`keeps finished tools above a pending ${delivery === "queue" ? "queue-to-steer" : "steer"} follow-up`, async ({
|
||||
page,
|
||||
|
||||
@@ -8,8 +8,13 @@ const directory = "C:/OpenCode/SubagentNavigation"
|
||||
const projectID = "proj_subagent_navigation"
|
||||
const parentID = "ses_subagent_parent"
|
||||
const childID = "ses_subagent_child"
|
||||
const grandchildID = "ses_subagent_grandchild"
|
||||
const greatGrandchildID = "ses_subagent_great_grandchild"
|
||||
const parentTitle = "Parent session"
|
||||
const childTitle = "Subagent child session"
|
||||
const grandchildTitle = "Nested subagent session"
|
||||
const greatGrandchildTitle =
|
||||
"Deep research subagent session investigating a very long chain of agent registry failures and navigation breadcrumbs"
|
||||
// Child session pages derive their heading from the task part that spawned them.
|
||||
const taskDescription = "Inspect child navigation"
|
||||
|
||||
@@ -25,6 +30,60 @@ test("navigates to a subagent child session missing from the session list", asyn
|
||||
await expect(page.getByRole("button", { name: "Toggle review", exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
test("opens a directly linked nested subagent in its root session tab", async ({ page }) => {
|
||||
await setup(page, undefined, 1)
|
||||
await page.goto(sessionHref(grandchildID), { waitUntil: "domcontentloaded" })
|
||||
|
||||
await expect(page.locator('[data-slot="session-title-parent"]')).toHaveText(childTitle)
|
||||
await expect(page.locator('[data-slot="session-title-child"]')).toHaveText(grandchildTitle)
|
||||
const tabs = page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-slot]')
|
||||
await expect(tabs.locator(`a[href="${sessionHref(grandchildID)}"]`)).toHaveCount(1)
|
||||
await expect(tabs).toHaveCount(1)
|
||||
await expect(tabs.locator('[data-slot="tab-title"]')).toHaveText(parentTitle)
|
||||
})
|
||||
|
||||
test("shows the full ancestor path and navigates to an earlier subagent", async ({ page }) => {
|
||||
await setup(page, undefined, 2)
|
||||
await page.goto(sessionHref(greatGrandchildID), { waitUntil: "domcontentloaded" })
|
||||
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toBeAttached()
|
||||
const header = page.locator("[data-session-title]")
|
||||
await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle])
|
||||
await expect(header.locator('[data-slot="session-title-parent"]')).toHaveText(grandchildTitle)
|
||||
await expect(header.getByRole("heading", { name: greatGrandchildTitle })).toBeVisible()
|
||||
|
||||
await header.getByRole("button", { name: childTitle, exact: true }).click()
|
||||
await expect(page).toHaveURL(sessionHref(childID))
|
||||
await expect(page.locator("[data-session-title]").getByRole("heading", { name: childTitle })).toBeVisible()
|
||||
await expect(page.locator('[data-slot="titlebar-tabs"] [data-titlebar-tab-slot]')).toHaveCount(1)
|
||||
})
|
||||
|
||||
test("keeps the active nested session visible in a narrow desktop header", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 820, height: 720 })
|
||||
await setup(page, undefined, 2)
|
||||
await page.goto(sessionHref(greatGrandchildID), { waitUntil: "domcontentloaded" })
|
||||
|
||||
const header = page.locator("[data-session-title]")
|
||||
const heading = header.getByRole("heading", { name: greatGrandchildTitle })
|
||||
const separator = header.locator('[data-slot="session-title-ancestors"] + [data-slot="session-title-separator"]')
|
||||
await expect(header.locator('[data-slot="session-title-ancestor"]')).toHaveText([parentTitle, childTitle])
|
||||
await expect(heading).toBeInViewport()
|
||||
await expect(separator).toBeInViewport()
|
||||
const bounds = await heading.boundingBox()
|
||||
expect(bounds).not.toBeNull()
|
||||
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(820)
|
||||
|
||||
await page.evaluate(() => document.documentElement.setAttribute("dir", "rtl"))
|
||||
await expect(heading).toBeInViewport()
|
||||
await expect(separator).toBeInViewport()
|
||||
|
||||
const root = header.locator(`[data-slot="session-title-ancestor"][data-session-id="${parentID}"]`)
|
||||
await root.focus()
|
||||
await expect(root).toBeFocused()
|
||||
await root.press("Enter")
|
||||
await expect(page).toHaveURL(sessionHref(parentID))
|
||||
})
|
||||
|
||||
test("returns to the parent session with Escape", async ({ page }) => {
|
||||
await setup(page)
|
||||
await openChildFromParent(page)
|
||||
@@ -144,7 +203,7 @@ test("shows the not found fallback when the viewed session is deleted", async ({
|
||||
await expect(page.getByRole("heading", { name: taskDescription })).toHaveCount(0)
|
||||
})
|
||||
|
||||
async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
async function setup(page: Page, events?: () => OpenCodeEvent[], nestedDepth: 0 | 1 | 2 = 0) {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
@@ -168,7 +227,14 @@ async function setup(page: Page, events?: () => OpenCodeEvent[]) {
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session(parentID, parentTitle, 1700000000000), childSession()],
|
||||
sessions: [
|
||||
session(parentID, parentTitle, 1700000000000),
|
||||
childSession(),
|
||||
...(nestedDepth >= 1 ? [session(grandchildID, grandchildTitle, 1700000002000, { parentID: childID })] : []),
|
||||
...(nestedDepth >= 2
|
||||
? [session(greatGrandchildID, greatGrandchildTitle, 1700000003000, { parentID: grandchildID })]
|
||||
: []),
|
||||
],
|
||||
pageMessages: (sessionID) => ({ items: sessionID === parentID ? parentMessages() : [] }),
|
||||
events,
|
||||
eventRetry: events ? 16 : undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+16
-24
@@ -4,9 +4,9 @@ import { FileComponentProvider } from "@opencode/ui/context/file"
|
||||
import { Font } from "@opencode/ui/font"
|
||||
import { ThemeProvider } from "@opencode/ui/theme/context"
|
||||
import { MetaProvider } from "@solidjs/meta"
|
||||
import { type BaseRouterProps, Router, useLocation } from "@solidjs/router"
|
||||
import { type BaseRouterProps, Router } from "@solidjs/router"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps, Show } from "solid-js"
|
||||
import { type Component, createRenderEffect, ErrorBoundary, type JSX, type ParentProps } from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { CommandProvider } from "@/shell/commands/command"
|
||||
import { DesktopCommands } from "@/shell/commands/desktop"
|
||||
@@ -107,29 +107,21 @@ export function AppInterface(props: {
|
||||
// The visual layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
// providers beneath it.
|
||||
const Root = (rootProps: ParentProps) => {
|
||||
const location = useLocation()
|
||||
// Pairing saves credentials before mounting any server connections or health checks.
|
||||
return (
|
||||
<>
|
||||
const Root = (rootProps: ParentProps) => (
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<BodyTypography />
|
||||
<Show when={location.pathname !== "/connect"} fallback={rootProps.children}>
|
||||
<TabsProvider>
|
||||
<GlobalProvider>
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<CommandProvider>
|
||||
<DesktopCommands />
|
||||
<SshRestore />
|
||||
<HighlightsProvider>
|
||||
{props.children}
|
||||
{rootProps.children}
|
||||
</HighlightsProvider>
|
||||
</CommandProvider>
|
||||
</GlobalProvider>
|
||||
</TabsProvider>
|
||||
)
|
||||
|
||||
return (
|
||||
<ServersProvider
|
||||
|
||||
@@ -47,6 +47,7 @@ export type ComposerDelivery = "steer" | "queue"
|
||||
// is loaded in the editor.
|
||||
export type ComposerQueue = {
|
||||
count: Accessor<number>
|
||||
movingBack: Accessor<boolean>
|
||||
// Delivery a plain submit uses right now.
|
||||
delivery: Accessor<ComposerDelivery>
|
||||
// Delivery offered on Mod+Enter and the toolbar hint button; undefined hides the hint.
|
||||
|
||||
@@ -168,6 +168,7 @@ function ComposerStory(props: {
|
||||
alternate: () => props.alternate,
|
||||
editing: () => undefined,
|
||||
confirmEdit() {},
|
||||
movingBack: () => false,
|
||||
cancelEdit() {},
|
||||
editFirst: () => false,
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export function Composer(props: {
|
||||
class?: string
|
||||
model: ComposerModel
|
||||
borderUnderlay?: boolean
|
||||
readOnly?: boolean
|
||||
suggestionBoundary?: () => HTMLElement | undefined
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
@@ -27,6 +28,7 @@ export function Composer(props: {
|
||||
<ComposerEditor
|
||||
controller={props.model}
|
||||
borderUnderlay={props.borderUnderlay}
|
||||
readOnly={props.readOnly}
|
||||
class={props.class}
|
||||
modelControlsVisible={!props.model.model.loading}
|
||||
attachKeybind={command.keybindParts("file.attach")}
|
||||
|
||||
@@ -542,7 +542,7 @@ export function ComposerAttachments(props: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onCommentRemove?.(comment)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
@@ -589,7 +589,7 @@ export function ComposerAttachments(props: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onAttachmentRemove(attachment)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
|
||||
aria-label={props.removeLabel}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
@@ -611,7 +611,7 @@ export function ComposerAttachments(props: {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.onUploadCancel?.(upload)}
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
class="absolute -top-1 -end-1 size-4 rounded-full bg-v2-icon-icon-muted outline-solid outline-1 outline-v2-icon-icon-contrast flex items-center justify-center hover-reveal group-hover:opacity-100"
|
||||
aria-label={i18n.t("ui.promptInput.cancelUpload")}
|
||||
>
|
||||
<Icon name="outline-xmark" class="text-v2-icon-icon-contrast" />
|
||||
|
||||
@@ -371,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onSubmit: (submitOptions) => {
|
||||
if (!available()) return
|
||||
const queue = options?.queue
|
||||
if (queue?.movingBack()) return
|
||||
// Confirming an edit re-admits the queued prompt instead of sending
|
||||
// the composer value as a new prompt. Enter keeps it queued in
|
||||
// place; the alternate action sends it as a steer.
|
||||
|
||||
@@ -496,6 +496,7 @@ export const dict = {
|
||||
"server.connect.scan": "Scan QR code",
|
||||
"server.connect.scan.description": "Point your camera at the QR code shown by opencode pair.",
|
||||
"server.connect.scan.invalid": "This is not an OpenCode pairing code. Scan the code shown by opencode pair.",
|
||||
"server.connect.link.expired": "This pairing link expired or was already used. Run opencode pair to get a new one.",
|
||||
"server.connect.camera": "Pairing camera",
|
||||
"server.connect.camera.starting": "Opening camera…",
|
||||
"server.connect.mixedContent":
|
||||
@@ -873,6 +874,9 @@ export const dict = {
|
||||
"session.queue.send": "Send",
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.moveBack": "Move Back",
|
||||
"session.queue.moveBackDraft": "Clear your draft before moving a prompt back",
|
||||
"session.queue.moveBackUnavailable": "Edit this prompt in the queue to preserve its file context",
|
||||
"session.queue.reorder": "Reorder queued prompt",
|
||||
"session.queue.attachments.one": "{{count}} attachment",
|
||||
"session.queue.attachments.other": "{{count}} attachments",
|
||||
@@ -1497,7 +1501,6 @@ export const dict = {
|
||||
"settings.permissions.tool.doom_loop.description": "Detect repeated tool calls with identical input",
|
||||
|
||||
"session.delete.failed.title": "Failed to delete session",
|
||||
"toast.session.deleted": "Session deleted",
|
||||
"session.delete.title": "Delete session",
|
||||
"session.delete.confirm": 'Delete session "{{name}}"?',
|
||||
"session.delete.button": "Delete session",
|
||||
|
||||
@@ -24,8 +24,6 @@ type DesktopOS = "macos" | "windows" | "linux"
|
||||
|
||||
export type PairingInfo = {
|
||||
readonly urls: readonly string[]
|
||||
readonly username: "opencode"
|
||||
readonly password: string
|
||||
}
|
||||
|
||||
export type FatalRendererErrorLog = {
|
||||
@@ -141,6 +139,8 @@ type PlatformBase = {
|
||||
/** Pair another device with the local desktop server. */
|
||||
pair?: {
|
||||
info(): Promise<PairingInfo>
|
||||
/** Single-use code for an `/auth/connect/:code` link. */
|
||||
code(): Promise<string>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,17 @@ describe("checkServerHealth", () => {
|
||||
expect(headers).toEqual([password ? `Basic ${btoa(`opencode:${password}`)}` : null])
|
||||
})
|
||||
|
||||
test("reports rejected credentials without retrying", async () => {
|
||||
let calls = 0
|
||||
const fetch = (async () => {
|
||||
calls++
|
||||
return Response.json({ _tag: "UnauthorizedError", message: "Authentication required" }, { status: 401 })
|
||||
}) as unknown as typeof globalThis.fetch
|
||||
|
||||
expect(await checkServerHealth(server, fetch)).toEqual({ healthy: false, unauthorized: true })
|
||||
expect(calls).toBe(1)
|
||||
})
|
||||
|
||||
test("returns healthy response with version", async () => {
|
||||
let request: URL | undefined
|
||||
const fetch = (async (input: RequestInfo | URL) => {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { authTokenFromCredentials } from "./api"
|
||||
import { ClientError, OpenCode } from "@opencode/client"
|
||||
import { ClientError, isUnauthorizedError, OpenCode } from "@opencode/client"
|
||||
import { Accessor, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
|
||||
export type ServerHealth = { healthy: boolean; version?: string; incompatible?: boolean; checking?: boolean }
|
||||
export type ServerHealth = {
|
||||
healthy: boolean
|
||||
version?: string
|
||||
incompatible?: boolean
|
||||
checking?: boolean
|
||||
unauthorized?: boolean
|
||||
}
|
||||
|
||||
interface CheckServerHealthOptions {
|
||||
timeoutMs?: number
|
||||
@@ -100,7 +106,7 @@ export async function checkServerHealth(
|
||||
.catch((error) => ({ error }))
|
||||
if ("data" in current) return current.data
|
||||
if (signal?.aborted) return { healthy: false }
|
||||
|
||||
if (isUnauthorizedError(current.error)) return { healthy: false, unauthorized: true }
|
||||
return next(count, current.error)
|
||||
}
|
||||
return attempt(0).finally(() => timeout?.clear?.())
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { Accessor, batch, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
|
||||
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { useServerHealth } from "@/runtime/server/health"
|
||||
@@ -20,7 +18,6 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -51,15 +48,18 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
|
||||
return serverCtx
|
||||
}
|
||||
|
||||
// A server that rejects our credentials would retry its event stream every second with the same
|
||||
// credentials, so its controller waits until health recovers and then starts with the current ones.
|
||||
createMemo(() => {
|
||||
for (const conn of server.list) {
|
||||
if (serverHealth[ServerConnection.key(conn)]?.unauthorized) continue
|
||||
ensureServerCtx(conn)
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
for (const [key] of serverCtxs) {
|
||||
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
|
||||
if (serverHealth[key]?.unauthorized || !server.list.find((conn) => ServerConnection.key(conn) === key)) {
|
||||
serverCtxDisposers.get(key)?.()
|
||||
serverCtxDisposers.delete(key)
|
||||
serverCtxs.delete(key)
|
||||
@@ -131,35 +131,8 @@ function createServerController(
|
||||
) {
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const queryClient = useQueryClient()
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const notifiedDeletionIDs = new Set<string>()
|
||||
const forgetDeletedSession = (sessionID: string) => {
|
||||
let removed = false
|
||||
queryClient.setQueryData<SessionInfo[]>(["home-sessions", conn], (current) => {
|
||||
if (!current?.some((session) => session.id === sessionID)) return current
|
||||
removed = true
|
||||
return current.filter((session) => session.id !== sessionID)
|
||||
})
|
||||
if (removed) void queryClient.invalidateQueries({ queryKey: ["home-sessions", conn], exact: true })
|
||||
}
|
||||
const notifyOpenSessionDeleted = (sessionID: string) => {
|
||||
if (notifiedDeletionIDs.has(sessionID)) return
|
||||
if (
|
||||
!tabs.store.some(
|
||||
(tab) =>
|
||||
tab.type === "session" &&
|
||||
tab.server === connKey &&
|
||||
(tab.sessionId === sessionID || tab.routeSessionId === sessionID),
|
||||
)
|
||||
)
|
||||
return
|
||||
if (notifiedDeletionIDs.size >= 128) notifiedDeletionIDs.delete(notifiedDeletionIDs.values().next().value!)
|
||||
notifiedDeletionIDs.add(sessionID)
|
||||
showToast({ variant: "success", title: language.t("toast.session.deleted") })
|
||||
}
|
||||
const source = createData({
|
||||
api: () => sdk.api,
|
||||
initialMessageLimit: () => (timelinePreset(settings.general.timelineDetail())?.id === "compact" ? 40 : 20),
|
||||
@@ -179,18 +152,8 @@ function createServerController(
|
||||
})
|
||||
const data = createDesktopData({
|
||||
data: source,
|
||||
remove: async (sessionID) => {
|
||||
await sdk.api.session.remove({ sessionID })
|
||||
forgetDeletedSession(sessionID)
|
||||
notifyOpenSessionDeleted(sessionID)
|
||||
},
|
||||
remove: (sessionID) => sdk.api.session.remove({ sessionID }),
|
||||
})
|
||||
onCleanup(
|
||||
sdk.event.on("session.deleted", (event) => {
|
||||
forgetDeletedSession(event.data.sessionID)
|
||||
notifyOpenSessionDeleted(event.data.sessionID)
|
||||
}),
|
||||
)
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
createPermissionAutoApprover({ sdk, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey, coordinator: notificationCoordinator })
|
||||
|
||||
@@ -29,7 +29,7 @@ import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import { decodePairingCode } from "./pairing"
|
||||
import type { Pairing } from "./pairing"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
@@ -117,16 +117,10 @@ export const DialogServer: Component<{
|
||||
invalid={!!form.state.error()}
|
||||
disabled={form.state.busy()}
|
||||
autofocus
|
||||
list="dialog-server-addresses"
|
||||
aria-describedby={form.state.error() ? "dialog-server-error" : undefined}
|
||||
onInput={(event) => form.change.value(event.currentTarget.value)}
|
||||
onKeyDown={keyDown}
|
||||
/>
|
||||
<datalist id="dialog-server-addresses">
|
||||
{form.state.urls().map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
<Show when={form.state.error()}>
|
||||
<span id="dialog-server-error" class="settings-server-dialog-error" role="alert">
|
||||
{form.state.error()}
|
||||
@@ -213,7 +207,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list" as FormMode,
|
||||
originalUrl: undefined as string | undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [] as string[],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined as boolean | undefined,
|
||||
@@ -227,7 +220,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
mode: "list",
|
||||
originalUrl: undefined,
|
||||
values: { url: "", name: "", password: "" },
|
||||
urls: [],
|
||||
scanning: false,
|
||||
error: "",
|
||||
status: undefined,
|
||||
@@ -333,11 +325,10 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
setStore("error", "")
|
||||
request.mutate()
|
||||
}
|
||||
const pair = (pairing: NonNullable<ReturnType<typeof decodePairingCode>>) => {
|
||||
const pair = (pairing: Pairing) => {
|
||||
healthPreview.cancel()
|
||||
setStore({
|
||||
values: { ...store.values, url: pairing.urls[0], password: pairing.password },
|
||||
urls: pairing.urls,
|
||||
values: { ...store.values, url: pairing.url, password: pairing.password },
|
||||
scanning: false,
|
||||
error: "",
|
||||
})
|
||||
@@ -359,7 +350,6 @@ function createFormController(options: { onSelect?: (server: ServerConnection.Ht
|
||||
value: () => store.values.url,
|
||||
name: () => store.values.name,
|
||||
password: () => store.values.password,
|
||||
urls: () => store.urls,
|
||||
scanning: () => store.scanning,
|
||||
error: () => store.error,
|
||||
status: () => store.status,
|
||||
|
||||
@@ -1,95 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { decodePairingCode, decodePairingScan, decodePairingUrl, pairingUrl } from "./pairing"
|
||||
import { pairingLink } from "./pairing"
|
||||
|
||||
describe("pairing URL", () => {
|
||||
test("pairs with the current origin using credentials without server URLs", () => {
|
||||
const info = { username: "opencode" as const, password: "a+b & café" }
|
||||
const origin = "https://opencode.example.com:49709"
|
||||
const url = new URL(pairingUrl(info, origin))
|
||||
|
||||
expect(url.origin).toBe(origin)
|
||||
expect(url.pathname).toBe("/connect")
|
||||
expect(url.search).toBe("")
|
||||
expect(url.hash).not.toBe("")
|
||||
expect(decodePairingUrl(url.hash, origin)).toEqual({ urls: [origin], password: info.password })
|
||||
expect(decodePairingCode(JSON.stringify(info))).toBeUndefined()
|
||||
})
|
||||
|
||||
test("keeps accepting query pairing data", () => {
|
||||
const info = { urls: ["http://192.168.1.2:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`?data=${encodeURIComponent(JSON.stringify(info))}`)).toEqual({
|
||||
urls: info.urls,
|
||||
password: info.password,
|
||||
describe("pairing link", () => {
|
||||
test("reads the server address and code from opencode pair links", () => {
|
||||
expect(pairingLink(" http://192.168.1.2:49374/auth/connect/abc_DEF-123 ")).toEqual({
|
||||
url: "http://192.168.1.2:49374",
|
||||
code: "abc_DEF-123",
|
||||
})
|
||||
})
|
||||
|
||||
test("accepts CLI base64url fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "a+b & café" }
|
||||
expect(decodePairingUrl(`#${Buffer.from(JSON.stringify(value)).toString("base64url")}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects invalid query data", () => {
|
||||
expect(decodePairingUrl("?data=invalid")).toBeUndefined()
|
||||
expect(decodePairingUrl("?other=value")).toBeUndefined()
|
||||
})
|
||||
|
||||
test("accepts legacy JSON fragments", () => {
|
||||
const value = { urls: ["http://localhost:4096"], username: "opencode", password: "secret" }
|
||||
expect(decodePairingUrl(`#${encodeURIComponent(JSON.stringify(value))}`)).toEqual({
|
||||
urls: value.urls,
|
||||
password: value.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects an invalid fragment", () => {
|
||||
expect(decodePairingUrl("#not-a-pairing-code")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("pairing scan", () => {
|
||||
const info = {
|
||||
urls: ["http://192.168.1.2:49374", "http://127.0.0.1:49374"],
|
||||
username: "opencode" as const,
|
||||
password: "a+b & café",
|
||||
}
|
||||
|
||||
test("decodes the raw JSON code", () => {
|
||||
expect(decodePairingScan(JSON.stringify(info))).toEqual({ urls: info.urls, password: info.password })
|
||||
})
|
||||
|
||||
test("decodes a direct /connect URL", () => {
|
||||
expect(
|
||||
decodePairingScan(pairingUrl({ username: info.username, password: info.password }, "http://192.168.1.2:49374")),
|
||||
).toEqual({
|
||||
urls: ["http://192.168.1.2:49374"],
|
||||
password: info.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps accepting /connect URLs with query data", () => {
|
||||
expect(
|
||||
decodePairingScan(`http://192.168.1.2:49374/connect?data=${encodeURIComponent(JSON.stringify(info))}`),
|
||||
).toEqual({
|
||||
urls: info.urls,
|
||||
password: info.password,
|
||||
})
|
||||
})
|
||||
|
||||
test("falls back to the URL origin when the payload omits server URLs", () => {
|
||||
const origin = "https://opencode.example.com:49709"
|
||||
expect(decodePairingScan(pairingUrl({ username: "opencode", password: "secret" }, origin))).toEqual({
|
||||
urls: [origin],
|
||||
password: "secret",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects URLs without pairing data and non-http schemes", () => {
|
||||
expect(decodePairingScan("http://192.168.1.2:49374/connect")).toBeUndefined()
|
||||
expect(decodePairingScan("https://example.com/?data=invalid")).toBeUndefined()
|
||||
expect(decodePairingScan("opencode-ios://connect?password=secret")).toBeUndefined()
|
||||
expect(decodePairingScan("not a code")).toBeUndefined()
|
||||
test("rejects other URLs", () => {
|
||||
expect(pairingLink("http://192.168.1.2:49374/auth/connect/")).toBeUndefined()
|
||||
expect(pairingLink("http://192.168.1.2:49374/auth/connect/abc/extra")).toBeUndefined()
|
||||
expect(pairingLink("http://192.168.1.2:49374/connect#abc")).toBeUndefined()
|
||||
expect(pairingLink("opencode-ios://auth/connect/abc")).toBeUndefined()
|
||||
expect(pairingLink("192.168.1.2:49374")).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,6 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { normalizeServerUrl } from "@/runtime/server/registry"
|
||||
|
||||
const pairing = Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
urls: Schema.optional(Schema.Array(Schema.String)),
|
||||
username: Schema.Literal("opencode"),
|
||||
password: Schema.String,
|
||||
}),
|
||||
)
|
||||
|
||||
export function serverAddress(value: string) {
|
||||
if (value.includes("://") && !/^https?:\/\//.test(value.trim())) return
|
||||
const normalized = normalizeServerUrl(value)
|
||||
@@ -20,42 +11,23 @@ export function serverAddress(value: string) {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function decodePairingCode(value: string, origin?: string) {
|
||||
const result = Schema.decodeUnknownOption(pairing)(value)
|
||||
if (Option.isNone(result)) return
|
||||
const urls = [
|
||||
...new Set((result.value.urls ?? (origin ? [origin] : [])).map(serverAddress).filter((url) => url !== undefined)),
|
||||
]
|
||||
if (!urls.length) return
|
||||
return { urls, password: result.value.password }
|
||||
}
|
||||
|
||||
export function pairingUrl(value: { username: "opencode"; password: string }, host: string) {
|
||||
return `${new URL("/connect", host)}#${base64Encode(JSON.stringify(value))}`
|
||||
}
|
||||
|
||||
// Scanned codes are either the raw pairing JSON or a full /connect URL from `opencode pair` or desktop.
|
||||
export function decodePairingScan(value: string) {
|
||||
// Links printed by `opencode pair` carry a single-use code that the server exchanges for a session token.
|
||||
export function pairingLink(value: string) {
|
||||
const url = URL.parse(value.trim())
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return decodePairingCode(value)
|
||||
return decodePairingUrl(url.search, url.origin) ?? decodePairingUrl(url.hash, url.origin)
|
||||
if (!url || (url.protocol !== "http:" && url.protocol !== "https:")) return
|
||||
const code = /^\/auth\/connect\/([A-Za-z0-9_-]+)$/.exec(url.pathname)?.[1]
|
||||
const address = serverAddress(url.origin)
|
||||
if (!code || !address) return
|
||||
return { url: address, code }
|
||||
}
|
||||
|
||||
export function decodePairingUrl(value: string, origin?: string) {
|
||||
if (value.startsWith("?")) {
|
||||
const data = new URLSearchParams(value).get("data")
|
||||
return data === null ? undefined : decodePairingCode(data, origin)
|
||||
}
|
||||
const encoded = value.startsWith("#") ? value.slice(1) : value
|
||||
if (!encoded) return
|
||||
const legacy = new URLSearchParams(`value=${encoded}`).get("value") ?? ""
|
||||
if (legacy.startsWith("{")) return decodePairingCode(legacy)
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(encoded) || encoded.length % 4 === 1) return
|
||||
const binary = atob(
|
||||
encoded
|
||||
.replaceAll("-", "+")
|
||||
.replaceAll("_", "/")
|
||||
.padEnd(Math.ceil(encoded.length / 4) * 4, "="),
|
||||
)
|
||||
return decodePairingCode(new TextDecoder().decode(Uint8Array.from(binary, (char) => char.charCodeAt(0))), origin)
|
||||
export type Pairing = { readonly url: string; readonly password: string }
|
||||
|
||||
export function redeemPairingLink(link: { url: string; code: string }) {
|
||||
return OpenCode.make({ baseUrl: link.url })
|
||||
.server.connect({ code: link.code })
|
||||
.then(
|
||||
(session): Pairing => ({ url: link.url, password: session.token }),
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,13 +3,10 @@ import { onCleanup, onMount, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { decodePairingScan } from "./pairing"
|
||||
import { pairingLink, redeemPairingLink, type Pairing } from "./pairing"
|
||||
import "./scanner.css"
|
||||
|
||||
export function PairingScanner(props: {
|
||||
onScan: (value: NonNullable<ReturnType<typeof decodePairingScan>>) => void
|
||||
onCancel: () => void
|
||||
}) {
|
||||
export function PairingScanner(props: { onScan: (value: Pairing) => void; onCancel: () => void }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({ error: "", ready: false })
|
||||
const video = document.createElement("video")
|
||||
@@ -22,13 +19,16 @@ export function PairingScanner(props: {
|
||||
const scanner = new QrScanner(
|
||||
video,
|
||||
(result) => {
|
||||
const pairing = decodePairingScan(result.data)
|
||||
if (!pairing) {
|
||||
const link = pairingLink(result.data)
|
||||
if (!link) {
|
||||
setState("error", language.t("server.connect.scan.invalid"))
|
||||
return
|
||||
}
|
||||
scanner.stop()
|
||||
props.onScan(pairing)
|
||||
void redeemPairingLink(link).then((redeemed) => {
|
||||
if (redeemed) return props.onScan(redeemed)
|
||||
setState("error", language.t("server.connect.link.expired"))
|
||||
})
|
||||
},
|
||||
{ preferredCamera: "environment", maxScansPerSecond: 10, returnDetailedScanResult: true },
|
||||
)
|
||||
|
||||
@@ -8,29 +8,20 @@ import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCheckServerHealth } from "@/runtime/server/health"
|
||||
import { useServers } from "@/runtime/server/registry"
|
||||
import { serverAddress } from "./pairing"
|
||||
import type { decodePairingCode } from "./pairing"
|
||||
import { pairingLink, redeemPairingLink, serverAddress } from "./pairing"
|
||||
import { isMixedContent } from "./browser"
|
||||
import { createCameraAvailability } from "./camera"
|
||||
import "./screen.css"
|
||||
|
||||
const PairingScanner = lazy(() => import("./scanner").then((module) => ({ default: module.PairingScanner })))
|
||||
|
||||
export function ConnectServerScreen(
|
||||
props: { pairing?: NonNullable<ReturnType<typeof decodePairingCode>>; onConnect?: () => void } = {},
|
||||
) {
|
||||
export function ConnectServerScreen(props: { url?: string } = {}) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const servers = useServers()
|
||||
const check = useCheckServerHealth()
|
||||
const camera = createCameraAvailability()
|
||||
const [state, setState] = createStore({
|
||||
url: props.pairing?.urls[0] ?? "",
|
||||
password: props.pairing?.password ?? "",
|
||||
urls: props.pairing?.urls ?? ([] as string[]),
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
const [state, setState] = createStore({ url: props.url ?? "", password: "", error: "", scanning: false })
|
||||
const connectionError = () =>
|
||||
language.t(
|
||||
platform.platform === "web" && isMixedContent(location.href, state.url)
|
||||
@@ -39,6 +30,16 @@ export function ConnectServerScreen(
|
||||
)
|
||||
const request = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
const link = pairingLink(state.url)
|
||||
if (link) {
|
||||
const redeemed = await redeemPairingLink(link)
|
||||
if (!redeemed) {
|
||||
setState("error", language.t("server.connect.link.expired"))
|
||||
return
|
||||
}
|
||||
// Keep the token in the form so a failed connection check can retry without the spent code.
|
||||
setState({ url: link.url, password: redeemed.password })
|
||||
}
|
||||
const url = serverAddress(state.url)
|
||||
if (!url) {
|
||||
setState("error", language.t("server.connect.address.invalid"))
|
||||
@@ -51,7 +52,6 @@ export function ConnectServerScreen(
|
||||
return
|
||||
}
|
||||
servers.add({ type: "http", http })
|
||||
props.onConnect?.()
|
||||
},
|
||||
onError: () => setState("error", connectionError()),
|
||||
}))
|
||||
@@ -76,13 +76,7 @@ export function ConnectServerScreen(
|
||||
void camera.refetch()
|
||||
}}
|
||||
onScan={(pairing) => {
|
||||
setState({
|
||||
url: pairing.urls[0],
|
||||
urls: pairing.urls,
|
||||
password: pairing.password,
|
||||
error: "",
|
||||
scanning: false,
|
||||
})
|
||||
setState({ url: pairing.url, password: pairing.password, error: "", scanning: false })
|
||||
request.mutate()
|
||||
}}
|
||||
/>
|
||||
@@ -110,18 +104,12 @@ export function ConnectServerScreen(
|
||||
spellcheck={false}
|
||||
required
|
||||
appearance="large"
|
||||
list="server-connect-addresses"
|
||||
placeholder={language.t("dialog.server.add.placeholder")}
|
||||
value={state.url}
|
||||
disabled={request.isPending}
|
||||
aria-describedby={state.error ? "server-connect-error" : undefined}
|
||||
onInput={(event) => setState({ url: event.currentTarget.value, error: "" })}
|
||||
/>
|
||||
<datalist id="server-connect-addresses">
|
||||
{state.urls.map((url) => (
|
||||
<option value={url} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="server-connect-field">
|
||||
<label for="server-connect-password">{language.t("dialog.server.add.password")}</label>
|
||||
|
||||
@@ -175,6 +175,18 @@ function SessionQueueRow(props: { queue: SessionQueueView; id: string; index: nu
|
||||
{props.queue.working() ? language.t("session.queue.steer") : language.t("session.queue.send")}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip placement="top" value={language.t("session.queue.moveBack")}>
|
||||
<IconButton
|
||||
data-action="session-queue-move-back"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="arrow-undo-down" />}
|
||||
disabled={props.queue.busy()}
|
||||
aria-label={language.t("session.queue.moveBack")}
|
||||
onClick={() => props.queue.moveBack(props.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
<Tooltip placement="top" value={language.t("session.queue.remove")}>
|
||||
<IconButton
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { queuedPromptAttachments, queuedPromptRows } from "./queue"
|
||||
import { queuedPromptAttachments, queuedPromptMoveBackDraft, queuedPromptRows } from "./queue"
|
||||
|
||||
const queued = [
|
||||
{
|
||||
@@ -104,3 +104,43 @@ describe("queuedPromptAttachments", () => {
|
||||
expect(queuedPromptAttachments(item)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("queuedPromptMoveBackDraft", () => {
|
||||
test("keeps full text, structured mentions, and inline images", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "inspect @main.ts with @build",
|
||||
files: [
|
||||
{
|
||||
data: "aGk=",
|
||||
mime: "text/plain",
|
||||
source: { type: "uri" as const, uri: "file:///repo/main.ts" },
|
||||
name: "main.ts",
|
||||
mention: { start: 8, end: 16, text: "@main.ts" },
|
||||
},
|
||||
{ data: "aGk=", mime: "image/png", source: { type: "inline" as const }, name: "shot.png" },
|
||||
],
|
||||
agents: [{ name: "build", mention: { start: 22, end: 28, text: "@build" } }],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
expect(queuedPromptMoveBackDraft(item)).toMatchObject([
|
||||
{ type: "text", content: "inspect " },
|
||||
{ type: "file", content: "@main.ts", url: "data:text/plain;base64,aGk=" },
|
||||
{ type: "text", content: " with " },
|
||||
{ type: "agent", content: "@build", name: "build" },
|
||||
{ type: "image", filename: "shot.png" },
|
||||
])
|
||||
})
|
||||
|
||||
test("does not drop hidden file context", () => {
|
||||
const item = {
|
||||
...queued[0],
|
||||
payload: {
|
||||
text: "inspect this",
|
||||
files: [{ data: "aGk=", mime: "text/plain", source: { type: "uri" as const, uri: "file:///repo/main.ts" } }],
|
||||
},
|
||||
} satisfies SessionInboxInfo
|
||||
expect(queuedPromptMoveBackDraft(item)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import type { SessionInboxInfo } from "@opencode/client/promise"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Skill } from "@opencode/schema/skill"
|
||||
import type { ComposerDelivery } from "@/composer/adapter"
|
||||
import type { ComposerStateTarget } from "@/composer/submission-state"
|
||||
import type { ImageAttachmentPart, PathAttachmentPart, Prompt } from "@/composer/state"
|
||||
@@ -42,6 +43,7 @@ export function createSessionQueue(input: {
|
||||
mutationFn: async (
|
||||
change:
|
||||
| { type: "reorder"; inboxIDs: string[] }
|
||||
| { type: "move-back"; item: QueuedPrompt; prompt: Prompt }
|
||||
| {
|
||||
type: "edit"
|
||||
inboxIDs: string[]
|
||||
@@ -54,6 +56,13 @@ export function createSessionQueue(input: {
|
||||
},
|
||||
) => {
|
||||
if (change.type === "reorder") return rewrite(change.inboxIDs)
|
||||
if (change.type === "move-back") {
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
|
||||
input.draft.mode.set("normal")
|
||||
input.draft.set(change.prompt, promptLength(change.prompt))
|
||||
input.restoreFocus(promptLength(change.prompt))
|
||||
return
|
||||
}
|
||||
const replacement = await editedPromptInput(
|
||||
input.sessionID,
|
||||
location().directory,
|
||||
@@ -139,6 +148,25 @@ export function createSessionQueue(input: {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
const moveBack = (id: string) => {
|
||||
if (mutation.isPending || state.editing) return
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
if (
|
||||
input.draft.current().some((part) => ("content" in part ? !!part.content.length : true)) ||
|
||||
input.draft.mode.current() !== "normal" ||
|
||||
input.draft.retry.current()
|
||||
) {
|
||||
showToast({ title: language.t("session.queue.moveBackDraft") })
|
||||
return
|
||||
}
|
||||
const prompt = queuedPromptMoveBackDraft(item)
|
||||
if (!prompt) {
|
||||
showToast({ title: language.t("session.queue.moveBackUnavailable") })
|
||||
return
|
||||
}
|
||||
mutation.mutate({ type: "move-back", item, prompt })
|
||||
}
|
||||
const reorder = (inboxIDs: string[]) => {
|
||||
if (mutation.isPending) return Promise.resolve()
|
||||
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
|
||||
@@ -226,9 +254,11 @@ export function createSessionQueue(input: {
|
||||
editFirst,
|
||||
rows,
|
||||
busy: () => mutation.isPending,
|
||||
movingBack: () => mutation.isPending && mutation.variables?.type === "move-back",
|
||||
working: input.working,
|
||||
steer,
|
||||
remove,
|
||||
moveBack,
|
||||
edit,
|
||||
reorder,
|
||||
}
|
||||
@@ -239,7 +269,7 @@ export type SessionQueue = ReturnType<typeof createSessionQueue>
|
||||
// The slice of the queue the panel renders and drives.
|
||||
export type SessionQueueView = Pick<
|
||||
SessionQueue,
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "edit" | "reorder"
|
||||
"rows" | "editing" | "working" | "busy" | "steer" | "remove" | "moveBack" | "edit" | "reorder"
|
||||
>
|
||||
|
||||
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
|
||||
@@ -249,7 +279,8 @@ export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
text: queuedPromptText(item),
|
||||
attachments: (item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
attachments:
|
||||
(item.payload.files?.length ?? 0) + (readPromptPresentation(item.payload.metadata)?.attachments.length ?? 0),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -287,6 +318,88 @@ export function queuedPromptAttachments(item: QueuedPrompt): (ImageAttachmentPar
|
||||
]
|
||||
}
|
||||
|
||||
// Use the full model-visible text so comment notes and path references remain
|
||||
// in the draft. Convert mentioned files, agents, and skills back into editor
|
||||
// parts; a detached draft cannot represent non-mentioned file context.
|
||||
export function queuedPromptMoveBackDraft(item: QueuedPrompt): Prompt | undefined {
|
||||
if (
|
||||
item.payload.files?.some((file) => !isComposerAttachment(file) && !file.mention) ||
|
||||
item.payload.agents?.some((agent) => !agent.mention) ||
|
||||
item.payload.skills?.some((skill) => !skill.mention)
|
||||
)
|
||||
return
|
||||
const text = item.payload.text
|
||||
const references = [
|
||||
...(item.payload.files ?? []).flatMap((file) =>
|
||||
file.mention
|
||||
? [
|
||||
{
|
||||
type: "file" as const,
|
||||
content: file.mention.text,
|
||||
start: file.mention.start,
|
||||
end: file.mention.end,
|
||||
path: file.name ?? file.mention.text.replace(/^@/, ""),
|
||||
filename: file.name,
|
||||
mime: file.mime,
|
||||
url: `data:${file.mime};base64,${file.data}`,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.agents ?? []).flatMap((agent) =>
|
||||
agent.mention
|
||||
? [
|
||||
{
|
||||
type: "agent" as const,
|
||||
content: agent.mention.text,
|
||||
start: agent.mention.start,
|
||||
end: agent.mention.end,
|
||||
name: agent.name,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
...(item.payload.skills ?? []).flatMap((skill) =>
|
||||
skill.mention
|
||||
? [
|
||||
{
|
||||
type: "skill" as const,
|
||||
content: skill.mention.text,
|
||||
start: skill.mention.start,
|
||||
end: skill.mention.end,
|
||||
id: Skill.ID.make(skill.id),
|
||||
name: Skill.Name.make(skill.name),
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
].sort((left, right) => left.start - right.start)
|
||||
if (
|
||||
references.some(
|
||||
(part, index) =>
|
||||
part.start < (references[index - 1]?.end ?? 0) || text.slice(part.start, part.end) !== part.content,
|
||||
)
|
||||
)
|
||||
return
|
||||
const parts: Prompt = references.flatMap((part, index) => {
|
||||
const start = references[index - 1]?.end ?? 0
|
||||
return [
|
||||
...(part.start > start
|
||||
? [{ type: "text" as const, content: text.slice(start, part.start), start, end: part.start }]
|
||||
: []),
|
||||
part,
|
||||
]
|
||||
})
|
||||
const start = references.at(-1)?.end ?? 0
|
||||
return [
|
||||
...parts,
|
||||
...(text.length > start || !parts.length
|
||||
? [{ type: "text" as const, content: text.slice(start), start, end: text.length }]
|
||||
: []),
|
||||
...queuedPromptAttachments(item).filter((part) => part.type === "image"),
|
||||
]
|
||||
}
|
||||
|
||||
function isComposerAttachment(file: NonNullable<QueuedPrompt["payload"]["files"]>[number]) {
|
||||
return !file.mention && file.source.type === "inline"
|
||||
}
|
||||
|
||||
@@ -224,7 +224,12 @@ export function ActiveSessionComposerRegion(props: {
|
||||
<div class="relative">
|
||||
<SessionQueuePanel queue={props.model.queue} />
|
||||
<div class="relative z-10">
|
||||
<Composer model={props.model.composer} borderUnderlay suggestionBoundary={props.suggestionBoundary} />
|
||||
<Composer
|
||||
model={props.model.composer}
|
||||
borderUnderlay
|
||||
readOnly={props.model.queue.movingBack()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createMemo, Show, type ParentProps } from "solid-js"
|
||||
import { createMemo, For, Show, type ParentProps } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
@@ -191,12 +191,96 @@ export function SessionProjectMenu(props: {
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionAncestorTrail(props: {
|
||||
sessionID: string
|
||||
parentID: string
|
||||
parentTitle?: string
|
||||
trailing: boolean
|
||||
}) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const navigate = useNavigate()
|
||||
const language = useLanguage()
|
||||
const ancestors = createMemo(() => {
|
||||
const path: { id: string; title: string; direct: boolean }[] = []
|
||||
const seen = new Set([props.sessionID])
|
||||
let id: string | undefined = props.parentID
|
||||
while (id && !seen.has(id)) {
|
||||
seen.add(id)
|
||||
const info = server.ctx.data.session.get(id)
|
||||
path.unshift({
|
||||
id,
|
||||
title:
|
||||
sessionTitle(info?.title ?? (id === props.parentID ? props.parentTitle : undefined)) ??
|
||||
language.t("session.tab.session"),
|
||||
direct: id === props.parentID,
|
||||
})
|
||||
id = info?.parentID
|
||||
}
|
||||
return path
|
||||
})
|
||||
const open = (id: string) => {
|
||||
const tab = tabs.store.find(
|
||||
(item) =>
|
||||
item.type === "session" &&
|
||||
item.server === server.key &&
|
||||
(item.sessionId === props.sessionID || item.routeSessionId === props.sessionID),
|
||||
)
|
||||
if (tab?.type === "session") tabs.rememberSessionRoute(tab, id, server.ctx.data.session.get(id)?.parentID)
|
||||
navigate(sessionHref(server.key, id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="flex min-w-0 max-w-full items-center">
|
||||
<div
|
||||
data-slot="session-title-ancestors"
|
||||
class="flex min-w-0 items-center overflow-x-auto whitespace-nowrap [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
>
|
||||
<For each={ancestors()}>
|
||||
{(ancestor, index) => (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
data-slot={ancestor.direct ? "session-title-parent" : "session-title-ancestor"}
|
||||
data-session-id={ancestor.id}
|
||||
title={ancestor.title}
|
||||
dir="auto"
|
||||
class="max-w-[min(200px,40vw)] shrink-0 truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={() => open(ancestor.id)}
|
||||
>
|
||||
{ancestor.title}
|
||||
</button>
|
||||
<Show when={index() < ancestors().length - 1}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<Show when={props.trailing}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] shrink-0 pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
|
||||
const server = useServer()
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const pending = createMemo(() => tabs.pendingSession(server.key, props.sessionID))
|
||||
const navigate = useNavigate()
|
||||
const tab = createMemo(() =>
|
||||
tabs.store.find(
|
||||
(item) =>
|
||||
@@ -250,53 +334,30 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
)
|
||||
})
|
||||
const workspaceSession = createMemo(() => !!pending() || isWorkspaceDirectory(project(), directory() ?? ""))
|
||||
const navigateParent = () => {
|
||||
const id = parentID()
|
||||
const current = tab()
|
||||
if (!id || current?.type !== "session") return
|
||||
tabs.rememberSessionRoute(current, id, parent()?.parentID)
|
||||
navigate(sessionHref(server.key, id))
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={title() || parentTitle()}>
|
||||
<SessionTitleHeader>
|
||||
<div class="flex h-12 w-full items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-1">
|
||||
<div class="flex min-w-0 w-full flex-1 items-center gap-0.5">
|
||||
<SessionProjectMenu
|
||||
project={project()}
|
||||
directory={directory()}
|
||||
workspace={workspaceSession()}
|
||||
/>
|
||||
<Show when={parentTitle()}>
|
||||
{(value) => (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
dir="auto"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={navigateParent}
|
||||
>
|
||||
{value()}
|
||||
</button>
|
||||
<SessionProjectMenu project={project()} directory={directory()} workspace={workspaceSession()} />
|
||||
<Show when={parentID()}>
|
||||
{(id) => (
|
||||
<SessionAncestorTrail
|
||||
sessionID={props.sessionID}
|
||||
parentID={id()}
|
||||
parentTitle={parentTitle()}
|
||||
trailing={!!title()}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={parentTitle() && title()}>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
</Show>
|
||||
<Show when={title()}>
|
||||
{(value) => (
|
||||
<h1
|
||||
data-slot={parentID() ? "session-title-child" : undefined}
|
||||
dir="auto"
|
||||
class="w-fit truncate rounded-[6px] px-1 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
|
||||
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
|
||||
>
|
||||
{value()}
|
||||
</h1>
|
||||
|
||||
@@ -34,7 +34,7 @@ import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
import { SessionHeaderSpacer } from "@/session/header/session-header"
|
||||
import type { BackgroundTask } from "../summary/background"
|
||||
|
||||
@@ -413,21 +413,14 @@ function MessageTimelineView(
|
||||
workspace={workspaceSession()}
|
||||
/>
|
||||
<Show when={parentID()}>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="session-title-parent"
|
||||
class="min-w-0 max-w-[40%] truncate pl-2 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:text-v2-text-text-muted"
|
||||
onClick={props.action.navigateParent}
|
||||
>
|
||||
{parentTitle()}
|
||||
</button>
|
||||
<span
|
||||
data-slot="session-title-separator"
|
||||
class="-translate-y-[0.5px] pl-2 pr-1 text-[11px] font-medium text-v2-text-text-faint"
|
||||
aria-hidden="true"
|
||||
>
|
||||
/
|
||||
</span>
|
||||
{(id) => (
|
||||
<SessionAncestorTrail
|
||||
sessionID={sessionID() ?? ""}
|
||||
parentID={id()}
|
||||
parentTitle={parentTitle()}
|
||||
trailing={!!(childTitle() || title.editing)}
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={childTitle() || title.editing}>
|
||||
<Show
|
||||
@@ -436,6 +429,7 @@ function MessageTimelineView(
|
||||
<h1
|
||||
data-slot="session-title-child"
|
||||
class="truncate text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base w-fit rounded-[6px] px-1 py-1 hover:bg-v2-overlay-simple-overlay-hover"
|
||||
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
|
||||
onClick={openTitleEditor}
|
||||
>
|
||||
{childTitle()}
|
||||
@@ -451,6 +445,7 @@ function MessageTimelineView(
|
||||
value={title.draft}
|
||||
disabled={props.pending.rename()}
|
||||
class="block text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base field-sizing-content rounded-[6px] px-1 py-1"
|
||||
classList={{ "max-w-[45%] shrink-0": !!parentID() }}
|
||||
style={{
|
||||
"--inline-input-shadow": "none",
|
||||
"text-align": "start",
|
||||
|
||||
@@ -394,7 +394,7 @@ export const SettingsGeneral: Component = () => {
|
||||
</div>
|
||||
</SettingsRow>
|
||||
|
||||
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
|
||||
<Show when={mobile()}>
|
||||
<SettingsRow
|
||||
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
|
||||
description={language.t("settings.general.row.mobileTitlebarBottom.description")}
|
||||
|
||||
@@ -8,8 +8,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { createEffect, createMemo, onCleanup, Show } from "solid-js"
|
||||
import { renderSVG } from "uqr"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform, type PairingInfo } from "@/runtime/platform/platform"
|
||||
import { pairingUrl } from "@/servers/connect/pairing"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { SettingsRow } from "@/settings/row"
|
||||
|
||||
@@ -74,8 +73,8 @@ export function SettingsPairing() {
|
||||
dialog.push(() => (
|
||||
<DialogPairing
|
||||
title={language.t("settings.pairing.connection")}
|
||||
info={localInfo()}
|
||||
host={localHost()!}
|
||||
code={pair.code}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@@ -117,17 +116,19 @@ export function SettingsPairing() {
|
||||
)
|
||||
}
|
||||
|
||||
function DialogPairing(props: { title: string; info: PairingInfo | null | undefined; host: string }) {
|
||||
function DialogPairing(props: { title: string; host: string; code: () => Promise<string> }) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
// Codes are single-use, so keep replacing the link while the dialog is open.
|
||||
const code = useQuery(() => ({
|
||||
queryKey: ["pairing", "code"],
|
||||
queryFn: props.code,
|
||||
gcTime: 0,
|
||||
refetchInterval: 60_000,
|
||||
}))
|
||||
const url = createMemo(() => {
|
||||
if (!props.info) return
|
||||
return pairingUrl({ username: props.info.username, password: props.info.password }, props.host)
|
||||
})
|
||||
const origin = createMemo(() => {
|
||||
const value = url()
|
||||
if (!value) return
|
||||
return new URL(value).origin
|
||||
if (!code.isSuccess) return
|
||||
return new URL(`/auth/connect/${code.data}`, props.host).href
|
||||
})
|
||||
const copy = useMutation(() => ({
|
||||
mutationFn: async () => {
|
||||
@@ -153,7 +154,7 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
|
||||
<DialogTitleGroup title={props.title} description={language.t("pair.description")} />
|
||||
</DialogHeader>
|
||||
<DialogBody class="flex flex-col gap-4 px-4 pb-4">
|
||||
<Show when={props.info}>
|
||||
<Show when={url()}>
|
||||
<div
|
||||
class="aspect-square w-full shrink-0 rounded-[6px] bg-v2-background-bg-base p-6 text-v2-text-text-base [&>svg]:size-full"
|
||||
role="img"
|
||||
@@ -176,12 +177,17 @@ function DialogPairing(props: { title: string; info: PairingInfo | null | undefi
|
||||
>
|
||||
<Icon name={copy.isSuccess ? "check" : "copy"} size="small" class="shrink-0" />
|
||||
<bdi dir="ltr" class="min-w-0 break-all text-start">
|
||||
{origin()}
|
||||
{new URL(props.host).origin}
|
||||
</bdi>
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={code.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.error")}
|
||||
</p>
|
||||
</Show>
|
||||
<Show when={copy.error}>
|
||||
<p class="text-text-danger-base" role="alert">
|
||||
{language.t("pair.copy.error")}
|
||||
|
||||
@@ -12,7 +12,7 @@ type Entry<Tab> = {
|
||||
description?: Label
|
||||
section?: Label
|
||||
subtab?: "mcps" | "plugins" | "skills" | "lsps"
|
||||
available?: "desktop" | "browser" | "dev" | "mobile-dev"
|
||||
available?: "desktop" | "browser" | "mobile"
|
||||
}
|
||||
|
||||
export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
@@ -83,7 +83,7 @@ export const clientSettings: Entry<SettingsRootTab>[] = [
|
||||
tab: "general",
|
||||
label: "settings.general.row.mobileTitlebarBottom.title",
|
||||
target: "settings-mobile-titlebar-bottom",
|
||||
available: "mobile-dev",
|
||||
available: "mobile",
|
||||
},
|
||||
{
|
||||
tab: "general",
|
||||
|
||||
@@ -17,7 +17,6 @@ export function settingsSearchIndex(input: {
|
||||
servers: readonly SettingsSearchServer[]
|
||||
desktop: boolean
|
||||
browser: boolean
|
||||
dev: boolean
|
||||
mobile: boolean
|
||||
translate: ReturnType<typeof useLanguage>["t"]
|
||||
}) {
|
||||
@@ -57,8 +56,7 @@ export function settingsSearchIndex(input: {
|
||||
clientSettings.forEach((entry) => {
|
||||
if (entry.available === "desktop" && !input.desktop) return
|
||||
if (entry.available === "browser" && !input.browser) return
|
||||
if ((entry.available === "dev" || entry.available === "mobile-dev") && !input.dev) return
|
||||
if (entry.available === "mobile-dev" && !input.mobile) return
|
||||
if (entry.available === "mobile" && !input.mobile) return
|
||||
add(entry, { type: "root", tab: entry.tab, target: entry.target }, "")
|
||||
})
|
||||
input.servers.forEach((server) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ function index(input: Partial<Parameters<typeof settingsSearchIndex>[0]> = {}) {
|
||||
servers,
|
||||
desktop: false,
|
||||
browser: false,
|
||||
dev: false,
|
||||
mobile: false,
|
||||
translate: (key) => strings[key],
|
||||
...input,
|
||||
@@ -49,7 +48,7 @@ describe("settings search index", () => {
|
||||
expect(rankSettings("font", items, root)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("only advertises settings supported by this platform and channel", () => {
|
||||
test("only advertises settings supported by this platform", () => {
|
||||
const targets = (input: Parameters<typeof index>[0]) => index(input).map((item) => item.view.target)
|
||||
expect(targets({})).not.toContain("settings-pinch-zoom")
|
||||
expect(targets({})).not.toContain("settings-experimental-browser")
|
||||
@@ -57,9 +56,9 @@ describe("settings search index", () => {
|
||||
expect(targets({})).toContain("settings-tab-layout")
|
||||
expect(targets({ desktop: true })).toContain("settings-pinch-zoom")
|
||||
expect(targets({ browser: true })).not.toContain("settings-experimental-browser")
|
||||
expect(targets({ dev: true })).not.toContain("settings-show-project-icon")
|
||||
expect(targets({ dev: true })).not.toContain("settings-mobile-titlebar-bottom")
|
||||
expect(targets({ dev: true, mobile: true })).toContain("settings-mobile-titlebar-bottom")
|
||||
expect(targets({})).not.toContain("settings-show-project-icon")
|
||||
expect(targets({})).not.toContain("settings-mobile-titlebar-bottom")
|
||||
expect(targets({ mobile: true })).toContain("settings-mobile-titlebar-bottom")
|
||||
})
|
||||
|
||||
test("uses section labels and stable identities independent of translated text", () => {
|
||||
|
||||
@@ -75,7 +75,6 @@ export function SettingsSearch() {
|
||||
servers: inventory(),
|
||||
desktop: platform.platform === "desktop",
|
||||
browser: !!platform.browserPane,
|
||||
dev: import.meta.env.VITE_OPENCODE_CHANNEL !== "prod",
|
||||
mobile: mobile(),
|
||||
translate: language.t,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Route, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, onMount, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Route, useParams } from "@solidjs/router"
|
||||
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
|
||||
import { Home } from "@/home/route"
|
||||
import { ServerProvider } from "@/runtime/server/current"
|
||||
import { useGlobal } from "@/runtime/server/runtime"
|
||||
@@ -10,7 +10,6 @@ import { LayoutProvider } from "@/shell/state/layout"
|
||||
import { SettingsSurfaceProvider } from "@/settings/surface"
|
||||
import Shell from "@/shell/shell"
|
||||
import { requireServerKey } from "./session"
|
||||
import { decodePairingUrl } from "@/servers/connect/pairing"
|
||||
import { DesktopPairingCommand } from "@/shell/commands/desktop"
|
||||
|
||||
export const File = lazy(() => import("@opencode/session-ui/file").then((module) => ({ default: module.File })))
|
||||
@@ -28,7 +27,6 @@ export function preloadRoute(url: string) {
|
||||
const pathname = url.split(/[?#]/, 1)[0]
|
||||
if (pathname === "/new-session") return DraftRoute.preload().then(() => undefined)
|
||||
if (pathname === "/settings") return SettingsScreen.preload().then(() => undefined)
|
||||
if (pathname === "/connect") return ConnectServerScreen.preload().then(() => undefined)
|
||||
if (/^\/server\/[^/]+\/session\/[^/]+$/.test(pathname))
|
||||
return TargetSessionRouteContent.preload().then(() => undefined)
|
||||
return Promise.resolve()
|
||||
@@ -36,48 +34,29 @@ export function preloadRoute(url: string) {
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<>
|
||||
<Route path="/connect" component={ConnectRoute} />
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectRoute() {
|
||||
const navigate = useNavigate()
|
||||
const servers = useServers()
|
||||
const pairing = decodePairingUrl(location.search, location.origin) ?? decodePairingUrl(location.hash, location.origin)
|
||||
onMount(() => {
|
||||
if (!pairing) return
|
||||
servers.add({ type: "http", http: { url: pairing.urls[0], password: pairing.password } })
|
||||
navigate("/", { replace: true })
|
||||
})
|
||||
return (
|
||||
<Show when={!pairing}>
|
||||
<ConnectServerScreen onConnect={() => navigate("/", { replace: true })} />
|
||||
</Show>
|
||||
<Route component={AppLayout}>
|
||||
<Route path="/" component={Home} />
|
||||
<Route path="/settings" component={SettingsScreen} />
|
||||
<Route
|
||||
path="/server/:serverKey/session/:id"
|
||||
component={() => (
|
||||
<SessionRouteFrame>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="flex min-h-0 flex-1 px-2 pb-[var(--shell-bottom-inset,8px)] pt-[var(--shell-top-inset,8px)]">
|
||||
<SessionPanelFrame raised />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TargetServerRoute>
|
||||
<TargetSessionRouteContent />
|
||||
</TargetServerRoute>
|
||||
</Suspense>
|
||||
</SessionRouteFrame>
|
||||
)}
|
||||
/>
|
||||
<Route path="/new-session" component={DraftRoute} />
|
||||
</Route>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -97,8 +76,15 @@ function TargetServerRoute(props: ParentProps) {
|
||||
|
||||
function AppLayout(props: ParentProps) {
|
||||
const servers = useServers()
|
||||
const global = useGlobal()
|
||||
// A lone server that rejects our credentials (e.g. the web app before pairing) has nothing else to show.
|
||||
const signedOut = () => {
|
||||
const only = servers.list.length === 1 ? servers.list[0] : undefined
|
||||
if (only?.type !== "http") return
|
||||
return global.servers.health[ServerConnection.key(only)]?.unauthorized ? only : undefined
|
||||
}
|
||||
return (
|
||||
<Show when={servers.list.length > 0} fallback={<ConnectServerScreen />}>
|
||||
<Show when={servers.list.length > 0 && !signedOut()} fallback={<ConnectServerScreen url={signedOut()?.http.url} />}>
|
||||
<LayoutProvider>
|
||||
<SettingsSurfaceProvider>
|
||||
<DesktopPairingCommand />
|
||||
|
||||
@@ -33,6 +33,7 @@ import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { updaterAction } from "@/shell/updates/action"
|
||||
import type { UpdaterState } from "@/shell/updates/types"
|
||||
import { rootSession } from "@/shell/routes/session"
|
||||
import devIcon from "../../../../desktop/icons/dev/64x64.png"
|
||||
import betaIcon from "../../../../desktop/icons/beta/64x64.png"
|
||||
|
||||
@@ -185,7 +186,7 @@ export function Titlebar(props: {
|
||||
const route = layout.route()
|
||||
return route.type === "session" && !!tabs.pendingSession(route.server, route.sessionId)
|
||||
})
|
||||
const [loadedSession] = createResource(
|
||||
const [resolvedSession] = createResource(
|
||||
() => {
|
||||
const route = layout.route()
|
||||
if (route.type !== "session") return undefined
|
||||
@@ -193,7 +194,23 @@ export function Titlebar(props: {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
return conn ? { route, ctx: global.ensureServerCtx(conn) } : undefined
|
||||
},
|
||||
({ route, ctx }) => ctx.sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
|
||||
async ({ route, ctx }) => {
|
||||
const info = await ctx.sdk.api.session
|
||||
.get({ sessionID: route.sessionId })
|
||||
.catch(() => ctx.data.session.get(route.sessionId))
|
||||
if (!info) return
|
||||
ctx.data.session.remember(info)
|
||||
const rootID = await rootSession(info, async (id) => {
|
||||
const cached = ctx.data.session.get(id)
|
||||
if (cached) return cached
|
||||
const ancestor = await ctx.sdk.api.session.get({ sessionID: id })
|
||||
ctx.data.session.remember(ancestor)
|
||||
return ancestor
|
||||
})
|
||||
.then((root) => root.id)
|
||||
.catch(() => ctx.data.session.root(info.id))
|
||||
return { info, rootID }
|
||||
},
|
||||
)
|
||||
const session = createMemo(() => {
|
||||
const route = layout.route()
|
||||
@@ -202,8 +219,8 @@ export function Titlebar(props: {
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
|
||||
const cached = conn ? global.ensureServerCtx(conn).data.session.get(route.sessionId) : undefined
|
||||
if (cached) return cached
|
||||
const loaded = loadedSession()
|
||||
return loaded?.id === route.sessionId ? loaded : undefined
|
||||
const resolved = resolvedSession()
|
||||
return resolved?.info.id === route.sessionId ? resolved.info : undefined
|
||||
})
|
||||
|
||||
const matchRoute = (route: LayoutRoute) => {
|
||||
@@ -221,7 +238,8 @@ export function Titlebar(props: {
|
||||
if (main) return main
|
||||
const s = session()
|
||||
if (s?.parentID) {
|
||||
const parentID = s.parentID
|
||||
const resolved = resolvedSession()
|
||||
const parentID = resolved?.info.id === s.id ? resolved.rootID : s.parentID
|
||||
const parent = tabsStore.find(
|
||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
||||
)
|
||||
@@ -256,7 +274,9 @@ export function Titlebar(props: {
|
||||
}
|
||||
const s = session()
|
||||
if (!s) return
|
||||
const sessionId = s.parentID ?? s.id
|
||||
const resolved = resolvedSession()
|
||||
if (s.parentID && resolved?.info.id !== s.id) return
|
||||
const sessionId = resolved?.info.id === s.id ? resolved.rootID : s.id
|
||||
const next = { server: route.server, sessionId }
|
||||
tabsStoreActions.addSessionTab(next)
|
||||
}
|
||||
|
||||
@@ -18,7 +18,8 @@ export function serviceWorker(directory: string) {
|
||||
skipWaiting: false,
|
||||
inlineWorkboxRuntime: true,
|
||||
navigateFallback: "/index.html",
|
||||
navigateFallbackDenylist: [/^\/api(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
|
||||
// Pairing links must reach the server so it can set the session cookie.
|
||||
navigateFallbackDenylist: [/^\/(?:api|auth)(?:\/|$)/, /^\/(?:_assets|assets)(?:\/|$)/],
|
||||
// Include lazy chunks and non-JS dependencies, not just the startup bundle.
|
||||
globPatterns: ["**/*"],
|
||||
globIgnores: ["**/*.map", "_headers", "_redirects"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -495,10 +495,10 @@ const Root = Spec.make(typeof OPENCODE_CLI_NAME === "string" ? OPENCODE_CLI_NAME
|
||||
},
|
||||
}),
|
||||
Spec.make("pair", {
|
||||
description: "Show server pairing information",
|
||||
description: "Print one-time links to connect a browser or app",
|
||||
params: {
|
||||
url: Flag.string("url").pipe(
|
||||
Flag.withDescription("Advertise an external HTTP(S) server URL in the pairing QR code"),
|
||||
Flag.withDescription("Use an external HTTP(S) server URL in pairing links"),
|
||||
Flag.mapTryCatch(
|
||||
(value) => {
|
||||
const url = new URL(value)
|
||||
|
||||
@@ -2,7 +2,6 @@ import { EOL } from "os"
|
||||
import { Effect, Option } from "effect"
|
||||
import { Service } from "@opencode/client/effect/service"
|
||||
import { OpenCode } from "@opencode/client/promise"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { renderUnicodeCompact } from "uqr"
|
||||
import { Commands } from "../commands"
|
||||
import { Runtime } from "../../framework/runtime"
|
||||
@@ -12,34 +11,25 @@ export default Runtime.handler(
|
||||
Commands.commands.pair,
|
||||
Effect.fn("cli.pair")(function* (input: Runtime.Input<typeof Commands.commands.pair>) {
|
||||
const endpoint = yield* Service.ensure(yield* ServiceConfig.options())
|
||||
const password = yield* ServiceConfig.password()
|
||||
const client = OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) })
|
||||
const urls = Option.isSome(input.url)
|
||||
? [input.url.value]
|
||||
: (yield* Effect.tryPromise(() =>
|
||||
OpenCode.make({ baseUrl: endpoint.url, headers: Service.headers(endpoint) }).server.info(),
|
||||
)).urls
|
||||
const info = { urls, username: "opencode", password }
|
||||
const link = info.urls[0]
|
||||
? `${new URL("/connect", info.urls[0])}#${base64Encode(JSON.stringify({ username: info.username, password }))}`
|
||||
: undefined
|
||||
: (yield* Effect.tryPromise(() => client.server.info())).urls
|
||||
const pairing = yield* Effect.tryPromise(() => client.server.pair())
|
||||
const links = urls.map((url) => new URL(`/auth/connect/${pairing.code}`, url).href)
|
||||
process.stdout.write(
|
||||
[
|
||||
"",
|
||||
` URLs ${info.urls[0] ?? "(none)"}`,
|
||||
...info.urls.slice(1).map((url) => ` ${url}`),
|
||||
` Username ${info.username}`,
|
||||
` Password ${info.password}`,
|
||||
...(link
|
||||
` Open a link to connect. Links work once and expire in ${Math.round(pairing.expires_in / 60)} minutes.`,
|
||||
"",
|
||||
...(links.length ? links.map((link) => ` ${link}`) : [" (no server URLs)"]),
|
||||
...(links[0]
|
||||
? [
|
||||
"",
|
||||
" Scan to pair",
|
||||
"",
|
||||
renderUnicodeCompact(link, { border: 2 })
|
||||
renderUnicodeCompact(links[0], { border: 2 })
|
||||
.split(EOL)
|
||||
.map((line) => " " + line)
|
||||
.join(EOL),
|
||||
"",
|
||||
` Link ${link}`,
|
||||
]
|
||||
: []),
|
||||
"",
|
||||
@@ -47,8 +37,17 @@ export default Runtime.handler(
|
||||
)
|
||||
|
||||
if (Option.isSome(input.url)) return
|
||||
const hostname = new URL(endpoint.url).hostname
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(hostname)) return
|
||||
process.stderr.write(` Run \`opencode service set hostname 0.0.0.0\` to access the service remotely.${EOL}${EOL}`)
|
||||
const url = new URL(endpoint.url)
|
||||
if (!["localhost", "127.0.0.1", "[::1]"].includes(url.hostname)) return
|
||||
process.stderr.write(
|
||||
[
|
||||
` Over SSH? Forward the port, then open the link on your machine:`,
|
||||
` ssh -L ${url.port}:${url.hostname}:${url.port} <host>`,
|
||||
` If port ${url.port} is busy locally, forward another port and use it in the link.`,
|
||||
"",
|
||||
" To connect from other devices, run `opencode service set hostname 0.0.0.0`.",
|
||||
"",
|
||||
].join(EOL) + EOL,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -13,8 +13,13 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
const url = new URL(request.url, "http://localhost")
|
||||
// Serve the web shell before API authentication so /connect can load credentials in JavaScript.
|
||||
if (url.pathname === "/api" || url.pathname.startsWith("/api/") || url.pathname === "/openapi.json")
|
||||
// Serve the web shell before API authentication so a signed-out browser gets the app's sign-in screen.
|
||||
if (
|
||||
url.pathname === "/api" ||
|
||||
url.pathname.startsWith("/api/") ||
|
||||
url.pathname.startsWith("/auth/") ||
|
||||
url.pathname === "/openapi.json"
|
||||
)
|
||||
return yield* api.pipe(
|
||||
Effect.catchIf(isRouteNotFound, () => Effect.succeed(HttpServerResponse.empty({ status: 404 }))),
|
||||
)
|
||||
|
||||
@@ -35,7 +35,7 @@ describe("web UI", () => {
|
||||
yield* Effect.forEach(
|
||||
[
|
||||
"/",
|
||||
"/connect?data=%7B%7D",
|
||||
"/settings",
|
||||
"/workspace/example",
|
||||
"/_assets/app.js",
|
||||
"/_assets/app.css",
|
||||
@@ -68,6 +68,24 @@ describe("web UI", () => {
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => response.json())).toHaveProperty("pid")
|
||||
|
||||
const pairing = yield* Effect.promise(() =>
|
||||
fetch(new URL("/api/pair", origin), {
|
||||
method: "POST",
|
||||
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
|
||||
}).then((response) => response.json() as Promise<{ code: string }>),
|
||||
)
|
||||
const redirect = yield* Effect.promise(() =>
|
||||
fetch(new URL(`/auth/connect/${pairing.code}`, origin), {
|
||||
redirect: "manual",
|
||||
headers: { accept: "text/html" },
|
||||
}),
|
||||
)
|
||||
expect(redirect.status).toBe(302)
|
||||
const cookie = (redirect.headers.get("set-cookie") ?? "").split(";")[0]
|
||||
const authorized = yield* Effect.promise(() => fetch(new URL("/api/info", origin), { headers: { cookie } }))
|
||||
expect(authorized.status).toBe(200)
|
||||
yield* Effect.promise(() => authorized.arrayBuffer())
|
||||
}).pipe(Effect.provide(NodeFileSystem.layer)),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -47,8 +47,17 @@ export type ServerInfoOutput = {
|
||||
}
|
||||
export type ServerInfoOperation<E = never> = () => Effect.Effect<ServerInfoOutput, E>
|
||||
|
||||
export type ServerPairOutput = { readonly code: string; readonly expires_in: number }
|
||||
export type ServerPairOperation<E = never> = () => Effect.Effect<ServerPairOutput, E>
|
||||
|
||||
export type ServerConnectInput = { readonly code: string }
|
||||
export type ServerConnectOutput = { readonly token: string }
|
||||
export type ServerConnectOperation<E = never> = (input: ServerConnectInput) => Effect.Effect<ServerConnectOutput, E>
|
||||
|
||||
export interface ServerApi<E = never> {
|
||||
readonly info: ServerInfoOperation<E>
|
||||
readonly pair: ServerPairOperation<E>
|
||||
readonly connect: ServerConnectOperation<E>
|
||||
}
|
||||
|
||||
export type LocationGetInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
|
||||
@@ -6,6 +6,9 @@ import { HttpApiClient } from "effect/unstable/httpapi"
|
||||
import { ClientApi } from "../../contract"
|
||||
import type {
|
||||
ServerInfoOutput,
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -281,7 +284,19 @@ const preserveStream =
|
||||
const EndpointServerInfo = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerInfoOutput>()(raw["server.info"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({ info: EndpointServerInfo(raw) })
|
||||
const EndpointServerPair = (raw: RawClient["server.server"]) => () =>
|
||||
preserveEffect<ServerPairOutput>()(raw["server.pair"]({}).pipe(Effect.mapError(mapClientError)))
|
||||
|
||||
const EndpointServerConnect = (raw: RawClient["server.server"]) => (input: ServerConnectInput) =>
|
||||
preserveEffect<ServerConnectOutput>()(
|
||||
raw["server.connect"]({ params: { code: input["code"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupServer = (raw: RawClient["server.server"]) => ({
|
||||
info: EndpointServerInfo(raw),
|
||||
pair: EndpointServerPair(raw),
|
||||
connect: EndpointServerConnect(raw),
|
||||
})
|
||||
|
||||
const EndpointLocationGet = (raw: RawClient["server.location"]) => (input?: LocationGetInput) =>
|
||||
preserveEffect<LocationGetOutput>()(
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
ServerInfoOutput,
|
||||
ServerPairOutput,
|
||||
ServerConnectInput,
|
||||
ServerConnectOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
LocationReloadOutput,
|
||||
@@ -416,6 +419,22 @@ export function make(options: ClientOptions) {
|
||||
{ method: "GET", path: `/api/info`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
pair: (requestOptions?: RequestOptions) =>
|
||||
request<ServerPairOutput>(
|
||||
{ method: "POST", path: `/api/pair`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
|
||||
requestOptions,
|
||||
),
|
||||
connect: (input: ServerConnectInput, requestOptions?: RequestOptions) =>
|
||||
request<ServerConnectOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/auth/connect/${encodeURIComponent(input.code)}`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
location: {
|
||||
get: (input?: LocationGetInput, requestOptions?: RequestOptions) =>
|
||||
|
||||
@@ -2,6 +2,10 @@ export type JsonValue = null | boolean | number | string | Array<JsonValue> | {
|
||||
|
||||
export type ServerInfo = { version: string; pid: number; urls: Array<string>; paths: { tmp: string } }
|
||||
|
||||
export type PairingCode = { code: string; expires_in: number }
|
||||
|
||||
export type PairingSession = { token: string }
|
||||
|
||||
export type LocationPublicInfo = { directory: string; project: { id: string; directory: string; canonical: string } }
|
||||
|
||||
export type LocationPublicRef = { directory: string }
|
||||
@@ -2696,6 +2700,12 @@ export const isWorktreeError = (value: unknown): value is WorktreeError =>
|
||||
|
||||
export type ServerInfoOutput = ServerInfo
|
||||
|
||||
export type ServerPairOutput = PairingCode
|
||||
|
||||
export type ServerConnectInput = { readonly code: { readonly code: string }["code"] }
|
||||
|
||||
export type ServerConnectOutput = PairingSession
|
||||
|
||||
export type LocationGetInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -102,6 +102,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
|
||||
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
|
||||
- [ ] `for...in` over inherited enumerable keys (`Object.create(proto)`), and skipping keys deleted during the loop.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
- [x] `throw` with arbitrary values.
|
||||
@@ -154,6 +155,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
|
||||
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
|
||||
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
|
||||
- [ ] `GeneratorFunction.prototype`: every function, generator or not, inherits directly from `Function.prototype`,
|
||||
and a generator whose `prototype` was replaced by a non-object still creates from the shared generator prototype.
|
||||
- [x] Generator and async generator functions bind parameters (defaults, destructuring) at the call and defer only the
|
||||
body to the first `next()`, so a bad argument throws synchronously from the call site, as in JS.
|
||||
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
|
||||
@@ -295,9 +298,27 @@ reject }` object.
|
||||
- [x] Every value has a real prototype chain built fresh for each run: `Object.prototype`, `Array.prototype`,
|
||||
`String.prototype`, `Error.prototype` → `TypeError.prototype`, and so on hold the built-in methods as
|
||||
non-enumerable properties, and each constructor's `prototype` points at it (`[].constructor === Array`,
|
||||
`Object.getPrototypeOf` is not exposed). Programs may read and even overwrite these prototypes; the change is
|
||||
confined to that run. `__proto__` is an ordinary own data key, so `o.__proto__ = x` never changes the chain, and
|
||||
`Object.groupBy` results have no prototype at all, as in JS.
|
||||
`Object.getPrototypeOf([]) === Array.prototype`). Programs may read and even overwrite these prototypes; the
|
||||
change is confined to that run. `__proto__` is an ordinary own data key, so `o.__proto__ = x` never changes the
|
||||
chain, and `Object.groupBy` results have no prototype at all, as in JS.
|
||||
- [x] `Object.getPrototypeOf`: the prototype of an object, or the built-in prototype for a string, number, or boolean
|
||||
(`Object.getPrototypeOf("a") === String.prototype`); `null`, `undefined`, and the two confined symbols throw a
|
||||
`TypeError`.
|
||||
- [x] `Object.create(proto)` with an object or `null` prototype; any other prototype is a `TypeError`
|
||||
(`Object prototype may only be an Object or null`). Inherited reads, `in`, `hasOwnProperty`, and own-only
|
||||
`Object.keys` follow the chain as in JS, but `for...in` still enumerates own keys only. A second `properties`
|
||||
argument other than `undefined` throws a `TypeError`: property descriptors are not supported (there is no
|
||||
`Object.defineProperty` either).
|
||||
- [x] `Object.freeze`, `Object.seal`, and `Object.preventExtensions`, with `isFrozen`, `isSealed`, and `isExtensible`.
|
||||
Each returns its argument and passes primitives through (`Object.freeze(1) === 1`, `Object.isFrozen(1)`).
|
||||
Programs run as strict code, so violations throw `TypeError`: `Cannot assign to read only property 'a'`,
|
||||
`Cannot add property b, object is not extensible`, and `Cannot delete property 'a'`. Arrays enforce the same
|
||||
rules for index writes, `length`, and the mutating methods (`Object.freeze([1]).push(2)` throws; a sealed array's
|
||||
`pop()` throws; a non-extensible array's `pop()`, `sort()`, and `splice(0, 1, x)` work). The mutating methods are
|
||||
checked once up front, so a `fill` or `sort` that would land on a hole of a non-extensible array succeeds here
|
||||
where JS throws. Maps, Sets, and Dates freeze their properties only, not their contents, as in JS.
|
||||
`Object.freeze(new Uint8Array(1))` and `seal` throw `TypeError` like JS (`preventExtensions` and empty typed
|
||||
arrays are fine). Functions are objects and freeze like any other value.
|
||||
- [x] Circular references are rejected when created (`o.self = o`, `array.push(array)`), not at serialization as in JS.
|
||||
- [x] `Object.is` for supported data values.
|
||||
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||
@@ -325,8 +346,8 @@ reject }` object.
|
||||
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
|
||||
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
|
||||
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
|
||||
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
|
||||
`Object.getPrototypeOf` is not exposed.
|
||||
shares one prototype, where JavaScript gives each collection its own; `Object.getPrototypeOf` shows the
|
||||
difference.
|
||||
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
|
||||
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
|
||||
- [x] `Array.prototype.toSpliced`.
|
||||
@@ -518,6 +539,12 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
- [x] `atob` and `btoa` with forgiving-base64 decoding and WebIDL string conversion; invalid input throws a
|
||||
`TypeError`, since there is no `DOMException`.
|
||||
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
|
||||
- [x] `structuredClone(value)` deep-copies primitives, plain objects (own enumerable string keys onto a plain object;
|
||||
the prototype is not kept), arrays (holes and extra keys kept), Map, Set, Date, RegExp (`lastIndex` reset to 0),
|
||||
Uint8Array, and errors (standard `name`, `message`, `cause`, and `stack`; other names become `Error`, extra
|
||||
fields drop). Shared references stay shared in the copy, and the copy is extensible even when the source was
|
||||
frozen. Functions, symbols, promises, iterators, URL, Headers, and tool references cannot be cloned; without a
|
||||
`DOMException`, the failure is a `TypeError` whose message starts with `DataCloneError:`.
|
||||
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
|
||||
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
|
||||
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
|
||||
@@ -569,6 +596,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
|
||||
carries a 1-based line and column in the submitted source instead.
|
||||
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
|
||||
- [ ] The derived error constructors inheriting from `Error`: `Object.getPrototypeOf(TypeError)` is
|
||||
`Function.prototype` here, while `TypeError.prototype` does inherit from `Error.prototype`.
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
`catch`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
|
||||
import { headersGlobal } from "../stdlib/headers.js"
|
||||
import { iteratorGlobals } from "../stdlib/iterator.js"
|
||||
import { coercion } from "../stdlib/value.js"
|
||||
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
|
||||
import { base64Global, cryptoGlobal, structuredCloneGlobal } from "../stdlib/web.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { errorGlobal } from "./errors.js"
|
||||
import { errorTypes } from "./intrinsics.js"
|
||||
@@ -95,6 +95,7 @@ const table: Record<string, Factory> = {
|
||||
atob: (ctx) => base64Global(ctx, "atob"),
|
||||
btoa: (ctx) => base64Global(ctx, "btoa"),
|
||||
crypto: (ctx) => cryptoGlobal(ctx),
|
||||
structuredClone: (ctx) => structuredCloneGlobal(ctx),
|
||||
...Object.fromEntries(errorTypes.map((type) => [type, <R>(ctx: Interpreter<R>) => errorGlobal(type, ctx)])),
|
||||
}
|
||||
|
||||
|
||||
@@ -2218,7 +2218,6 @@ class Frame<R> {
|
||||
}
|
||||
})()
|
||||
if (written) return
|
||||
if (target instanceof Arr && key === "length") throw rangeError("Invalid array length", node)
|
||||
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type GeneratorRequestKind,
|
||||
IteratorSymbol,
|
||||
type PendingThrow,
|
||||
rangeError,
|
||||
typeError,
|
||||
} from "./model.js"
|
||||
|
||||
/** Property attributes, as in a JS property descriptor. */
|
||||
@@ -41,6 +43,8 @@ export const frozen: Attributes = { writable: false, enumerable: false, configur
|
||||
*/
|
||||
export class Obj {
|
||||
readonly props = new Map<string | symbol, Slot>()
|
||||
/** [[Extensible]]: cleared by `Object.preventExtensions`, `seal`, and `freeze`. */
|
||||
extensible = true
|
||||
constructor(public proto: Obj | null) {}
|
||||
|
||||
/** The class name `Object.prototype.toString` reports: `[object Map]`. */
|
||||
@@ -91,6 +95,8 @@ export class Obj {
|
||||
|
||||
export class Arr extends Obj {
|
||||
override readonly tag = "Array"
|
||||
/** The attributes every live element shares; `seal` and `freeze` narrow them since elements have no slots. */
|
||||
elements: Attributes = data
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly items: Array<Value> = [],
|
||||
@@ -460,10 +466,11 @@ export const own = (target: Obj, key: PropertyKey): Slot | undefined => {
|
||||
const at = index(target, name)
|
||||
if (at !== undefined) {
|
||||
const items = elements(target)
|
||||
return at in items ? { value: items[at], ...data } : undefined
|
||||
if (!(at in items)) return undefined
|
||||
return { value: items[at], ...(target instanceof Arr ? target.elements : data) }
|
||||
}
|
||||
if (target instanceof Arr && name === "length") {
|
||||
return { value: target.items.length, writable: true, enumerable: false, configurable: false }
|
||||
return { value: target.items.length, writable: target.elements.writable, enumerable: false, configurable: false }
|
||||
}
|
||||
}
|
||||
return target.props.get(name)
|
||||
@@ -505,13 +512,19 @@ export const hasPrototype = (value: Value, proto: Obj): boolean => {
|
||||
const writeElement = (target: Indexed, name: string | symbol, value: Value): boolean | undefined => {
|
||||
const at = index(target, name)
|
||||
if (at !== undefined) {
|
||||
if (target instanceof Bytes) target.bytes[at] = typeof value === "number" ? value : Number(value)
|
||||
else target.items[at] = value
|
||||
if (target instanceof Bytes) {
|
||||
target.bytes[at] = typeof value === "number" ? value : Number(value)
|
||||
return true
|
||||
}
|
||||
if (!(at in target.items)) rejectAddition(target, at)
|
||||
target.items[at] = value
|
||||
return true
|
||||
}
|
||||
if (!(target instanceof Arr) || name !== "length") return undefined
|
||||
const length = typeof value === "number" ? value : Number(value)
|
||||
if (!Number.isInteger(length) || length < 0) return false
|
||||
if (!Number.isInteger(length) || length < 0) throw rangeError("Invalid array length")
|
||||
// Shrinking deletes elements, which a sealed array forbids.
|
||||
if (length < target.items.length && !target.elements.configurable) return false
|
||||
checkArrayLength(length)
|
||||
target.items.length = length
|
||||
return true
|
||||
@@ -541,10 +554,17 @@ export const set = (target: Obj, key: PropertyKey, value: Value): boolean => {
|
||||
const written = writeElement(target, name, value)
|
||||
if (written !== undefined) return written
|
||||
}
|
||||
rejectAddition(target, name)
|
||||
target.props.set(name, { value, ...data })
|
||||
return true
|
||||
}
|
||||
|
||||
/** Creating a property on a non-extensible object is the one [[Set]] failure with its own message. */
|
||||
export const rejectAddition = (target: Obj, key: string | symbol | number): void => {
|
||||
if (target.extensible) return
|
||||
throw typeError(`Cannot add property ${String(key)}, object is not extensible.`)
|
||||
}
|
||||
|
||||
/** [[DefineOwnProperty]] for a data property, ignoring the chain. */
|
||||
export const define = (target: Obj, key: PropertyKey, value: Value, attrs: Attributes = data): void => {
|
||||
const name = canonical(key)
|
||||
@@ -560,7 +580,11 @@ export const remove = (target: Obj, key: PropertyKey): boolean => {
|
||||
const name = canonical(key)
|
||||
if (isIndexed(target)) {
|
||||
const at = index(target, name)
|
||||
if (at !== undefined) return target instanceof Bytes ? !(at in target.bytes) : delete target.items[at]
|
||||
if (at !== undefined) {
|
||||
if (target instanceof Bytes) return !(at in target.bytes)
|
||||
if (at in target.items && !target.elements.configurable) return false
|
||||
return delete target.items[at]
|
||||
}
|
||||
if (target instanceof Arr && name === "length") return false
|
||||
}
|
||||
const slot = target.props.get(name)
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
coerceToInteger,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
rejectAddition,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
@@ -124,6 +125,20 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
])
|
||||
|
||||
const self = (thisValue: Value, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`)
|
||||
// A mutating method fails where its first element write, delete, or `length` write would on a frozen, sealed, or
|
||||
// non-extensible array. `growth` is given by the methods that always write `length`, even when it is 0; holes a
|
||||
// method would fill on a non-extensible array are not checked.
|
||||
const mutable = (target: Arr, growth?: number): Arr => {
|
||||
const length = target.items.length
|
||||
if ((growth ?? 0) > 0) rejectAddition(target, length)
|
||||
if ((growth ?? 0) < 0 && length > 0 && !target.elements.configurable) {
|
||||
throw typeError(`Cannot delete property '${length - 1}'.`)
|
||||
}
|
||||
if (!target.elements.writable && (length > 0 || growth !== undefined)) {
|
||||
throw typeError(`Cannot assign to read only property '${length > 0 ? 0 : "length"}'.`)
|
||||
}
|
||||
return target
|
||||
}
|
||||
const optNumber = (value: Value): number | undefined => (value === undefined ? undefined : coerceToInteger(value))
|
||||
|
||||
methods(builtins, proto, [
|
||||
@@ -196,6 +211,8 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
0,
|
||||
(thisValue) => {
|
||||
const target = self(thisValue, "reverse")
|
||||
// Fewer than two elements means no writes at all, so a frozen one-element array reverses fine.
|
||||
if (target.items.length > 1) mutable(target)
|
||||
target.items.reverse()
|
||||
return target
|
||||
},
|
||||
@@ -204,7 +221,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"sort",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "sort")
|
||||
const target = mutable(self(thisValue, "sort"))
|
||||
const items = target.items
|
||||
const length = items.length
|
||||
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(items, index)).filter((o) => !o).length
|
||||
@@ -244,7 +261,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"push",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "push")
|
||||
const target = mutable(self(thisValue, "push"), args.length)
|
||||
// Validate all insertions before mutating to avoid partial cyclic updates.
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.push result")
|
||||
return target.items.push(...args)
|
||||
@@ -254,25 +271,27 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"unshift",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "unshift")
|
||||
const target = mutable(self(thisValue, "unshift"), args.length)
|
||||
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result")
|
||||
return target.items.unshift(...args)
|
||||
},
|
||||
],
|
||||
["pop", 0, (thisValue) => self(thisValue, "pop").items.pop()],
|
||||
["shift", 0, (thisValue) => self(thisValue, "shift").items.shift()],
|
||||
["pop", 0, (thisValue) => mutable(self(thisValue, "pop"), -1).items.pop()],
|
||||
["shift", 0, (thisValue) => mutable(self(thisValue, "shift"), -1).items.shift()],
|
||||
[
|
||||
"splice",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "splice")
|
||||
if (args.length === 0) return wrap(target.items.splice(0, 0))
|
||||
const length = target.items.length
|
||||
const start = optNumber(args[0]) ?? 0
|
||||
if (args.length === 1) return wrap(target.items.splice(start))
|
||||
const deleteCount = optNumber(args[1]) ?? 0
|
||||
const from = start < 0 ? Math.max(length + start, 0) : Math.min(start, length)
|
||||
const deleteCount =
|
||||
args.length === 1 ? length - from : Math.min(Math.max(optNumber(args[1]) ?? 0, 0), length - from)
|
||||
const inserted = args.slice(2)
|
||||
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
|
||||
return wrap(target.items.splice(start, deleteCount, ...inserted))
|
||||
mutable(target, inserted.length - deleteCount)
|
||||
return wrap(target.items.splice(from, deleteCount, ...inserted))
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -291,7 +310,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"fill",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "fill")
|
||||
const target = mutable(self(thisValue, "fill"))
|
||||
rejectCircularInsertion(target, args[0], "Array.fill result")
|
||||
target.items.fill(args[0], optNumber(args[1]), optNumber(args[2]))
|
||||
return target
|
||||
@@ -301,7 +320,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"copyWithin",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "copyWithin")
|
||||
const target = mutable(self(thisValue, "copyWithin"))
|
||||
target.items.copyWithin(optNumber(args[0]) ?? 0, optNumber(args[1]) ?? 0, optNumber(args[2]))
|
||||
return target
|
||||
},
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, methods, receiver } from "../interpreter/native.js"
|
||||
import {
|
||||
type AstNode,
|
||||
AsyncIteratorSymbol,
|
||||
invalidData,
|
||||
IteratorSymbol,
|
||||
rangeError,
|
||||
typeError,
|
||||
} from "../interpreter/model.js"
|
||||
import { type AstNode, AsyncIteratorSymbol, invalidData, IteratorSymbol, typeError } from "../interpreter/model.js"
|
||||
import {
|
||||
define,
|
||||
entries,
|
||||
@@ -18,13 +11,16 @@ import {
|
||||
hidden,
|
||||
keys,
|
||||
own,
|
||||
ownKeys,
|
||||
Arr,
|
||||
Bytes,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
set,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { primitivePrototype } from "../interpreter/intrinsics.js"
|
||||
import { containsOpaqueReference, describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
@@ -63,7 +59,6 @@ export const objectAssign = <R>(ctx: Interpreter<R>, args: Array<Value>): Value
|
||||
for (const key of enumerableKeys(from)) {
|
||||
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", seen)
|
||||
if (!set(target, key, getOwn(from, key))) {
|
||||
if (target instanceof Arr && key === "length") throw rangeError("Invalid array length")
|
||||
throw typeError(`Cannot assign to read only property '${String(key)}'.`)
|
||||
}
|
||||
}
|
||||
@@ -107,6 +102,34 @@ const classTag = (value: Value): string => {
|
||||
const propertyKey = (value: Value): PropertyKey =>
|
||||
value === AsyncIteratorSymbol || value === IteratorSymbol ? value : coerceToString(value)
|
||||
|
||||
// SetIntegrityLevel: primitives pass through. A typed array's bytes cannot carry attributes, so JS throws after
|
||||
// already making it non-extensible.
|
||||
const restrict = (level: "freeze" | "seal" | "preventExtensions", value: Value): Value => {
|
||||
if (!(value instanceof Obj)) return value
|
||||
value.extensible = false
|
||||
if (level === "preventExtensions") return value
|
||||
if (value instanceof Bytes && value.bytes.length > 0) {
|
||||
throw typeError(`Cannot ${level} array buffer views with elements.`)
|
||||
}
|
||||
for (const slot of value.props.values()) {
|
||||
slot.configurable = false
|
||||
if (level === "freeze" && "value" in slot) slot.writable = false
|
||||
}
|
||||
if (value instanceof Arr) value.elements = { writable: level === "seal", enumerable: true, configurable: false }
|
||||
return value
|
||||
}
|
||||
|
||||
// TestIntegrityLevel: array elements and `length` answer through `own`, so a non-extensible empty array is sealed
|
||||
// but not frozen, as in JS.
|
||||
const integrity = (value: Value, frozen: boolean): boolean => {
|
||||
if (!(value instanceof Obj)) return true
|
||||
if (value.extensible) return false
|
||||
return ownKeys(value).every((key) => {
|
||||
const slot = own(value, key)
|
||||
return slot !== undefined && !slot.configurable && !(frozen && "value" in slot && slot.writable)
|
||||
})
|
||||
}
|
||||
|
||||
// Object constructs identically with or without new, like JS. Only `keys` copies its result into the
|
||||
// program; `values`, `entries`, `assign`, and `fromEntries` hand back the program's own values.
|
||||
export const objectGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
@@ -157,6 +180,39 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["is", 2, (_, args) => Object.is(args[0], args[1])],
|
||||
["assign", 2, (_, args) => objectAssign(ctx, args)],
|
||||
["fromEntries", 1, (_, args) => objectFromEntries(ctx, args[0])],
|
||||
["freeze", 1, (_, args) => restrict("freeze", args[0])],
|
||||
["seal", 1, (_, args) => restrict("seal", args[0])],
|
||||
["preventExtensions", 1, (_, args) => restrict("preventExtensions", args[0])],
|
||||
["isFrozen", 1, (_, args) => integrity(args[0], true)],
|
||||
["isSealed", 1, (_, args) => integrity(args[0], false)],
|
||||
["isExtensible", 1, (_, args) => args[0] instanceof Obj && args[0].extensible],
|
||||
[
|
||||
"getPrototypeOf",
|
||||
1,
|
||||
(_, args) => {
|
||||
if (args[0] instanceof Obj) return args[0].proto
|
||||
const proto = primitivePrototype(builtins, args[0])
|
||||
if (proto === undefined) {
|
||||
throw typeError(`Object.getPrototypeOf cannot convert ${describeValue(args[0])} to an object.`)
|
||||
}
|
||||
return proto
|
||||
},
|
||||
],
|
||||
[
|
||||
"create",
|
||||
2,
|
||||
(_, args) => {
|
||||
if (args[0] !== null && !(args[0] instanceof Obj)) {
|
||||
throw typeError("Object prototype may only be an Object or null.")
|
||||
}
|
||||
if (args[1] !== undefined) {
|
||||
throw typeError(
|
||||
"Object.create property descriptors are not supported; assign the fields after creating the object.",
|
||||
)
|
||||
}
|
||||
return new Obj(args[0])
|
||||
},
|
||||
],
|
||||
])
|
||||
define(object, "groupBy", groupBy(ctx, "Object"), hidden)
|
||||
methods(builtins, builtins.Object, [
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
import { fn, methods } from "../interpreter/native.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { Bytes, Obj, coerceToString } from "../interpreter/objects.js"
|
||||
import {
|
||||
define,
|
||||
entries,
|
||||
get,
|
||||
getOwn,
|
||||
hasOwn,
|
||||
hidden,
|
||||
isRuntimeReference,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
ErrorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
RegExpObj,
|
||||
SetObj,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { createErrorValue, isErrorType } from "../interpreter/intrinsics.js"
|
||||
import { describeValue } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
|
||||
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a
|
||||
// TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have.
|
||||
@@ -17,6 +37,63 @@ export const base64Global = <R>(ctx: Interpreter<R>, name: "atob" | "btoa") =>
|
||||
}
|
||||
})
|
||||
|
||||
// HTML structured clone over CodeMode's data kinds. Shared references survive through the memo; prototypes and
|
||||
// frozen state do not, as in JS. Without a DOMException, DataCloneError is a TypeError carrying that prefix.
|
||||
export const structuredCloneGlobal = <R>(ctx: Interpreter<R>) =>
|
||||
fn<R>(ctx.builtins, "structuredClone", 1, (_, args) => {
|
||||
if (args.length === 0) throw typeError("structuredClone requires 1 argument")
|
||||
const builtins = ctx.builtins
|
||||
const memo = new Map<Obj, Obj>()
|
||||
const clone = (value: Value): Value => {
|
||||
if (typeof value === "symbol" || value instanceof ToolReference) {
|
||||
throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
|
||||
}
|
||||
if (!(value instanceof Obj)) return value
|
||||
const seen = memo.get(value)
|
||||
if (seen !== undefined) return seen
|
||||
const remember = <T extends Obj>(copy: T): T => {
|
||||
memo.set(value, copy)
|
||||
return copy
|
||||
}
|
||||
if (value instanceof MapObj) {
|
||||
const copy = remember(new MapObj(builtins.Map))
|
||||
for (const [key, item] of value.map) copy.map.set(clone(key), clone(item))
|
||||
return copy
|
||||
}
|
||||
if (value instanceof SetObj) {
|
||||
const copy = remember(new SetObj(builtins.Set))
|
||||
for (const item of value.set) copy.set.add(clone(item))
|
||||
return copy
|
||||
}
|
||||
if (value instanceof DateObj) return remember(new DateObj(builtins.Date, value.time))
|
||||
if (value instanceof RegExpObj) {
|
||||
return remember(new RegExpObj(builtins.RegExp, value.regex.source, value.regex.flags))
|
||||
}
|
||||
if (value instanceof Bytes) return remember(new Bytes(builtins.Uint8Array, new Uint8Array(value.bytes)))
|
||||
if (value instanceof ErrorObj) {
|
||||
const name = get(value, "name")
|
||||
const message = get(value, "message")
|
||||
const copy = remember(
|
||||
createErrorValue(
|
||||
builtins[typeof name === "string" && isErrorType(name) ? name : "Error"],
|
||||
message === undefined ? undefined : coerceToString(message),
|
||||
),
|
||||
)
|
||||
const stack = getOwn(value, "stack")
|
||||
if (typeof stack === "string") define(copy, "stack", stack, hidden)
|
||||
if (hasOwn(value, "cause")) define(copy, "cause", clone(getOwn(value, "cause")), hidden)
|
||||
return copy
|
||||
}
|
||||
if (isRuntimeReference(value)) throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
|
||||
const copy = remember(
|
||||
value instanceof Arr ? new Arr(builtins.Array, new Array(value.items.length)) : new Obj(builtins.Object),
|
||||
)
|
||||
for (const [key, item] of entries(value)) define(copy, key, clone(item))
|
||||
return copy
|
||||
}
|
||||
return clone(args[0])
|
||||
})
|
||||
|
||||
export const cryptoGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const object = new Obj(ctx.builtins.Object)
|
||||
methods(ctx.builtins, object, [
|
||||
|
||||
@@ -1231,3 +1231,233 @@ describe("loose equality and operator gates on opaque references", () => {
|
||||
).toEqual(["1,2", null, "[object Function]", true, true, "function", false])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.freeze, seal, and preventExtensions", () => {
|
||||
test("a frozen object rejects writes, additions, and deletes with TypeErrors", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = Object.freeze({ a: 1 })
|
||||
const errors = []
|
||||
try { o.a = 2 } catch (e) { errors.push(e.name + ": " + e.message) }
|
||||
try { o.b = 2 } catch (e) { errors.push(e.name + ": " + e.message) }
|
||||
try { delete o.a } catch (e) { errors.push(e.name + ": " + e.message) }
|
||||
return [errors, o.a, Object.isFrozen(o), Object.isSealed(o), Object.isExtensible(o)]
|
||||
`),
|
||||
).toEqual([
|
||||
[
|
||||
"TypeError: Cannot assign to read only property 'a'.",
|
||||
"TypeError: Cannot add property b, object is not extensible.",
|
||||
"TypeError: Cannot delete property 'a'.",
|
||||
],
|
||||
1,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
])
|
||||
})
|
||||
|
||||
test("seal keeps writes, preventExtensions keeps deletes, and each returns its argument", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const s = { a: 1 }, p = { a: 1 }
|
||||
const errors = []
|
||||
Object.seal(s).a = 2
|
||||
try { delete s.a } catch (e) { errors.push(e.name) }
|
||||
delete Object.preventExtensions(p).a
|
||||
try { p.b = 1 } catch (e) { errors.push(e.name) }
|
||||
return [errors, s.a, Object.keys(p), Object.isSealed(s), Object.isFrozen(s), Object.isSealed(p), Object.isFrozen(p)]
|
||||
`),
|
||||
).toEqual([["TypeError", "TypeError"], 2, [], true, false, true, true])
|
||||
})
|
||||
|
||||
test("primitives pass through, and Object.assign honors the flags", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [Object.freeze(1) === 1, Object.isFrozen(1), Object.isSealed("a"), Object.isExtensible(null), Object.isFrozen({}), Object.isExtensible({})]
|
||||
`),
|
||||
).toEqual([true, true, true, false, false, true])
|
||||
const failure = await error(`Object.assign(Object.freeze({ a: 1 }), { b: 1 })`)
|
||||
expect(failure.message).toContain("Cannot add property b, object is not extensible")
|
||||
})
|
||||
|
||||
test("frozen arrays reject element, length, and mutating-method writes like JS", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const a = Object.freeze([1, 2])
|
||||
const attempt = (f) => { try { f(); return "ok" } catch (e) { return e.name + ": " + e.message } }
|
||||
return [
|
||||
attempt(() => a.push(3)),
|
||||
attempt(() => { a[0] = 9 }),
|
||||
attempt(() => { a[5] = 9 }),
|
||||
attempt(() => { a.length = 0 }),
|
||||
attempt(() => a.pop()),
|
||||
attempt(() => a.sort()),
|
||||
attempt(() => a.reverse()),
|
||||
attempt(() => a.fill(0)),
|
||||
attempt(() => a.copyWithin(0, 1)),
|
||||
attempt(() => a.splice(0, 1)),
|
||||
attempt(() => a.unshift(0)),
|
||||
a, a.map((x) => x * 2), Object.isFrozen(a), Object.isFrozen(Object.freeze([])),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
"TypeError: Cannot add property 2, object is not extensible.",
|
||||
"TypeError: Cannot assign to read only property '0'.",
|
||||
"TypeError: Cannot add property 5, object is not extensible.",
|
||||
"TypeError: Cannot assign to read only property 'length'.",
|
||||
"TypeError: Cannot delete property '1'.",
|
||||
"TypeError: Cannot assign to read only property '0'.",
|
||||
"TypeError: Cannot assign to read only property '0'.",
|
||||
"TypeError: Cannot assign to read only property '0'.",
|
||||
"TypeError: Cannot assign to read only property '0'.",
|
||||
"TypeError: Cannot delete property '1'.",
|
||||
"TypeError: Cannot add property 2, object is not extensible.",
|
||||
[1, 2],
|
||||
[2, 4],
|
||||
true,
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
test("sealed and non-extensible arrays allow exactly the writes JS does", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const s = Object.seal([1, 2]), p = Object.preventExtensions([1, 2, 3])
|
||||
const attempt = (f) => { try { f(); return "ok" } catch (e) { return e.name } }
|
||||
const results = [attempt(() => { s[0] = 5 }), attempt(() => s.pop()), attempt(() => { s.length = 0 }), attempt(() => s.push(1))]
|
||||
p.pop(); p.sort(); p.reverse(); p.fill(0, 1); p.copyWithin(0, 1); p.length = 5
|
||||
results.push(attempt(() => p.push(1)), attempt(() => p.splice(0, 1, 7, 8)), p.splice(0, 1, 7), attempt(() => { p[9] = 1 }))
|
||||
return [results, s, p, Object.isSealed(p), Object.isFrozen(p), Object.isSealed(Object.preventExtensions([])), Object.isFrozen(Object.preventExtensions([]))]
|
||||
`),
|
||||
).toEqual([
|
||||
["ok", "TypeError", "TypeError", "TypeError", "TypeError", "TypeError", [0], "TypeError"],
|
||||
[5, 2],
|
||||
[7, 0, null, null, null],
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
])
|
||||
})
|
||||
|
||||
test("typed arrays with elements cannot be frozen; wrappers freeze their properties only", async () => {
|
||||
const failure = await error(`Object.freeze(new Uint8Array([1]))`)
|
||||
expect(failure.message).toContain("Cannot freeze array buffer views with elements")
|
||||
expect(
|
||||
await value(`
|
||||
const empty = Object.freeze(new Uint8Array(0))
|
||||
const bytes = Object.preventExtensions(new Uint8Array([1]))
|
||||
const m = Object.freeze(new Map()); m.set(1, 2)
|
||||
const f = Object.freeze(() => 1)
|
||||
let named = "ok"
|
||||
try { f.x = 1 } catch (e) { named = e.name }
|
||||
return [Object.isFrozen(empty), Object.isFrozen(bytes), Object.isExtensible(bytes), m.size, Object.isFrozen(f), named, f()]
|
||||
`),
|
||||
).toEqual([true, false, false, 1, true, "TypeError", 1])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Object.getPrototypeOf and Object.create", () => {
|
||||
test("getPrototypeOf returns the built-in prototypes for objects and primitives", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
return [
|
||||
Object.getPrototypeOf([]) === Array.prototype,
|
||||
Object.getPrototypeOf({}) === Object.prototype,
|
||||
Object.getPrototypeOf(Object.prototype),
|
||||
Object.getPrototypeOf("a") === String.prototype,
|
||||
Object.getPrototypeOf(1) === Number.prototype,
|
||||
Object.getPrototypeOf(true) === Boolean.prototype,
|
||||
Object.getPrototypeOf(new TypeError("x")) === TypeError.prototype,
|
||||
Object.getPrototypeOf(TypeError.prototype) === Error.prototype,
|
||||
Object.getPrototypeOf(() => 1) === Function.prototype,
|
||||
]
|
||||
`),
|
||||
).toEqual([true, true, null, true, true, true, true, true, true])
|
||||
})
|
||||
|
||||
test("getPrototypeOf rejects null, undefined, and symbols", async () => {
|
||||
expect((await error(`Object.getPrototypeOf(null)`)).message).toContain("cannot convert null to an object")
|
||||
expect((await error(`Object.getPrototypeOf(undefined)`)).message).toContain("cannot convert undefined to an object")
|
||||
expect((await error(`Object.getPrototypeOf(Symbol.iterator)`)).message).toContain("cannot convert a symbol")
|
||||
})
|
||||
|
||||
test("Object.create links the prototype: inherited reads, in, and own-only keys", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const p = { greet(name) { return "hi " + name }, a: 1 }
|
||||
const c = Object.create(p)
|
||||
c.name = "x"
|
||||
const seen = []
|
||||
for (const key in c) seen.push(key)
|
||||
return ["greet" in c, Object.keys(c), c.hasOwnProperty("a"), c.a, c.greet(c.name), Object.getPrototypeOf(c) === p, Object.getPrototypeOf(Object.create(null)), seen]
|
||||
`),
|
||||
).toEqual([true, ["name"], false, 1, "hi x", true, null, ["name"]])
|
||||
})
|
||||
|
||||
test("Object.create rejects non-object prototypes and property descriptors", async () => {
|
||||
expect((await error(`Object.create(1)`)).message).toContain("Object prototype may only be an Object or null")
|
||||
expect((await error(`Object.create()`)).message).toContain("Object prototype may only be an Object or null")
|
||||
expect((await error(`Object.create(null, { a: { value: 1 } })`)).message).toContain(
|
||||
"Object.create property descriptors are not supported",
|
||||
)
|
||||
expect(await value(`return Object.keys(Object.create({}, undefined))`)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("structuredClone", () => {
|
||||
test("deep-copies data values and keeps shared references shared", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const shared = { n: 1 }
|
||||
const source = { a: shared, b: shared, list: [1, , shared], map: new Map([[1, { a: [1, 2] }]]), set: new Set([shared]), when: new Date(5), bytes: new Uint8Array([1, 2]) }
|
||||
const c = structuredClone(source)
|
||||
c.a.n = 2
|
||||
return [
|
||||
c !== source, c.a === c.b, c.a !== shared, shared.n, c.list[2] === c.a, 1 in c.list, c.list.length,
|
||||
c.map.get(1).a, c.map !== source.map, c.set.has(c.a), c.when.getTime(), c.when instanceof Date,
|
||||
c.bytes instanceof Uint8Array, c.bytes[1], Array.isArray(c.list),
|
||||
]
|
||||
`),
|
||||
).toEqual([true, true, true, 1, true, false, 3, [1, 2], true, true, 5, true, true, 2, true])
|
||||
})
|
||||
|
||||
test("regexps reset lastIndex, errors keep name, message, and cause, and extras are dropped", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const r = /a/g
|
||||
r.lastIndex = 3
|
||||
const e = new TypeError("boom", { cause: { code: 1 } })
|
||||
e.extra = 1
|
||||
const custom = new Error("x")
|
||||
custom.name = "Custom"
|
||||
const [cr, ce, cc] = [structuredClone(r), structuredClone(e), structuredClone(custom)]
|
||||
return [cr.source, cr.flags, cr.lastIndex, ce instanceof TypeError, ce.name, ce.message, ce.cause, ce.cause !== e.cause, ce.extra, Object.keys(ce), cc.name]
|
||||
`),
|
||||
).toEqual(["a", "g", 0, true, "TypeError", "boom", { code: 1 }, true, null, [], "Error"])
|
||||
})
|
||||
|
||||
test("the clone is a plain extensible object: prototypes, symbols, undefined fields, and frozen state drop", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const c = structuredClone(Object.freeze(Object.assign(Object.create({ inherited: 1 }), { a: undefined, [Symbol.iterator]: 1, b: 2 })))
|
||||
return [Object.isFrozen(c), Object.getPrototypeOf(c) === Object.prototype, "a" in c, Symbol.iterator in c, c.b, c.inherited]
|
||||
`),
|
||||
).toEqual([false, true, true, false, 2, null])
|
||||
})
|
||||
|
||||
test("functions, symbols, promises, wrappers, and tool references throw a DataCloneError TypeError", async () => {
|
||||
for (const code of [
|
||||
`structuredClone(() => 1)`,
|
||||
`structuredClone({ deep: [Symbol.iterator] })`,
|
||||
`structuredClone(Promise.resolve(1))`,
|
||||
`structuredClone(new URL("http://x"))`,
|
||||
`structuredClone(tools)`,
|
||||
]) {
|
||||
const failure = await error(code)
|
||||
expect(failure.message).toMatch(/^TypeError: DataCloneError: .* could not be cloned\./)
|
||||
}
|
||||
expect(await value(`try { structuredClone(() => 1) } catch (e) { return e.name }`)).toBe("TypeError")
|
||||
expect((await error(`structuredClone()`)).message).toContain("structuredClone requires 1 argument")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
|
||||
|
||||
`script/sync-test262.ts` skips a file when its frontmatter declares a `flags`, `features`, or `includes` value the
|
||||
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
|
||||
checkout is at the pinned revision, so every machine runs the same 4478 files. Boundaries are
|
||||
checkout is at the pinned revision, so every machine runs the same 4595 files. Boundaries are
|
||||
intentional limits of the interpreter, not compatibility work: classes, `this`, `arguments`, prototype objects,
|
||||
property descriptors, accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
|
||||
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
|
||||
|
||||
@@ -3,7 +3,13 @@
|
||||
"directories": [
|
||||
"built-ins/Array/prototype",
|
||||
"built-ins/Iterator",
|
||||
"built-ins/Object/freeze",
|
||||
"built-ins/Object/getPrototypeOf",
|
||||
"built-ins/Object/is",
|
||||
"built-ins/Object/isExtensible",
|
||||
"built-ins/Object/isFrozen",
|
||||
"built-ins/Object/isSealed",
|
||||
"built-ins/Object/preventExtensions",
|
||||
"built-ins/String/raw",
|
||||
"language/expressions/does-not-equals",
|
||||
"language/expressions/equals",
|
||||
@@ -18,7 +24,7 @@
|
||||
"arguments": "\\barguments\\b",
|
||||
"call/apply/bind": "\\.(call|apply|bind)\\s*\\(",
|
||||
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
|
||||
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|create|getPrototypeOf|setPrototypeOf|freeze|seal|preventExtensions|isFrozen|isSealed|isExtensible)\\b",
|
||||
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
|
||||
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
|
||||
"sloppy mode": "\\bwith\\s*\\(",
|
||||
"eval": "\\b(eval|Function)\\b",
|
||||
|
||||
@@ -194,6 +194,7 @@ built-ins/Array/prototype/unshift/S15.4.4.13_A3_T2.js # Array.prototype.unshift
|
||||
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T1.js # Array.prototype.unshift called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/unshift/S15.4.4.13_A4_T2.js # #3: Array.prototype[0] = 1; x = []; x.length = 1; x.unshift(0); x[1] === 1. Actual: undefined
|
||||
built-ins/Iterator/from/return-method-returns-iterator-result.js # Iterator next must be a function.
|
||||
built-ins/Iterator/prototype/Symbol.iterator/is-function.js # every built-in collection iterator shares one prototype, so its parent is Object.prototype
|
||||
built-ins/Iterator/prototype/drop/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError
|
||||
built-ins/Iterator/prototype/drop/limit-tonumber.js # Iterator.prototype.drop expects a non-negative count, received NaN.
|
||||
built-ins/Iterator/prototype/take/limit-tonumber-throws.js # Expected a Test262Error but got a RangeError
|
||||
@@ -205,6 +206,20 @@ language/expressions/does-not-equals/S11.9.2_A7.9.js # #1: ({valueOf: function(
|
||||
language/expressions/equals/S11.9.1_A7.8.js # #1: ({valueOf: function() {return 1}} == true) === true
|
||||
language/expressions/equals/S11.9.1_A7.9.js # #1: (true == {valueOf: function() {return 1}}) === true
|
||||
language/expressions/equals/S9.1_A1_T3.js # #1: var object = {valueOf: function() {return 1}, toString: function() {return 0}}; object + "" ===
|
||||
built-ins/Object/freeze/throws-when-false.js # Unknown identifier 'Proxy'.
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-12.js # Object.getPrototypeOf(EvalError) Expected SameValue(«», «Error») to be true
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-13.js # Object.getPrototypeOf(RangeError) Expected SameValue(«», «Error») to be true
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-14.js # Object.getPrototypeOf(ReferenceError) Expected SameValue(«», «Error») to be true
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-15.js # Object.getPrototypeOf(SyntaxError) Expected SameValue(«», «Error») to be true
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-16.js # Object.getPrototypeOf(TypeError) Expected SameValue(«», «Error») to be true
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-17.js # Object.getPrototypeOf(URIError) Expected SameValue(«», «Error») to be true
|
||||
built-ins/Object/getPrototypeOf/15.2.3.2-2-2.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
built-ins/Object/isExtensible/15.2.3.13-2-24.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
built-ins/Object/isExtensible/15.2.3.13-2-25.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
built-ins/Object/isExtensible/15.2.3.13-2-26.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
built-ins/Object/isExtensible/15.2.3.13-2-27.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
built-ins/Object/preventExtensions/15.2.3.10-3-24.js # … cannot be constructed: user-defined constructors and classes are not supported.
|
||||
built-ins/Object/preventExtensions/throws-when-false.js # Unknown identifier 'Proxy'.
|
||||
language/expressions/tagged-template/constructor-invocation.js # The called value cannot be constructed: user-defined constructors and classes are not supported.
|
||||
language/expressions/tagged-template/template-object-frozen-strict.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/async-generator/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
@@ -221,6 +236,8 @@ language/statements/for-await-of/async-gen-decl-dstr-obj-empty-num.js # TypeErr
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-empty-string.js # TypeError: Object destructuring requires a data object or array value, received a string.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-number.js # TypeError: Object destructuring requires a data object or array value, received a number.
|
||||
language/statements/for-await-of/async-gen-decl-dstr-obj-rest-str-val.js # TypeError: Object destructuring requires a data object or array value, received a string.
|
||||
language/statements/for-in/S12.6.4_A7_T2.js # for...in visits keys deleted during the loop
|
||||
language/statements/for-in/order-enumerable-shadowed.js # Object.create property descriptors are not supported; assign the fields after creating the object.
|
||||
language/statements/for-of/dstr/array-elem-iter-rtrn-close-err.js # Iterator next must be a function.
|
||||
language/statements/for-of/dstr/array-elem-iter-thrw-close-err.js # Expected SameValue(«1», «0») to be true
|
||||
language/statements/for-of/dstr/array-elem-trlg-iter-list-thrw-close-err.js # Expected SameValue(«1», «0») to be true
|
||||
@@ -258,8 +275,10 @@ language/statements/function/S13.2_A4_T1.js # Unknown identifier '…'.
|
||||
language/statements/function/S13.2_A4_T2.js # #1: typeof __gunc.prototype === '…'. Actual: typeof __gunc.prototype ===undefined
|
||||
language/statements/function/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/function/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/generators/default-proto.js # generator functions have no GeneratorFunction.prototype
|
||||
language/statements/generators/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/generators/dstr/dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/generators/prototype-relation-to-function.js # generator functions have no GeneratorFunction.prototype
|
||||
language/statements/labeled/value-await-non-module.js # Failed to parse TypeScript: Expression expected.
|
||||
language/statements/let/dstr/ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/statements/return/S12.9_A1_T1.js # expected SyntaxError but the program ran
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.15",
|
||||
"version": "2.0.16",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -76,6 +76,7 @@ export const appHandlers = AppRpcs.toLayer(
|
||||
}),
|
||||
AppRelaunch: () => Effect.sync(lifecycle.relaunch),
|
||||
AppPairInfo: () => pair(pairing.info),
|
||||
AppPairCode: () => pair(pairing.code),
|
||||
AppGetKeepScreenActive: () => Effect.sync(screenActivity.get),
|
||||
AppSetKeepScreenActive: ({ enabled }) =>
|
||||
Effect.try(() => screenActivity.set(enabled)).pipe(Effect.mapError(String)),
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { SidecarCredentials } from "./sidecar-credentials"
|
||||
|
||||
export function createPairing() {
|
||||
const requireCredentials = () => {
|
||||
const client = async () => {
|
||||
const credentials = SidecarCredentials.get()
|
||||
if (!credentials) throw new Error("The local desktop server is not ready")
|
||||
return credentials
|
||||
}
|
||||
const readInfo = async (credentials: ReturnType<typeof requireCredentials>) => {
|
||||
const { OpenCode } = await import("@opencode/client/promise")
|
||||
const info = await OpenCode.make({
|
||||
return OpenCode.make({
|
||||
baseUrl: credentials.url,
|
||||
headers: credentials.password
|
||||
? { Authorization: `Basic ${Buffer.from(`opencode:${credentials.password}`).toString("base64")}` }
|
||||
: undefined,
|
||||
}).server.info()
|
||||
return { urls: info.urls, username: "opencode" as const, password: credentials.password ?? "" }
|
||||
})
|
||||
}
|
||||
const info = () => readInfo(requireCredentials())
|
||||
return { info }
|
||||
const info = async () => ({ urls: (await (await client()).server.info()).urls })
|
||||
const code = async () => (await (await client()).server.pair()).code
|
||||
return { info, code }
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user