mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-25 18:17:35 +00:00
Compare commits
31
Commits
output-limits
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6585bb7105 | ||
|
|
f954688fbb | ||
|
|
1463dabde9 | ||
|
|
29ea6ee05b | ||
|
|
b170904731 | ||
|
|
88e1fa9304 | ||
|
|
ff1bf315ed | ||
|
|
144ce00e00 | ||
|
|
ad504094f0 | ||
|
|
bad6834a3e | ||
|
|
0c4bbc3cd1 | ||
|
|
ae7dd82126 | ||
|
|
65d5123ead | ||
|
|
4eb46a8885 | ||
|
|
1986e92842 | ||
|
|
14fc63ba9e | ||
|
|
c34ffa117e | ||
|
|
beeb14e910 | ||
|
|
aae42e2e75 | ||
|
|
cc9011c1ae | ||
|
|
6cd938e1e9 | ||
|
|
7de6b3fc15 | ||
|
|
c1c9a13993 | ||
|
|
917d904f18 | ||
|
|
ee5b67eb84 | ||
|
|
048a47e89e | ||
|
|
5335347e80 | ||
|
|
16b18dff13 | ||
|
|
61c2349cef | ||
|
|
684721efb8 | ||
|
|
962c14a49c |
@@ -597,8 +597,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"@opentui/core": ">=0.5.12",
|
||||
"@opentui/solid": ">=0.5.12",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1114,9 +1114,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2252,27 +2252,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.12", "", { "dependencies": { "@opentui/core": "0.5.12" }, "peerDependencies": { "@opentui/react": "0.5.12", "@opentui/solid": "0.5.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yWPvJjRhJTRoRSUucQq9Ua8ZW7n/2YQ/j6JxWq5Qekm4WuFiTplEkebR/Aj2/xA8tX68NOE5qv1LrY0Jk3NLNQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.12", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-+Clo0VPDdruHSoBNvV/wKAM8iR6HJPtB00oa8yl9ujU=",
|
||||
"aarch64-linux": "sha256-4wU5v36GTXjwyt5ls4FH+5G43Ujd+dKVSJR21w3lhbA=",
|
||||
"aarch64-darwin": "sha256-pThjoD6baddQ6biy7k1ByXwGwLAeWe/+w0tcYmt1uWs=",
|
||||
"x86_64-darwin": "sha256-bCBl63CqBiqilb+YdaOLBYYZx/yf47c1aqgDOkgdegg="
|
||||
"x86_64-linux": "sha256-aQQQhaUlAhpfqzH0vNi0IJ1cg7FQHIKYzxeq5d8PZoU=",
|
||||
"aarch64-linux": "sha256-r9aDFu3UYmudmmYPhzCrpFvQlaejXc8V1IzLtG3jZPc=",
|
||||
"aarch64-darwin": "sha256-B0m41LelD7d61vPHGIZZSO/cU7gbjHDJHt6oxNRRM8Q=",
|
||||
"x86_64-darwin": "sha256-9TWJsyI3Y6BMomtGSgqA1th9LpxpqP4F5Tl/GyexVYw="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -52,9 +52,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
@@ -12,9 +12,9 @@
|
||||
|
||||
Per-type constructors live on the type, not as top-level re-exports. Use `Message.system(...)`, `Message.user(...)`, `Message.assistant(...)`, `Message.tool(...)`, `Message.media(...)`, `LanguageModel.make(...)`, `ToolDefinition.make(...)`, `ToolCallPart.make(...)`, `ToolResultPart.make(...)`, `ToolChoice.make(...)`, `ToolChoice.named(...)`, `SystemPart.make(...)`, and `GenerationOptions.make(...)` directly. The top-level `LLM` namespace is reserved for request-shaped call APIs: `LLM.request`, `LLM.generate`, `LLM.stream`, and `LLM.generateObject`. `LLM.generate`/`LLM.stream` and Promise `ai.llm.generate`/`ai.llm.stream` accept ergonomic input or an `LLMRequest`; both paths use the same canonical request. Core still builds, logs, replays, and updates that durable `LLMRequest` boundary. Use `LLMRequest.update(...)` when deriving canonical request data; do not add a duplicate `LLM.updateRequest(...)` path.
|
||||
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream` (later `Video`, `Speech`, `Transcription`). Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
Modality namespaces mirror `LLM` exactly: `Image.request`, `Image.generate`, `Image.stream`, and the same for `Video`, `Speech`, and `Transcription`. Common request fields (`images`, `mask`, `n`, `size`, `aspectRatio`, `seed`, `format`) lower natively or fail with a typed `AIError`; provider-native controls always live under `providerOptions`, never under a modality-specific `options` key.
|
||||
|
||||
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.inlineMedia` / `requireInlineMedia` / `mediaUrl` / `MediaInput.refID` rather than re-deriving base64 or URL handling.
|
||||
Media payloads are always `Media.Asset` (`src/media.ts`). Construct them with `Media.bytes`, `Media.base64`, `Media.url`, `Media.ref`, `Media.fromDataUrl`, or `Media.file`; never introduce a parallel `data: string | Uint8Array` shape. `MediaPart.media`, `ImageRequest.images`/`mask`, `ImageResponse.images`, and the `media` `LLMEvent` all share it. Protocols branch on `asset.source.type` and `asset.kind` and use `ProviderShared.requireInlineMedia` / `inlineRequired` / `mediaUrl` / `mediaReference` and `MediaInput.inlineBytes` / `refID` rather than re-deriving base64 or URL handling.
|
||||
|
||||
`schema/messages.ts → media.ts → route/executor-service.ts` is an accepted runtime dependency from the schema layer on the executor service tag: `Media.Asset.bytes()` must be able to download `url` sources, and the tag lives in that leaf module precisely so the schema barrel never imports the executor implementation (which imports the schema barrel back). Do not move the tag into `route/executor.ts` or import `route/executor.ts` from `src/schema/*` or `src/media.ts`.
|
||||
|
||||
@@ -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.inline(...)` / `queued(...)` / `stream(...)` (`src/route/media.ts`) compose a `MediaProtocol` kind with `Endpoint` and `Auth` and own the transport plumbing: `http` option merging, URL/query rendering, auth headers, JSON vs multipart encoding, and handing the response back to the protocol. `MediaProtocol.inline` (`src/route/media-protocol.ts`) is `body.from(request)` plus `response.decode(response, context)`; each protocol declares `const route = MediaProtocol.identity({ id, name, provider })` once and decodes through `route.decodeJson` / `route.text` / `route.decodeStarted` so decode failures retain the raw body and HTTP context, raising `route.unsupported(operation, message)` for requests it cannot lower, and passes `route` as the first argument to `MediaProtocol.inline` / `queued` / `stream`. `Generation` (`src/generation.ts`) is the provider-neutral handle for a queued generation over a `GenerationRoute` (`status`, `result`, `cancel`). Image protocol files follow the same section order as LLM protocols and declare unsupported common fields once through the protocol's `unsupported` list.
|
||||
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel`, each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
`MediaProtocol.queued` is the submit-then-poll kind every video route uses: `start` (body + decode into `{ token, snapshot }`), `status`, `result`, and optional `cancel` (with `activeOnly` when the provider's cancel endpoint deletes finished work, as Runway's does: the route refreshes status first and skips terminal generations), each addressed by a route-owned `token` whose `Schema.Codec` makes it serializable. `MediaRoute.inline` and `MediaRoute.queued` compose the two kinds with `Endpoint` and `Auth`; the queued route decodes the token once at the boundary (`start` output or `resume` input) and closes over it in a token-free `GenerationRoute` (`status`/`result`/`cancel` are plain Effects), so `Generation` never sees the token's shape and only carries the encoded JSON for persistence. Polls reuse the route's auth and deployment headers plus the request's `http` overlay after `start`, and resolve relative paths against the route base URL (provider-issued absolute URLs such as fal's `status_url` pass through). `result` is always its own GET even when the provider returns output inside the status document, so `Generation.await` behaves the same after `start` and after `resume`. `PollContext.auth` carries only what `Auth` added or changed so protocols can hand download credentials to output assets as transient `Media.Asset.headers` (Veo) — never part of `source` or JSON. Status strings map through a per-protocol `STATUS` table via `MediaProtocol.status`; terminal generations without output fail through `output.ended` / `output.contentPolicy` with the provider document on `reason.body`. `GenerationAwaitOptions` (`AwaitOptions` in `src/generation.ts`, `{ poll?: Poll }`) is the one options type for `await`, `events`, `Video.generate`, and `Video.stream`.
|
||||
|
||||
`MediaProtocol.stream` is the incremental kind every speech route uses, with the same discipline as LLM protocols. `MediaRoute.stream` submits the caller's request as `MediaProtocol.Addressed<Request>` (`{ ...request, mode }`, `mode: "generate" | "stream"`), so one provider stays one protocol: `body.from`, the endpoint path, and `frames` read `request.mode` to pick the body, path, and framing. `frames(bytes, context)` returns frames — `Framing.sse`, `Framing.lines`, `Framing.document` (a single-document response shaped like a streamed record), or the raw `bytes` for chunked audio. `initial()` is fresh per-response parser state; `step` folds each frame into it and emits modality events; `finish(state, context)` runs once after the last frame with the request, body, and observed `http` (header-only usage lives there) and emits exactly one terminal event or fails with `route.incomplete()`. Keep parser state to real accumulators and derive anything the request or body determines in `finish`. `generate` runs the same stream and folds it with the modality's `collect`. Request-derived URL parameters go on the body's `query` (array values repeat the parameter), applied before route and caller `http.query`. Decode frames with `route.decodeFrame` and raise stream-time failures with `route.frameError` (the frame stays on `reason.body`); protocols never thread HTTP context, because the route fills `reason.http` on stream errors that lack it. Speech protocols share `protocols/utils/speech-stream.ts` for deltas, timestamps, voice ids, PCM and container descriptions, and the terminal asset.
|
||||
|
||||
@@ -112,7 +112,7 @@ For providers where the URL is derived from typed inputs (Azure resource name, B
|
||||
|
||||
### Provider Facades
|
||||
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, later `.video` / `.speech` / `.transcription`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
|
||||
Provider-facing APIs are configured facades over route values. Endpoint/auth/resource/API-version setup happens before model selection, and model selectors accept only a model or deployment id. Media models use per-modality selectors on the same facade (`openai.image(id)`, `.speech(id)`, `.transcription(id)`, `google.video(id)`) that mirror `openai.responses(id)`; the one-word overlap with the request namespace is accepted over a second construction path:
|
||||
|
||||
```ts
|
||||
const openai = OpenAI.configure({ apiKey, baseURL })
|
||||
|
||||
+19
-18
@@ -475,18 +475,18 @@ 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) | ✗ |
|
||||
| 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`, `/multi`) | ✓ |
|
||||
| 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.
|
||||
@@ -621,8 +621,7 @@ persist the bytes promptly if they must remain available.
|
||||
### Partial images
|
||||
|
||||
OpenAI's GPT image models stream previews. `Image.stream` sends `stream: true` with `partialImages` (0–3, default 2)
|
||||
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request.
|
||||
`dall-e-*` models do not stream and fail typed:
|
||||
and emits `image-partial` events before each final `image`; `Image.generate` keeps the plain JSON request:
|
||||
|
||||
```ts
|
||||
import { Stream } from "effect"
|
||||
@@ -699,7 +698,7 @@ const program = Effect.gen(function* () {
|
||||
})
|
||||
```
|
||||
|
||||
The hosted result is represented as a provider-executed tool call and tool result, and the generated image is also emitted as a first-class `media` `LLMEvent` (`response.message` then carries a `media` part). Gemini image-capable models emit the same `media` event for inline image output. Retaining `response.message` preserves the generated image for continuation on both routes.
|
||||
The hosted result is represented as a provider-executed tool call and a tool result whose content carries the generated image as a file. Gemini image-capable models instead emit a first-class `media` `LLMEvent` for inline image output (`response.message` then carries a `media` part). Retaining `response.message` preserves the generated image for continuation on both routes.
|
||||
|
||||
## Video generation
|
||||
|
||||
@@ -840,9 +839,10 @@ Provider notes:
|
||||
- **OpenAI** streams over SSE (`stream_format: "sse"`), which is also the only place it reports token usage; `tts-1`
|
||||
and `tts-1-hd` do not support SSE and stream the raw audio body instead. `pcm` is 24 kHz 16-bit mono. `language`
|
||||
and `timestamps` are not supported.
|
||||
- **Gemini TTS** returns raw 16-bit PCM only (`audio/L16;codec=pcm;rate=24000`), so any `format` other than `pcm`
|
||||
fails typed; wrap the samples yourself. Style is directed in the text, so `instructions` and `speed` fail typed.
|
||||
Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
|
||||
- **Gemini TTS** returns the provider's default output: WAV for Gemini 3.8 TTS `generate`, raw 16-bit PCM
|
||||
(`audio/L16;codec=pcm;rate=24000`) otherwise. `pcm` is the only explicit `format` it accepts, and it fails typed on
|
||||
Gemini 3.8 `generate`; the route never wraps PCM as WAV. Style is directed in the text, so `instructions` and
|
||||
`speed` fail typed. Only `gemini-3.1-flash-tts-preview` and later support streaming. Two-speaker audio goes through
|
||||
`providerOptions.speechConfig.multiSpeakerVoiceConfig`.
|
||||
- **ElevenLabs** requires `voice` (the path voice id) and authenticates with `xi-api-key`. `format` maps to the
|
||||
`output_format` query parameter (`mp3_44100_128`, `pcm_24000`, `wav_24000`, `opus_48000_64`);
|
||||
@@ -944,6 +944,7 @@ const transcript = await generation.await({ poll: { interval: 3_000 } })
|
||||
- **`ImageClient`** — Effect service and layer for image execution, parallel to `LLMClient`.
|
||||
- **`Media`** — the shared asset type (`Media.Asset`, `Media.Source`) and constructors used by messages, tool results, and media requests.
|
||||
- **`Generation`** — provider-neutral handle for an in-flight media generation (`await`, `refresh`, `cancel`, `events`) used by queued media routes.
|
||||
- **`Video.request` / `generate` / `stream` / `start` / `resume`** — queued video generation through a provider-neutral request; `VideoClient` is its Effect service and layer.
|
||||
- **`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.
|
||||
- **`AIClient.layer` / `AIClient.layerWith(executor)`** — every modality client plus the request executor in one layer.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Media generation in `@opencode/ai` — public API direction
|
||||
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images); phase 5 proposal.
|
||||
Status: phases 1–4 implemented (through Image queued routes and partial images; ElevenLabs Scribe transcription
|
||||
pending); phase 5 proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
@@ -40,7 +41,7 @@ The design below is derived from a survey of the raw provider APIs (OpenAI, Gemi
|
||||
|
||||
### Model selection
|
||||
|
||||
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `openai.image(id)` today, `.video(id)` / `.speech(id)` / `.transcription(id)` as those modalities land — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
|
||||
A model value is built as `OpenAI.configure({ apiKey }).responses("gpt-5")` or `.image("gpt-image-2")`: `configure` fixes credentials, endpoint, and defaults; the selector fixes which of the provider's APIs to hit and binds the typed `providerOptions` generic. Media follows the same shape with one selector per modality — `.image(id)`, `.video(id)`, `.speech(id)`, `.transcription(id)` on the facades that offer each — mirroring `openai.responses(id)`. `Image.request` accepts `ImageModel` only, exactly as `LLM.request` accepts `LanguageModel`.
|
||||
|
||||
```ts
|
||||
import { OpenAI, Google } from "@opencode/ai/providers"
|
||||
@@ -135,8 +136,8 @@ Effect.gen(function* () {
|
||||
})
|
||||
```
|
||||
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the README's Image
|
||||
portability matrix.
|
||||
`size` and `aspectRatio` are not interchangeable; each route rejects fields it cannot lower — see the portability table
|
||||
in the README's Image generation section.
|
||||
|
||||
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`.
|
||||
|
||||
@@ -166,8 +167,8 @@ Effect.gen(function* () {
|
||||
|
||||
// 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.video // Media.Asset: url (expiresAt on Veo and Runway; transient `headers` for Veo downloads)
|
||||
response.usage // credits on Runway; the other three report none (xAI's usage.cost_in_usd_ticks is not decoded)
|
||||
response.notices // Veo raiMediaFilteredReasons → filtered, xAI respect_moderation → moderated
|
||||
yield* response.video.materialize() // pull bytes before the URL expires
|
||||
|
||||
@@ -175,7 +176,7 @@ Effect.gen(function* () {
|
||||
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
|
||||
yield* generation.cancel() // fal PUT cancel_url, Runway DELETE /tasks/{id}; Veo and xAI succeed without a request
|
||||
|
||||
// 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.
|
||||
@@ -188,10 +189,11 @@ Effect.gen(function* () {
|
||||
|
||||
Tokens are route-owned JSON: Veo `{ operation }`, xAI `{ requestID }`, Runway `{ taskID }`, fal
|
||||
`{ requestID, statusURL, responseURL, cancelURL }` (fal's follow-up URLs are authoritative and absolute). Common-field
|
||||
lowering per provider: Veo takes inline media only and rejects `audio: false` and `n > 1`; xAI rejects `seed` and
|
||||
`negativePrompt` and routes a `video` input to edits or (`providerOptions.mode: "extend"`) extensions; fal rejects
|
||||
`durationSeconds`, `references`, and `frames.last` because the field names and enums differ per model; Runway passes
|
||||
`aspectRatio` through as its pixel `ratio` and rejects `n`.
|
||||
lowering per provider: Veo takes inline media only, rejects `audio: false` and `n > 1`, and requires `frames.first`
|
||||
when `frames.last` is set; xAI rejects `n`, `seed`, and `negativePrompt` and routes a `video` input to edits or
|
||||
(`providerOptions.mode: "extend"`) extensions; fal rejects `n`, plus `durationSeconds`, `references`, and `frames.last`
|
||||
because the field names and enums differ per model; Runway passes `aspectRatio` through as its pixel `ratio` and
|
||||
rejects `n`.
|
||||
|
||||
Deferred: `Video.complete(model, token, webhook)` (finish from a webhook payload without polling) and provider poll
|
||||
hints (none of the four providers emit one). Later providers: Luma, Kling, MiniMax, Replicate.
|
||||
@@ -241,10 +243,12 @@ name→id resolution. Multi-speaker (Gemini `speechConfig.multiSpeakerVoiceConfi
|
||||
`opus_48000_64`, Cartesia `{ container, encoding, sample_rate }`, Deepgram `encoding`+`container`) and declares the
|
||||
asset's media type rather than sniffing, because headerless PCM can look like an MPEG frame sync. Headerless PCM
|
||||
always carries `info.encoding`, `info.sampleRate`, and `info.channels`; its media type is the provider's declaration
|
||||
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini returns PCM only, so any
|
||||
other `format` is rejected rather than wrapped as WAV by the route. Every `format` value a route cannot produce (unknown
|
||||
to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but PCM on Gemini) fails the same way as an
|
||||
unsupported field: `UnsupportedOperation` with `operation: "media.format"`.
|
||||
(Gemini `audio/L16;codec=pcm;rate=24000`, Deepgram's `content-type`) or `audio/pcm`. Gemini's asset follows the
|
||||
provider's declared type: WAV for Gemini 3.8 TTS `generate`, headerless PCM otherwise. The route never wraps PCM as WAV,
|
||||
so `pcm` is the only explicit `format` it accepts, and not on Gemini 3.8 `generate`. Every `format` value a route cannot
|
||||
produce (unknown to it, a container on Cartesia SSE, WAV on an ElevenLabs stream, anything but `pcm` on Gemini, `pcm` on
|
||||
Gemini 3.8 `generate`) fails the same way as an unsupported field: `UnsupportedOperation` with
|
||||
`operation: "media.format"`.
|
||||
|
||||
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
|
||||
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
|
||||
@@ -277,7 +281,7 @@ const request = Transcription.request({
|
||||
language: "en", // provider-native passthrough
|
||||
timestamps: "segment", // none | segment | word
|
||||
diarize: true,
|
||||
speakers: 2, // expected count, hint only (AssemblyAI)
|
||||
speakers: 2, // exact speaker count (AssemblyAI only)
|
||||
providerOptions: { known_speaker_names: ["agent"] },
|
||||
})
|
||||
|
||||
@@ -291,10 +295,11 @@ yield* Transcription.resume(model, token)
|
||||
Transcription is the first modality whose providers span all three protocol kinds, and it needed no fourth kind.
|
||||
Every `MediaRoute` now carries its `kind`; `TranscriptionRoute` is the union of the inline, stream, and queued routes;
|
||||
`TranscriptionModel.fromRoute` is overloaded per protocol kind (arity picks the overload: `<Options>`,
|
||||
`<Options, Frame, State>`, `<Options, Token>`) and composes through `MediaRoute.inline` / `stream` / `queued`; and
|
||||
`TranscriptionClient` dispatches on `route.kind`. `generate` on a queued route is `start` then `await`; `stream` on an
|
||||
inline route is the response as a single `finish`, and on a queued route it is the status observations followed by
|
||||
`finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
|
||||
`<Options, Frame, State>`, `<Options, Token>`) and composes through the shared `composeRoute` (`src/media-model.ts`),
|
||||
which picks `MediaRoute.inline` / `stream` / `queued`; and `TranscriptionClient`, like every modality client, is
|
||||
`MediaClient.make` (`src/media-client.ts`), which dispatches on `route.kind`. `generate` on a queued route is `start`
|
||||
then `await`; `stream` on an inline route is the response as a single `finish`, and on a queued route it is the status
|
||||
observations followed by `finish`. `start` / `resume` on a non-queued route fail with `UnsupportedOperation` (`transcription.start`). The
|
||||
`finish` event carries the whole transcript (text, segments, words, language, duration, usage), so the stream route's
|
||||
`collect` is just "take `finish`".
|
||||
|
||||
@@ -309,11 +314,12 @@ Settled rules:
|
||||
word offsets, so segment timestamps and diarization also request word offsets there.
|
||||
- **Diarization.** `diarize` means segments (and words, where the provider labels them) carry `speaker`. Labels are
|
||||
provider-native strings — OpenAI `A` or a known speaker name, Deepgram `0`, Gemini `spk:0`, AssemblyAI `A` — with no
|
||||
cross-provider speaker model. `speakers` is a hint; only AssemblyAI (`speakers_expected`) accepts it.
|
||||
cross-provider speaker model. `speakers` is the exact number of speakers to label, which AssemblyAI (`speakers_expected`, the only route that
|
||||
accepts it) treats as a constraint rather than a hint.
|
||||
- **Language** is passed through (`language`, OpenAI `gpt-transcribe` `languages[]`, Gemini `languageCodes`,
|
||||
AssemblyAI `language_code`). `response.language` is the provider's own value, lowercased but not normalized: an
|
||||
ISO code on most routes, `english` from whisper-1, `en_us` from AssemblyAI. Deepgram and AssemblyAI assume English
|
||||
unless asked to detect, so a missing `language` enables their detection.
|
||||
ISO code on most routes (AssemblyAI's detection returns `en`), `english` from whisper-1. Deepgram and AssemblyAI
|
||||
assume English unless asked to detect, so a missing `language` enables their detection.
|
||||
- **Gemini** requires a transcribe model; other model ids fail with `UnsupportedOperation` before the call, because
|
||||
general models ignore `audioTranscriptionConfig` and answer conversationally. Streamed chunks carry whole speaker
|
||||
turns (one part per turn), which join with a space.
|
||||
@@ -323,7 +329,7 @@ Settled rules:
|
||||
|
||||
| Provider | Kind | Audio input | `timestamps` | `diarize` | Unsupported | Usage |
|
||||
|---|---|---|---|---|---|---|
|
||||
| OpenAI | stream (`stream: true` in `stream` mode) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model; streaming on `whisper-1` | `tokens` or `seconds` |
|
||||
| OpenAI | stream (`stream: true` in `stream` mode; `whisper-1` ignores `stream`, so it emits only `finish`) | multipart `file` (inline only) | `whisper-1` (`verbose_json`); diarize model: `segment` | `gpt-4o-transcribe-diarize` (`diarized_json`) | `speakers`; `prompt` on the diarize model | `tokens` or `seconds` |
|
||||
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
|
||||
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
|
||||
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
|
||||
@@ -353,7 +359,7 @@ GenerationAwaitOptions = { poll?: Poll }
|
||||
Poll = { interval?: Duration; timeout?: Duration }
|
||||
```
|
||||
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, and Replicate are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
`Generation` is not video-specific. Image routes on BFL, fal, Replicate, and Stability `upscale()` are queued; `Image.start` exists for them. A route declares itself `inline` or `queued`; `generate` on a queued route is `start` then `await`.
|
||||
|
||||
### Usage
|
||||
|
||||
@@ -400,14 +406,15 @@ Streams become `AsyncIterable` via `Stream.toAsyncIterable`. `AIError` is thrown
|
||||
|
||||
### Providers
|
||||
|
||||
Existing facades gain per-modality selectors; the modality routes each facade provides:
|
||||
Existing facades gain per-modality selectors; the modality routes each facade provides (*italics* are not
|
||||
implemented):
|
||||
|
||||
| Facade | llm | image | video | speech | transcription | other |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `OpenAI` | responses (default), chat | Images API (stream) | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
|
||||
| `OpenAI` | responses (default), chat | Images API (stream) | *Sora skipped (decision 8)* | ✓ | ✓ | |
|
||||
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
|
||||
| `XAI` | ✓ | ✓ | ✓ | | | |
|
||||
| `ElevenLabs` | | | | ✓ | Scribe | soundEffect, music |
|
||||
| `ElevenLabs` | | | | ✓ | *Scribe (pending)* | *soundEffect, music (phase 5)* |
|
||||
| `Cartesia` | | | | ✓ | | |
|
||||
| `Deepgram` | | | | Aura | ✓ | |
|
||||
| `Fal` | | ✓ (queued) | ✓ | | | |
|
||||
@@ -416,7 +423,7 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
|
||||
| `Replicate` | | ✓ (queued) | | | | |
|
||||
| `Stability` | | `image` (inline), `upscale()` (queued) | | | | |
|
||||
| `Runway` | | | ✓ | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | per provider | | | | |
|
||||
| `Luma`, `Kling`, `MiniMax` | | *deferred* | *deferred* | | | |
|
||||
|
||||
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.
|
||||
|
||||
|
||||
@@ -53,6 +53,8 @@ export type Event = Observation | { readonly type: "generation-finished"; readon
|
||||
|
||||
const TERMINAL: ReadonlySet<Status> = new Set(["completed", "failed", "cancelled", "expired"])
|
||||
|
||||
export const isTerminal = (status: Status) => TERMINAL.has(status)
|
||||
|
||||
export class Generation<Response> {
|
||||
readonly id: string
|
||||
readonly status: Status
|
||||
@@ -81,7 +83,7 @@ export class Generation<Response> {
|
||||
}
|
||||
|
||||
get terminal() {
|
||||
return TERMINAL.has(this.status)
|
||||
return isTerminal(this.status)
|
||||
}
|
||||
|
||||
refresh(): Effect.Effect<Generation<Response>, AIError> {
|
||||
@@ -109,9 +111,10 @@ export class Generation<Response> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Status observations as a stream, ending after the first terminal observation. Each poll is bounded by the time
|
||||
* remaining until `poll.timeout`, so a hung status request fails the stream instead of stalling it. (`Stream.interruptWhen`
|
||||
* would express this directly but deadlocks under `TestClock` when the source completes while the timer sleeps.)
|
||||
* Status observations as a stream, ending after the first terminal observation. Each poll and each sleep between polls
|
||||
* is bounded by the time remaining until `poll.timeout`, so a hung status request or a long interval fails the stream at
|
||||
* the deadline instead of stalling it. (`Stream.interruptWhen` would express this directly but deadlocks under
|
||||
* `TestClock` when the source completes while the timer sleeps.)
|
||||
*/
|
||||
events(options?: AwaitOptions): Stream.Stream<Event, AIError> {
|
||||
if (this.terminal) return Stream.make(this.event())
|
||||
@@ -120,17 +123,26 @@ export class Generation<Response> {
|
||||
Clock.currentTimeMillis.pipe(
|
||||
Effect.map((start) => {
|
||||
const deadline = start + Duration.toMillis(timeout)
|
||||
// Fail before polling once the deadline has passed: a fast status request could otherwise win the zero-budget
|
||||
// race and schedule another zero-delay poll.
|
||||
const refresh = Clock.currentTimeMillis.pipe(
|
||||
Effect.flatMap((now) =>
|
||||
this.refresh().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(Math.max(0, deadline - now)),
|
||||
orElse: () => this.timeoutError(timeout),
|
||||
}),
|
||||
),
|
||||
now >= deadline
|
||||
? this.timeoutError(timeout)
|
||||
: this.refresh().pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: Duration.millis(deadline - now),
|
||||
orElse: () => this.timeoutError(timeout),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
return Stream.fromEffectSchedule(refresh, this.schedule(options?.poll)).pipe(
|
||||
const schedule = this.schedule(options?.poll).pipe(
|
||||
Schedule.modifyDelay((meta) =>
|
||||
Effect.succeed(Duration.min(meta.duration, Duration.millis(Math.max(0, deadline - meta.now)))),
|
||||
),
|
||||
)
|
||||
return Stream.fromEffectSchedule(refresh, schedule).pipe(
|
||||
Stream.takeUntil((generation) => generation.terminal),
|
||||
Stream.map((generation) => generation.event()),
|
||||
)
|
||||
|
||||
@@ -110,8 +110,11 @@ const fromRequest = Effect.fn("AssemblyAITranscription.fromRequest")(function* (
|
||||
language_code: request.language,
|
||||
language_detection: request.language === undefined ? true : undefined,
|
||||
prompt: request.prompt,
|
||||
// Turn-level `utterances`, the only segments AssemblyAI returns, require speaker labels.
|
||||
speaker_labels: request.diarize === true || request.timestamps === "segment" ? true : undefined,
|
||||
// Turn-level `utterances`, the only segments AssemblyAI returns, and `speakers_expected` require speaker labels.
|
||||
speaker_labels:
|
||||
request.diarize === true || request.timestamps === "segment" || request.speakers !== undefined
|
||||
? true
|
||||
: undefined,
|
||||
speakers_expected: request.speakers,
|
||||
},
|
||||
request.providerOptions,
|
||||
@@ -155,8 +158,7 @@ const decodeResult = Effect.fn("AssemblyAITranscription.decodeResult")(function*
|
||||
const error = transcript.error ?? undefined
|
||||
if (status === "failed")
|
||||
return yield* output.ended("failed", `${route.name} transcription failed${error === undefined ? "" : `: ${error}`}`)
|
||||
if (status !== "completed")
|
||||
return yield* output.invalid(`${route.name} transcript ${context.token.transcriptID} has not finished`)
|
||||
if (status !== "completed") return yield* output.pending(context.token.transcriptID)
|
||||
const duration = transcript.audio_duration ?? undefined
|
||||
return new TranscriptionResponse({
|
||||
text: transcript.text ?? "",
|
||||
|
||||
@@ -31,13 +31,21 @@ export type Request = ImageRequestFor<BlackForestLabsImageOptions>
|
||||
// 2. Token and response schemas
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. */
|
||||
export const Token = Schema.Struct({ id: Schema.String, pollingURL: Schema.String })
|
||||
/**
|
||||
* Regional clusters answer on different hosts, so the returned `polling_url` is followed verbatim. BFL reports the
|
||||
* credit cost on submit, so it rides on the token; it is optional so tokens persisted before it existed still decode.
|
||||
*/
|
||||
export const Token = Schema.Struct({
|
||||
id: Schema.String,
|
||||
pollingURL: Schema.String,
|
||||
cost: Schema.optionalKey(Schema.Number),
|
||||
})
|
||||
export type Token = Schema.Schema.Type<typeof Token>
|
||||
|
||||
const StartResponse = Schema.Struct({
|
||||
id: Schema.String,
|
||||
polling_url: Schema.String,
|
||||
cost: optionalNull(Schema.Number),
|
||||
})
|
||||
|
||||
const Result = Schema.Struct({
|
||||
@@ -145,7 +153,11 @@ const fromRequest = Effect.fn("BlackForestLabsImages.fromRequest")(function* (re
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStart = route.decodeStarted(StartResponse, (value) => ({
|
||||
token: { id: value.id, pollingURL: value.polling_url },
|
||||
token: {
|
||||
id: value.id,
|
||||
pollingURL: value.polling_url,
|
||||
...(value.cost === undefined || value.cost === null ? {} : { cost: value.cost }),
|
||||
},
|
||||
snapshot: { id: value.id, status: "queued" },
|
||||
}))
|
||||
|
||||
@@ -169,14 +181,16 @@ const decodeResult = Effect.fn("BlackForestLabsImages.decodeResult")(function* (
|
||||
if (isModerated(document.status)) return yield* output.contentPolicy(`${route.name} moderated the generation`)
|
||||
if (status === "failed" || status === "expired")
|
||||
return yield* output.ended(status, `${route.name} generation ${context.token.id} ended with ${document.status}`)
|
||||
if (status !== "completed" || document.result === undefined || document.result === null)
|
||||
if (status !== "completed") return yield* output.pending(context.token.id)
|
||||
if (document.result === undefined || document.result === null)
|
||||
return yield* output.invalid(`${route.name} generation ${context.token.id} has no result`)
|
||||
const { sample, seed, prompt, ...rest } = document.result
|
||||
// A settled `cost` on the result supersedes the submit-time cost carried on the token.
|
||||
const cost = document.cost ?? context.token.cost
|
||||
return new ImageResponse({
|
||||
// `sample` is a signed URL that expires 10 minutes after the result is ready, so it is downloaded now.
|
||||
images: [yield* context.materialize(Media.url(sample))],
|
||||
usage:
|
||||
document.cost === undefined || document.cost === null ? undefined : { type: "credits", credits: document.cost },
|
||||
usage: cost === undefined ? undefined : { type: "credits", credits: cost },
|
||||
providerMetadata: {
|
||||
bfl: { id: context.token.id, seed: seed ?? undefined, prompt: prompt ?? undefined, ...rest },
|
||||
},
|
||||
|
||||
@@ -67,6 +67,9 @@ const queryParameters = (request: Request) => {
|
||||
}
|
||||
|
||||
const fromRequest = Effect.fn("DeepgramSpeech.fromRequest")(function* (request: Request) {
|
||||
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
|
||||
if (request.timestamps === true)
|
||||
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
|
||||
if (
|
||||
request.format !== undefined &&
|
||||
FORMATS[request.format] === undefined &&
|
||||
@@ -117,7 +120,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>(route, {
|
||||
unsupported: ["voice", "language", "instructions", "timestamps"],
|
||||
unsupported: ["voice", "language", "instructions"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes) => bytes,
|
||||
initial: () => ({ chunks: [] }),
|
||||
|
||||
@@ -49,7 +49,7 @@ const QueueResult = Schema.StructWithRest(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const sizing = (model: string) => {
|
||||
if (/^fal-ai\/(nano-banana|flux-pro\/v1\.1-ultra)/.test(model)) return "aspect_ratio"
|
||||
if (/^fal-ai\/(nano-banana|flux-pro\/(v1\.1-ultra|kontext))/.test(model)) return "aspect_ratio"
|
||||
if (model.startsWith("fal-ai/flux")) return "image_size"
|
||||
return undefined
|
||||
}
|
||||
@@ -63,20 +63,24 @@ const validate = (request: Request) => {
|
||||
return Effect.fail(route.unsupported("media.size", `${id} sizes by aspectRatio`))
|
||||
if (request.aspectRatio !== undefined && field === "image_size")
|
||||
return Effect.fail(route.unsupported("media.aspectRatio", `${id} sizes by size (image_size)`))
|
||||
if ((request.images?.length ?? 0) > 1 && !isEdit(id))
|
||||
if ((request.images?.length ?? 0) > 1 && !takesImageList(id))
|
||||
return Effect.fail(
|
||||
route.unsupported("media.images", `${id} takes one image_url; use an /edit endpoint for several images`),
|
||||
route.unsupported(
|
||||
"media.images",
|
||||
`${id} takes one image_url; use an /edit or /multi endpoint for several images`,
|
||||
),
|
||||
)
|
||||
return Effect.void
|
||||
}
|
||||
|
||||
// `/edit` endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one `image_url` (beside `mask_url`).
|
||||
const isEdit = (model: string) => model.endsWith("/edit")
|
||||
// `/edit` and `/multi` (Kontext) endpoints take an `image_urls` list; image-to-image, fill, and Ultra take one
|
||||
// `image_url` (beside `mask_url`).
|
||||
const takesImageList = (model: string) => model.endsWith("/edit") || model.endsWith("/multi")
|
||||
|
||||
const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Request) {
|
||||
yield* validate(request)
|
||||
const images = yield* Effect.forEach(request.images ?? [], (image) => FalQueue.mediaUrl(image, route.name))
|
||||
const edit = isEdit(request.model.id)
|
||||
const list = takesImageList(request.model.id)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
@@ -86,8 +90,8 @@ const fromRequest = Effect.fn("FalImages.fromRequest")(function* (request: Reque
|
||||
image_size: request.size === undefined ? undefined : MediaInput.dimensions(request.size),
|
||||
aspect_ratio: request.aspectRatio,
|
||||
output_format: request.format,
|
||||
image_urls: edit && images.length > 0 ? images : undefined,
|
||||
image_url: edit ? undefined : images[0],
|
||||
image_urls: list && images.length > 0 ? images : undefined,
|
||||
image_url: list ? undefined : images[0],
|
||||
mask_url: request.mask === undefined ? undefined : yield* FalQueue.mediaUrl(request.mask, route.name),
|
||||
},
|
||||
request.providerOptions,
|
||||
@@ -112,12 +116,14 @@ const decodeResult = Effect.fn("FalImages.decodeResult")(function* (
|
||||
// With the safety checker on, flagged images come back blacked out rather than omitted.
|
||||
const flagged = (has_nsfw_concepts ?? []).flatMap((value, index) => (value ? [index] : []))
|
||||
return new ImageResponse({
|
||||
images: images.map((image) =>
|
||||
Media.url(image.url, {
|
||||
mediaType: image.content_type ?? undefined,
|
||||
info: { width: image.width ?? undefined, height: image.height ?? undefined },
|
||||
}),
|
||||
),
|
||||
images: images.map((image) => {
|
||||
const info = { width: image.width ?? undefined, height: image.height ?? undefined }
|
||||
// `sync_mode: true` returns data URIs instead of hosted URLs.
|
||||
return (
|
||||
Media.parseDataUrl(image.url, { info }) ??
|
||||
Media.url(image.url, { mediaType: image.content_type ?? undefined, info })
|
||||
)
|
||||
}),
|
||||
notices:
|
||||
flagged.length === 0
|
||||
? undefined
|
||||
|
||||
@@ -101,7 +101,7 @@ const generationConfig = (request: Request) => {
|
||||
const fromRequest = Effect.fn("GoogleImages.fromRequest")(function* (request: Request) {
|
||||
if (request.n !== undefined && request.n > 1)
|
||||
return yield* route.unsupported(
|
||||
"image.n",
|
||||
"media.n",
|
||||
`${route.name} generates one image per request; call it once per image instead of n=${request.n}`,
|
||||
)
|
||||
const parts = yield* Effect.forEach(request.images ?? [], (image) =>
|
||||
|
||||
@@ -56,10 +56,18 @@ interface State extends SpeechStream.Audio, GeminiGenerateContent.Metadata {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const fromRequest = Effect.fn("GoogleSpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
|
||||
if (request.timestamps === true)
|
||||
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
|
||||
if (request.format === "pcm" && request.mode === "generate" && /^gemini-3\.8-.*-tts(?:-|$)/.test(request.model.id))
|
||||
return yield* route.unsupported(
|
||||
"media.format",
|
||||
`${route.name} returns WAV by default for Gemini 3.8 TTS unary requests; omit the format to accept it`,
|
||||
)
|
||||
if (request.format !== undefined && request.format !== "pcm")
|
||||
return yield* route.unsupported(
|
||||
"media.format",
|
||||
`${route.name} only returns raw PCM; request format "pcm" or omit it, then wrap the samples yourself`,
|
||||
`${route.name} only accepts raw PCM as an explicit format; omit it to accept the provider's default output`,
|
||||
)
|
||||
const voiceName = SpeechStream.voiceID(request.voice)
|
||||
return MediaProtocol.json(
|
||||
@@ -97,10 +105,18 @@ const step = Effect.fn("GoogleSpeech.step")(function* (state: State, frame: stri
|
||||
return [next, audio.flatMap((part) => SpeechStream.delta(next, part.data)[1])] as const
|
||||
})
|
||||
|
||||
const finish = (state: State) => {
|
||||
const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) => {
|
||||
const sampleRate = SpeechStream.sampleRate(state.mimeType) ?? DEFAULT_SAMPLE_RATE
|
||||
const output =
|
||||
state.mimeType?.split(";")[0]?.toLowerCase() === "audio/wav"
|
||||
? SpeechStream.container("wav", sampleRate)
|
||||
: SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`)
|
||||
if (context.request.format === "pcm" && output.info.format !== "pcm")
|
||||
return Effect.fail(
|
||||
route.frameError(`Google Speech returned ${output.info.format} instead of the requested raw PCM`),
|
||||
)
|
||||
return SpeechStream.finish(route, state, {
|
||||
...SpeechStream.pcm("pcm_s16le", sampleRate, state.mimeType ?? `audio/L16;codec=pcm;rate=${sampleRate}`),
|
||||
...output,
|
||||
usage: GeminiGenerateContent.usage(state.usage),
|
||||
providerMetadata: GeminiGenerateContent.providerMetadata(state),
|
||||
detail: state.finishReason === undefined ? undefined : `finish reason: ${state.finishReason}`,
|
||||
@@ -112,7 +128,7 @@ const finish = (state: State) => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string, State>(route, {
|
||||
unsupported: ["instructions", "speed", "timestamps"],
|
||||
unsupported: ["instructions", "speed"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => GeminiGenerateContent.frames(bytes, context.request.mode),
|
||||
initial: () => ({ chunks: [] }),
|
||||
|
||||
@@ -149,8 +149,7 @@ const decodeResult = Effect.fn("GoogleVideo.decodeResult")(function* (
|
||||
const output = yield* decodeOperation(response)
|
||||
const operation = output.value
|
||||
const status = statusOf(operation)
|
||||
if (status === "running")
|
||||
return yield* output.invalid(`${route.name} operation ${context.token.operation} has not finished`)
|
||||
if (status === "running") return yield* output.pending(context.token.operation)
|
||||
if (status === "failed")
|
||||
return yield* output.ended(
|
||||
"failed",
|
||||
|
||||
@@ -48,15 +48,17 @@ const Usage = Schema.Struct({
|
||||
output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
})
|
||||
|
||||
const OpenAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(
|
||||
Schema.Struct({
|
||||
b64_json: Schema.optional(Schema.String),
|
||||
url: Schema.optional(Schema.String),
|
||||
revised_prompt: Schema.optional(Schema.String),
|
||||
}),
|
||||
),
|
||||
/** What the provider actually rendered; it can differ from the request when `auto` or a default applied. */
|
||||
const Settings = {
|
||||
output_format: Schema.optional(Schema.String),
|
||||
size: Schema.optional(Schema.String),
|
||||
quality: Schema.optional(Schema.String),
|
||||
background: Schema.optional(Schema.String),
|
||||
}
|
||||
|
||||
const OpenAIImageResponse = Schema.Struct({
|
||||
data: Schema.Array(Schema.Struct({ b64_json: Schema.String })),
|
||||
...Settings,
|
||||
usage: Schema.optional(Usage),
|
||||
})
|
||||
|
||||
@@ -69,11 +71,13 @@ const StreamEvent = Schema.Union([
|
||||
type: Schema.Literals(["image_generation.partial_image", "image_edit.partial_image"]),
|
||||
b64_json: Schema.String,
|
||||
partial_image_index: Schema.Number,
|
||||
...Settings,
|
||||
output_format: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.Literals(["image_generation.completed", "image_edit.completed"]),
|
||||
b64_json: Schema.String,
|
||||
...Settings,
|
||||
output_format: Schema.String,
|
||||
usage: Schema.optional(Usage),
|
||||
}),
|
||||
@@ -92,6 +96,9 @@ type Frame = string | { readonly document: string; readonly requested: string |
|
||||
interface State {
|
||||
readonly completed: number
|
||||
readonly format?: string
|
||||
readonly size?: string
|
||||
readonly quality?: string
|
||||
readonly background?: string
|
||||
readonly usage?: MediaUsage
|
||||
}
|
||||
|
||||
@@ -110,10 +117,6 @@ const nativeOptions = (options: OpenAIImageOptions | undefined) => {
|
||||
|
||||
const streamOptions = (request: MediaProtocol.Addressed<Request>) => {
|
||||
if (request.mode !== "stream") return Effect.succeed(undefined)
|
||||
if (request.model.id.startsWith("dall-e"))
|
||||
return Effect.fail(
|
||||
route.unsupported("media.stream", `${request.model.id} does not stream; use Image.generate or a GPT image model`),
|
||||
)
|
||||
if (request.n !== undefined && request.n > 1)
|
||||
return Effect.fail(
|
||||
route.unsupported("media.n", `${route.name} streams one image; use Image.generate for n=${request.n}`),
|
||||
@@ -194,21 +197,34 @@ const usage = (value: Schema.Schema.Type<typeof Usage> | undefined): MediaUsage
|
||||
details: { openai: value },
|
||||
}
|
||||
|
||||
const eventImage = (frame: string, label: string, data: string, format: string) =>
|
||||
/** `size` echoes the rendered `WIDTHxHEIGHT`; `auto` or any other value leaves the dimensions unknown. */
|
||||
const info = (format: string, size: string | undefined): Media.Info => {
|
||||
const match = size?.match(/^(\d+)x(\d+)$/)
|
||||
return match ? { format, width: Number(match[1]), height: Number(match[2]) } : { format }
|
||||
}
|
||||
|
||||
const eventImage = (frame: string, label: string, data: string, format: string, size: string | undefined) =>
|
||||
MediaInput.decodedAsset((message, cause) => route.frameError(message, frame, cause), label, data, `image/${format}`, {
|
||||
info: { format },
|
||||
info: info(format, size),
|
||||
})
|
||||
|
||||
const onEvent = Effect.fn("OpenAIImages.onEvent")(function* (state: State, frame: string) {
|
||||
const event = yield* decodeEvent(frame)
|
||||
const format = event.output_format
|
||||
if ("partial_image_index" in event) {
|
||||
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format)
|
||||
const image = yield* eventImage(frame, `${route.name} partial image`, event.b64_json, format, event.size)
|
||||
return [state, [ImagePartialEvent.make({ index: event.partial_image_index, image })]] as const
|
||||
}
|
||||
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format)
|
||||
const image = yield* eventImage(frame, `${route.name} result ${state.completed}`, event.b64_json, format, event.size)
|
||||
return [
|
||||
{ ...state, completed: state.completed + 1, format, usage: usage(event.usage) },
|
||||
{
|
||||
completed: state.completed + 1,
|
||||
format,
|
||||
size: event.size,
|
||||
quality: event.quality,
|
||||
background: event.background,
|
||||
usage: usage(event.usage),
|
||||
},
|
||||
[ImageOutputEvent.make({ index: state.completed, image })],
|
||||
] as const
|
||||
})
|
||||
@@ -219,16 +235,20 @@ const onDocument = Effect.fn("OpenAIImages.onDocument")(function* (frame: Exclud
|
||||
Effect.mapError((cause) => invalid(`${route.name} returned an invalid response`, cause)),
|
||||
)
|
||||
const format = decoded.output_format ?? frame.requested ?? "png"
|
||||
const mediaType = `image/${format}`
|
||||
const images = yield* Effect.forEach(decoded.data, (item, index) =>
|
||||
MediaInput.imageOutput(invalid, `${route.name} result ${index}`, item, mediaType, {
|
||||
info: { format },
|
||||
providerMetadata:
|
||||
item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
|
||||
MediaInput.decodedAsset(invalid, `${route.name} result ${index}`, item.b64_json, `image/${format}`, {
|
||||
info: info(format, decoded.size),
|
||||
}),
|
||||
)
|
||||
if (images.length === 0) return yield* invalid(`${route.name} returned no images`)
|
||||
const state: State = { completed: images.length, format, usage: usage(decoded.usage) }
|
||||
const state: State = {
|
||||
completed: images.length,
|
||||
format,
|
||||
size: decoded.size,
|
||||
quality: decoded.quality,
|
||||
background: decoded.background,
|
||||
usage: usage(decoded.usage),
|
||||
}
|
||||
return [state, images.map((image, index) => ImageOutputEvent.make({ index, image }))] as const
|
||||
})
|
||||
|
||||
@@ -237,7 +257,17 @@ const step = (state: State, frame: Frame) => (typeof frame === "string" ? onEven
|
||||
const finish = (state: State) => {
|
||||
if (state.completed === 0) return Effect.fail(route.incomplete())
|
||||
return Effect.succeed([
|
||||
ImageFinishEvent.make({ usage: state.usage, providerMetadata: { openai: { outputFormat: state.format } } }),
|
||||
ImageFinishEvent.make({
|
||||
usage: state.usage,
|
||||
providerMetadata: {
|
||||
openai: {
|
||||
outputFormat: state.format,
|
||||
size: state.size,
|
||||
quality: state.quality,
|
||||
background: state.background,
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -143,12 +143,14 @@ const adapter = {
|
||||
restoreHostedToolItem: (item: unknown) => (Schema.is(OpenAIResponsesHostedToolItem)(item) ? item : undefined),
|
||||
} satisfies OpenResponses.ProviderAdapter
|
||||
|
||||
// Only GPT-6 Astra accepts `configuration_update`, and never alongside automatic `context_management` compaction.
|
||||
// GPT-6 Astra, Sol, and Luna accept `configuration_update` only in standard mode (not `reasoning.mode: "pro"` or
|
||||
// `-pro` slugs), and never alongside automatic `context_management` compaction.
|
||||
const supportsEffortUpdates = (request: LLMRequest) => {
|
||||
if (request.providerOptions?.contextManagement !== undefined) return false
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(request.http?.body?.reasoning)) return false
|
||||
const override = request.model.compatibility?.supportsEffortUpdates
|
||||
if (override !== undefined) return override
|
||||
return /(?:^|\/)gpt-6-astra$/i.test(request.model.id)
|
||||
return /(?:^|\/)gpt-6-(?:astra|sol|luna)$/i.test(request.model.id)
|
||||
}
|
||||
|
||||
const nativeImageToolInput = (tool: ToolDefinition) => {
|
||||
|
||||
@@ -61,6 +61,9 @@ interface State extends SpeechStream.Audio {
|
||||
const supportsSse = (model: string) => !/^tts-1(-hd)?(-|$)/.test(model)
|
||||
|
||||
const fromRequest = Effect.fn("OpenAISpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
// Not in `unsupported`: that list would also reject `timestamps: false`, which asks for nothing.
|
||||
if (request.timestamps === true)
|
||||
return yield* route.unsupported("media.timestamps", `${route.name} does not return timestamps`)
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
@@ -121,7 +124,7 @@ const finish = (state: State, context: MediaProtocol.ResponseContext<Request>) =
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, string | Uint8Array, State>(route, {
|
||||
unsupported: ["language", "timestamps"],
|
||||
unsupported: ["language"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) => (isSse(context.body) ? Framing.sse.frame(bytes) : bytes),
|
||||
initial: () => ({ chunks: [], done: false }),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { mergeJsonRecords, type MediaUsage } from "../schema/index.js"
|
||||
import { AIError, mergeJsonRecords, type MediaUsage } from "../schema/index.js"
|
||||
import {
|
||||
TranscriptionFinishEvent,
|
||||
TranscriptionModel,
|
||||
@@ -59,6 +60,9 @@ const Usage = Schema.Union([
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
total_tokens: Schema.optional(Schema.Number),
|
||||
input_token_details: Schema.optional(
|
||||
Schema.Struct({ audio_tokens: Schema.optional(Schema.Number), text_tokens: Schema.optional(Schema.Number) }),
|
||||
),
|
||||
}),
|
||||
Schema.Struct({ type: Schema.Literal("duration"), seconds: Schema.Number }),
|
||||
])
|
||||
@@ -75,14 +79,23 @@ const transcriptFields = {
|
||||
usage: Schema.optional(Usage),
|
||||
}
|
||||
|
||||
/** OpenAI may add stream event types; frames outside `EVENT_TYPES` are ignored. */
|
||||
const EventType = Schema.Struct({ type: Schema.String })
|
||||
const Event = Schema.Union([
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.delta"), delta: Schema.String }),
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.segment"), ...Segment.fields }),
|
||||
Schema.Struct({ type: Schema.Literal("transcript.text.done"), ...transcriptFields }),
|
||||
Schema.Struct({
|
||||
type: Schema.Literal("error"),
|
||||
message: Schema.optional(Schema.String),
|
||||
error: Schema.optional(Schema.Struct({ message: Schema.optional(Schema.String) })),
|
||||
}),
|
||||
])
|
||||
const EVENT_TYPES = new Set(["transcript.text.delta", "transcript.text.segment", "transcript.text.done", "error"])
|
||||
const Transcript = Schema.Struct(transcriptFields)
|
||||
type Transcript = Schema.Schema.Type<typeof Transcript>
|
||||
|
||||
const decodeEventType = route.decodeFrame(EventType)
|
||||
const decodeEvent = route.decodeFrame(Event)
|
||||
const decodeTranscript = route.decodeFrame(Transcript)
|
||||
|
||||
@@ -118,10 +131,12 @@ const capabilities = (model: string): Capabilities => {
|
||||
return TRANSCRIBE
|
||||
}
|
||||
|
||||
/** whisper-1 ignores `stream`, so its `stream` mode sends a plain request and emits only `finish`. */
|
||||
const streamsEvents = (request: MediaProtocol.Addressed<Request>) =>
|
||||
request.mode === "stream" && capabilities(request.model.id).stream
|
||||
|
||||
const validate = (request: MediaProtocol.Addressed<Request>, model: Capabilities) => {
|
||||
const id = request.model.id
|
||||
if (request.mode === "stream" && !model.stream)
|
||||
return Effect.fail(route.unsupported("media.stream", `${id} does not stream; use Transcription.generate`))
|
||||
if (request.diarize === true && !model.diarize)
|
||||
return Effect.fail(route.unsupported("media.diarize", `${id} does not diarize; use gpt-4o-transcribe-diarize`))
|
||||
if (request.prompt !== undefined && model.diarize)
|
||||
@@ -173,7 +188,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
|
||||
timestamp_granularities: responseFormat === "verbose_json" ? [request.timestamps] : undefined,
|
||||
// Diarizing audio longer than 30 seconds requires a chunking strategy.
|
||||
chunking_strategy: model.diarize ? "auto" : undefined,
|
||||
stream: request.mode === "stream" ? true : undefined,
|
||||
stream: streamsEvents(request) ? true : undefined,
|
||||
},
|
||||
{
|
||||
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
|
||||
@@ -196,7 +211,15 @@ const segment = (value: Schema.Schema.Type<typeof Segment>): TranscriptionSegmen
|
||||
})
|
||||
|
||||
const onEvent = Effect.fn("OpenAITranscription.onEvent")(function* (state: State, frame: string) {
|
||||
if (!EVENT_TYPES.has((yield* decodeEventType(frame)).type)) return [state, []] as const
|
||||
const event = yield* decodeEvent(frame)
|
||||
if (event.type === "error")
|
||||
return yield* new AIError({
|
||||
reason: classifyProviderFailure({
|
||||
message: `${route.name} stream failed: ${event.message ?? event.error?.message ?? "unknown error"}`,
|
||||
rawBody: frame,
|
||||
}),
|
||||
})
|
||||
if (event.type === "transcript.text.done") return [{ ...state, transcript: event }, []] as const
|
||||
if (event.type === "transcript.text.delta")
|
||||
return [state, event.delta.length === 0 ? [] : [TranscriptionTextDeltaEvent.make({ delta: event.delta })]] as const
|
||||
@@ -246,7 +269,7 @@ export const protocol = MediaProtocol.stream<Request, TranscriptionEvent, Frame,
|
||||
unsupported: ["speakers"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes, context) =>
|
||||
context.request.mode === "stream"
|
||||
streamsEvents(context.request)
|
||||
? Framing.sse.frame(bytes)
|
||||
: Framing.document.frame(bytes).pipe(Stream.map((document) => ({ document }))),
|
||||
initial: () => ({ segments: [] }),
|
||||
|
||||
@@ -132,8 +132,7 @@ const decodeResult = Effect.fn("ReplicateImages.decodeResult")(function* (
|
||||
status,
|
||||
`${route.name} prediction ${context.token.id} ${prediction.status}${typeof prediction.error === "string" ? `: ${prediction.error}` : ""}`,
|
||||
)
|
||||
if (status !== "completed")
|
||||
return yield* output.invalid(`${route.name} prediction ${context.token.id} has not finished`)
|
||||
if (status !== "completed") return yield* output.pending(context.token.id)
|
||||
if (prediction.data_removed === true)
|
||||
return yield* output.ended("expired", `${route.name} removed the output of prediction ${context.token.id}`)
|
||||
if (!isOutput(prediction.output))
|
||||
|
||||
@@ -141,8 +141,7 @@ const decodeResult = Effect.fn("RunwayVideo.decodeResult")(function* (
|
||||
}
|
||||
if (status === "cancelled")
|
||||
return yield* output.ended("cancelled", `${route.name} task ${context.token.taskID} was cancelled`)
|
||||
if (status !== "completed")
|
||||
return yield* output.invalid(`${route.name} task ${context.token.taskID} has not finished`)
|
||||
if (status !== "completed") return yield* output.pending(context.token.taskID)
|
||||
const urls = task.output ?? []
|
||||
if (urls.length === 0) return yield* output.invalid(`${route.name} task succeeded without any output`)
|
||||
return new VideoResponse({
|
||||
@@ -171,7 +170,7 @@ export const protocol = MediaProtocol.queued<Request, VideoResponse, Token>(rout
|
||||
start: { body: { from: fromRequest }, decode: decodeStart },
|
||||
status: { path: taskPath, decode: decodeStatus },
|
||||
result: { path: taskPath, decode: decodeResult },
|
||||
cancel: { method: "DELETE", path: taskPath },
|
||||
cancel: { method: "DELETE", path: taskPath, activeOnly: true },
|
||||
})
|
||||
|
||||
const startPath = (request: Request) => {
|
||||
|
||||
@@ -175,7 +175,7 @@ const decodeUpscaleResult = Effect.fn("StabilityImages.decodeUpscaleResult")(fun
|
||||
) {
|
||||
if (response.status === 202) {
|
||||
const output = yield* upscaleRoute.text(response)
|
||||
return yield* output.invalid(`${upscaleRoute.name} upscale ${context.token.id} has not finished`)
|
||||
return yield* output.pending(context.token.id)
|
||||
}
|
||||
return yield* decodeUpscaleImage(response)
|
||||
})
|
||||
|
||||
@@ -101,7 +101,8 @@ const decodeResponse = Effect.fn("XAIImages.decodeResponse")(function* (
|
||||
)
|
||||
if (images.length === 0) return yield* output.invalid(`${route.name} returned no images`)
|
||||
const usage = ProviderShared.isRecord(decoded.usage) ? decoded.usage : undefined
|
||||
// xAI reports image counts rather than tokens, seconds, or credits; the raw record stays in provider metadata.
|
||||
// xAI reports a USD cost (`cost_in_usd_ticks`) rather than tokens, seconds, or credits; the raw record stays in
|
||||
// provider metadata.
|
||||
return new ImageResponse({
|
||||
images,
|
||||
providerMetadata: usage === undefined ? undefined : { xai: { usage } },
|
||||
|
||||
@@ -136,8 +136,7 @@ const decodeResult = Effect.fn("XAIVideo.decodeResult")(function* (
|
||||
const output = yield* decodeVideoStatus(response)
|
||||
const decoded = output.value
|
||||
const status = yield* MediaProtocol.status(STATUS, decoded.status, output)
|
||||
if (status === "running")
|
||||
return yield* output.invalid(`${route.name} request ${context.token.requestID} has not finished`)
|
||||
if (status === "running") return yield* output.pending(context.token.requestID)
|
||||
if (status === "failed") {
|
||||
const code = decoded.error?.code ?? undefined
|
||||
const message = decoded.error?.message ?? undefined
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Duration, Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js"
|
||||
import { Media } from "../media.js"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
|
||||
@@ -9,6 +8,7 @@ import { mergeJsonRecords, type OpenString } from "../schema/index.js"
|
||||
const route = MediaProtocol.identity({ id: "zai-images", name: "Z.ai Images", provider: "zai" })
|
||||
export const DEFAULT_BASE_URL = "https://api.z.ai/api/paas/v4"
|
||||
export const PATH = "/images/generations"
|
||||
const OUTPUT_RETENTION = Duration.days(30)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
@@ -76,7 +76,7 @@ const decodeResponse = Effect.fn("ZAIImages.decodeResponse")(function* (
|
||||
const filters = decoded.content_filter ?? []
|
||||
return new ImageResponse({
|
||||
// Z.ai returns only URLs and no content type; the media type resolves when the asset is materialized.
|
||||
images: decoded.data.map((item) => Media.url(item.url)),
|
||||
images: yield* Effect.forEach(decoded.data, (item) => MediaProtocol.expiringUrl(item.url, OUTPUT_RETENTION)),
|
||||
// Z.ai reports applied content filters alongside a successful result; surface them instead of dropping them.
|
||||
notices:
|
||||
filters.length === 0
|
||||
|
||||
@@ -137,6 +137,11 @@ export interface Queued<Request, Response, Token> {
|
||||
readonly cancel?: {
|
||||
readonly method: AuthInput["method"]
|
||||
readonly path: (token: Token) => string
|
||||
/**
|
||||
* Fetch a fresh status first and skip the call for terminal generations, for providers whose cancel endpoint
|
||||
* destroys finished work (Runway's `DELETE /v1/tasks/{id}` deletes completed tasks and their outputs).
|
||||
*/
|
||||
readonly activeOnly?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
@@ -195,7 +200,8 @@ export const identity = (input: { readonly id: string; readonly name: string; re
|
||||
/**
|
||||
* Read a text body while retaining the original payload and HTTP context on every downstream error. `invalid` is a
|
||||
* malformed provider document; `ended` is a generation that reached a terminal status without output (`failed` is
|
||||
* provider-side, `cancelled`/`expired` mean the result will never exist); `contentPolicy` is a moderated result.
|
||||
* provider-side, `cancelled`/`expired` mean the result will never exist); `pending` is a `result()` read before the
|
||||
* generation finished, which is caller misuse; `contentPolicy` is a moderated result.
|
||||
*/
|
||||
const text = Effect.fn("MediaProtocol.text")(function* (response: HttpClientResponse.HttpClientResponse) {
|
||||
const http = context(response)
|
||||
@@ -224,6 +230,14 @@ export const identity = (input: { readonly id: string; readonly name: string; re
|
||||
? new ProviderInternalError({ message, body, http })
|
||||
: new InvalidRequestError({ message, body, http }),
|
||||
}),
|
||||
pending: (id: string) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: `${input.name} generation ${id} has not finished; await it before reading the result`,
|
||||
body,
|
||||
http,
|
||||
}),
|
||||
}),
|
||||
contentPolicy: (message: string) => new AIError({ reason: new ContentPolicyError({ message, body, http }) }),
|
||||
}
|
||||
})
|
||||
@@ -285,9 +299,8 @@ export const status = <Table extends Record<string, Status>>(
|
||||
raw: string,
|
||||
output: Output,
|
||||
): Effect.Effect<Status, AIError> => {
|
||||
const normalized: Status | undefined = table[raw]
|
||||
if (normalized === undefined) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
|
||||
return Effect.succeed(normalized)
|
||||
if (!Object.hasOwn(table, raw)) return Effect.fail(output.invalid(`Unknown generation status "${raw}"`))
|
||||
return Effect.succeed(table[raw])
|
||||
}
|
||||
|
||||
/** A `url` asset whose provider-declared retention window starts now. */
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Endpoint } from "./endpoint.js"
|
||||
import { RequestExecutorService, type Interface } from "./executor-service.js"
|
||||
import { RequestExecutor } from "./executor.js"
|
||||
import { MediaProtocol } from "./media-protocol.js"
|
||||
import { Generation } from "../generation.js"
|
||||
import { Generation, isTerminal } from "../generation.js"
|
||||
import type { Media } from "../media.js"
|
||||
import {
|
||||
AIError,
|
||||
@@ -164,14 +164,19 @@ export const queued = <Request extends MediaRequest, Response, Token>(
|
||||
transport
|
||||
.call("GET", operation.path(token), http, execute)
|
||||
.pipe(Effect.flatMap((sent) => operation.decode(sent.response, { token, auth: sent.auth, materialize })))
|
||||
const status = poll(protocol.status)
|
||||
const cancel = protocol.cancel
|
||||
const send =
|
||||
cancel === undefined
|
||||
? undefined
|
||||
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid)
|
||||
return {
|
||||
status: poll(protocol.status),
|
||||
status,
|
||||
result: poll(protocol.result),
|
||||
cancel:
|
||||
cancel === undefined
|
||||
? undefined
|
||||
: transport.call(cancel.method, cancel.path(token), http, execute).pipe(Effect.asVoid),
|
||||
send !== undefined && cancel?.activeOnly
|
||||
? status.pipe(Effect.flatMap((snapshot) => (isTerminal(snapshot.status) ? Effect.void : send)))
|
||||
: send,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,7 +385,11 @@ const encode = (body: MediaProtocol.Body | undefined, headers: Headers.Headers)
|
||||
}
|
||||
}
|
||||
|
||||
/** Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. */
|
||||
/**
|
||||
* Common fields are never silently dropped: a present field the protocol declared unsupported fails typed. `false`
|
||||
* counts as present because some booleans mean something when false (video `audio`); protocols reject opt-in
|
||||
* booleans such as speech `timestamps` with `=== true` in `body.from` instead of listing them.
|
||||
*/
|
||||
const rejectUnsupported = <Request extends object>(
|
||||
route: string,
|
||||
provider: ProviderID,
|
||||
|
||||
@@ -351,10 +351,34 @@ describe("OpenAI Responses effort updates", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips markers when the body overlay selects pro reasoning mode", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ apiKey: "fixture", http: { body: { reasoning: { mode: "pro" } } } }).responses(
|
||||
"gpt-6-sol",
|
||||
),
|
||||
messages: conversation,
|
||||
providerOptions: { reasoningEffort: "low" },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(updates(prepared.body)).toEqual([])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "low" })
|
||||
}),
|
||||
)
|
||||
|
||||
for (const [id, supported] of [
|
||||
["gpt-6-astra", true],
|
||||
["openai/gpt-6-astra", true],
|
||||
["gpt-6-sol", true],
|
||||
["openai/gpt-6-sol", true],
|
||||
["gpt-6-luna", true],
|
||||
["openai/gpt-6-luna", true],
|
||||
["gpt-6-astra-2026-09-01", false],
|
||||
["gpt-6-sol-pro", false],
|
||||
["gpt-6-luna-pro", false],
|
||||
["gpt-6-sol-fast", false],
|
||||
["gpt-5.6-sol", false],
|
||||
] as const) {
|
||||
it.effect(`${supported ? "lowers" : "strips"} markers for ${id}`, () =>
|
||||
|
||||
@@ -23,6 +23,7 @@ import { Provider as ProviderSubpath } from "@opencode/ai/provider"
|
||||
import {
|
||||
AssemblyAI,
|
||||
Baseten,
|
||||
BlackForestLabs,
|
||||
Cartesia,
|
||||
CloudflareAIGateway,
|
||||
CloudflareWorkersAI,
|
||||
@@ -32,14 +33,18 @@ import {
|
||||
Fal,
|
||||
Fireworks,
|
||||
Google,
|
||||
Meta,
|
||||
OpenCodeZen,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
Replicate,
|
||||
Runway,
|
||||
Stability,
|
||||
TypeSafeAI,
|
||||
VercelAIGateway,
|
||||
XAI,
|
||||
ZAI,
|
||||
} from "@opencode/ai/providers"
|
||||
import {
|
||||
OpenAIChat,
|
||||
@@ -151,8 +156,34 @@ describe("public exports", () => {
|
||||
expect(XAI.provider.chat).toBe(XAI.chat)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).image("gpt-image-2").route.id).toBe("openai-images")
|
||||
expect(OpenAI.provider.image).toBe(OpenAI.image)
|
||||
expect(Google.configure({ apiKey: "fixture" }).image("imagen-4.0-generate-001").route.id).toBe("google-images")
|
||||
expect(Google.provider.image).toBe(Google.image)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).image("grok-imagine-image").route.id).toBe("xai-images")
|
||||
expect(XAI.provider.image).toBe(XAI.image)
|
||||
expect(Fal.configure({ apiKey: "fixture" }).image("fal-ai/flux/dev").route.id).toBe("fal-images")
|
||||
expect(Fal.provider.image).toBe(Fal.image)
|
||||
expect(BlackForestLabs.configure({ apiKey: "fixture" }).image("flux-2-pro").route.id).toBe("bfl-images")
|
||||
expect(BlackForestLabs.provider.image).toBe(BlackForestLabs.image)
|
||||
expect(Replicate.configure({ apiKey: "fixture" }).image("black-forest-labs/flux-schnell").route.id).toBe(
|
||||
"replicate-images",
|
||||
)
|
||||
expect(Replicate.provider.image).toBe(Replicate.image)
|
||||
expect(Stability.configure({ apiKey: "fixture" }).image("sd3.5-large").route.id).toBe("stability-images")
|
||||
expect(Stability.provider.image).toBe(Stability.image)
|
||||
expect(Stability.configure({ apiKey: "fixture" }).upscale().route.id).toBe("stability-upscale")
|
||||
expect(Stability.provider.upscale).toBe(Stability.upscale)
|
||||
expect(Meta.configure({ apiKey: "fixture" }).image("muse-image").route.id).toBe("meta-images")
|
||||
expect(Meta.provider.image).toBe(Meta.image)
|
||||
expect(ZAI.configure({ apiKey: "fixture" }).image("glm-image").route.id).toBe("zai-images")
|
||||
expect(ZAI.provider.image).toBe(ZAI.image)
|
||||
expect(XAI.configure({ apiKey: "fixture" }).video("grok-imagine-video-1.5").route.id).toBe("xai-video")
|
||||
expect(XAI.provider.video).toBe(XAI.video)
|
||||
expect(Google.configure({ apiKey: "fixture" }).video("veo-3.1-generate-preview").route.id).toBe("google-video")
|
||||
expect(Google.provider.video).toBe(Google.video)
|
||||
expect(Fal.configure({ apiKey: "fixture" }).video("fal-ai/veo3.1").route.id).toBe("fal-video")
|
||||
expect(Fal.provider.video).toBe(Fal.video)
|
||||
expect(Runway.configure({ apiKey: "fixture" }).video("gen4.5").route.id).toBe("runway-video")
|
||||
expect(Runway.provider.video).toBe(Runway.video)
|
||||
expect(OpenAI.configure({ apiKey: "fixture" }).speech("gpt-4o-mini-tts").route.id).toBe("openai-speech")
|
||||
|
||||
@@ -97,6 +97,26 @@ describe("Generation", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails an event stream at the deadline when the poll interval is longer than the timeout", () =>
|
||||
Effect.gen(function* () {
|
||||
const scripted = yield* scriptedRoute(["running"], "never")
|
||||
const generation = new Generation(scripted.route, "t", { id: "gen_1", status: "queued" })
|
||||
|
||||
const fiber = yield* Effect.forkChild(
|
||||
generation
|
||||
.events({ poll: { interval: "30 seconds", timeout: "10 seconds" } })
|
||||
.pipe(Stream.runCollect, Effect.flip),
|
||||
)
|
||||
yield* TestClock.adjust("9 seconds")
|
||||
expect(fiber.pollUnsafe()).toBeUndefined()
|
||||
yield* TestClock.adjust("1 second")
|
||||
const error = yield* Fiber.join(fiber)
|
||||
|
||||
expect(error.reason._tag).toBe("Timeout")
|
||||
expect(yield* Ref.get(scripted.polls)).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("surfaces the route failure body for failed generations", () =>
|
||||
Effect.gen(function* () {
|
||||
const scripted = yield* scriptedRoute(["running", "failed"], "unused")
|
||||
|
||||
@@ -78,8 +78,11 @@ describe("Image", () => {
|
||||
mediaType: "image/webp",
|
||||
})
|
||||
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
expect(response.image.providerMetadata).toEqual({ openai: { revisedPrompt: "A precise robot" } })
|
||||
expect(response.image.info).toEqual({ format: "webp", width: 2048, height: 2048 })
|
||||
expect(response.usage).toMatchObject({ type: "tokens", total: 12 })
|
||||
expect(response.providerMetadata).toEqual({
|
||||
openai: { outputFormat: "webp", size: "2048x2048", quality: "high", background: "opaque" },
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
ImageClient.layer.pipe(
|
||||
@@ -107,8 +110,11 @@ describe("Image", () => {
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
data: [{ b64_json: "AQID", revised_prompt: "A precise robot" }, { b64_json: "BAUG" }],
|
||||
data: [{ b64_json: "AQID" }, { b64_json: "BAUG" }],
|
||||
output_format: "webp",
|
||||
size: "2048x2048",
|
||||
quality: "high",
|
||||
background: "opaque",
|
||||
usage: { input_tokens: 4, output_tokens: 8, total_tokens: 12 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
@@ -144,6 +150,7 @@ describe("Image", () => {
|
||||
),
|
||||
)
|
||||
expect(response.image.source).toEqual({ type: "bytes", data: Uint8Array.from([1, 2, 3]), mediaType: "image/png" })
|
||||
expect(response.image.info).toEqual({ format: "png" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -725,6 +732,7 @@ describe("Image", () => {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Image.start({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt }),
|
||||
Image.generate({ model: Google.configure({ apiKey: "test" }).image("gemini-3.1-flash-image"), prompt, n: 2 }),
|
||||
Image.start({
|
||||
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
|
||||
prompt,
|
||||
@@ -735,7 +743,6 @@ describe("Image", () => {
|
||||
prompt,
|
||||
size: "512x512",
|
||||
}),
|
||||
Stream.runCollect(Image.stream({ model: openai.image("dall-e-3"), prompt })),
|
||||
Stream.runCollect(Image.stream({ model: openai.image("gpt-image-2"), prompt, n: 2 })),
|
||||
Image.start({ model: replicate, prompt, seed: 7 }),
|
||||
Image.start({
|
||||
@@ -749,9 +756,9 @@ describe("Image", () => {
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "image.start"],
|
||||
["UnsupportedOperation", "media.n"],
|
||||
["UnsupportedOperation", "media.aspectRatio"],
|
||||
["UnsupportedOperation", "media.size"],
|
||||
["UnsupportedOperation", "media.stream"],
|
||||
["UnsupportedOperation", "media.n"],
|
||||
["UnsupportedOperation", "media.seed"],
|
||||
["InvalidRequest", false],
|
||||
@@ -761,6 +768,80 @@ describe("Image", () => {
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
const falToken = {
|
||||
requestID: "r1",
|
||||
statusURL: "https://queue.fal.test/fal-ai/flux/requests/r1/status",
|
||||
responseURL: "https://queue.fal.test/fal-ai/flux/requests/r1",
|
||||
cancelURL: "https://queue.fal.test/fal-ai/flux/requests/r1/cancel",
|
||||
}
|
||||
const falSubmitted = {
|
||||
request_id: falToken.requestID,
|
||||
status_url: falToken.statusURL,
|
||||
response_url: falToken.responseURL,
|
||||
cancel_url: falToken.cancelURL,
|
||||
}
|
||||
const bodies: Array<unknown> = []
|
||||
it.effect("sizes fal Kontext by aspect ratio and sends several images to /multi", () =>
|
||||
Effect.gen(function* () {
|
||||
const fal = Fal.configure({ apiKey: "test", baseURL: "https://queue.fal.test" })
|
||||
const images = [Media.url("https://example.test/a.png"), Media.url("https://example.test/b.png")]
|
||||
const rejected = yield* Image.start({
|
||||
model: fal.image("fal-ai/flux-pro/kontext"),
|
||||
prompt: "A lighthouse",
|
||||
size: "512x512",
|
||||
}).pipe(Effect.flip)
|
||||
yield* Image.start({
|
||||
model: fal.image("fal-ai/flux-pro/kontext"),
|
||||
prompt: "A lighthouse",
|
||||
images: images.slice(0, 1),
|
||||
aspectRatio: "16:9",
|
||||
})
|
||||
yield* Image.start({ model: fal.image("fal-ai/flux-pro/kontext/max/multi"), prompt: "A lighthouse", images })
|
||||
|
||||
expect(rejected.reason).toMatchObject({ _tag: "UnsupportedOperation", operation: "media.size" })
|
||||
expect(bodies).toEqual([
|
||||
{ prompt: "A lighthouse", aspect_ratio: "16:9", image_url: "https://example.test/a.png" },
|
||||
{ prompt: "A lighthouse", image_urls: ["https://example.test/a.png", "https://example.test/b.png"] },
|
||||
])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) => {
|
||||
bodies.push(JSON.parse(input.text))
|
||||
return Effect.succeed(json(input, falSubmitted))
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("decodes fal sync_mode data URIs as inline images", () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Image.resume(Fal.configure({ apiKey: "test" }).image("fal-ai/flux/schnell"), falToken)
|
||||
const response = yield* generation.await()
|
||||
|
||||
expect(response.images.map((image) => image.source)).toEqual([
|
||||
{ type: "base64", data: "AQID", mediaType: "image/png" },
|
||||
{ type: "url", url: "https://v3.fal.media/out.jpg", mediaType: "image/jpeg" },
|
||||
])
|
||||
expect(response.image.info).toEqual({ width: 512, height: 512 })
|
||||
expect(yield* response.image.bytes()).toEqual(Uint8Array.from([1, 2, 3]))
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.request.url === falToken.statusURL
|
||||
? json(input, { status: "COMPLETED" })
|
||||
: json(input, {
|
||||
images: [
|
||||
{ url: "data:image/png;base64,AQID", width: 512, height: 512, content_type: "image/png" },
|
||||
{ url: "https://v3.fal.media/out.jpg", width: 512, height: 512, content_type: "image/jpeg" },
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const moderated = { id: "req_1", status: "Content Moderated" }
|
||||
const prediction = {
|
||||
id: "p_1",
|
||||
@@ -768,6 +849,56 @@ describe("Image", () => {
|
||||
output: { text: "not an image" },
|
||||
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
|
||||
}
|
||||
for (const pending of [
|
||||
{
|
||||
model: BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"),
|
||||
token: { id: "req_1", pollingURL: "https://bfl.test/v1/get_result?id=req_1" },
|
||||
status: 200,
|
||||
body: { id: "req_1", status: "Pending" },
|
||||
message: "Black Forest Labs generation req_1",
|
||||
},
|
||||
{
|
||||
model: Replicate.configure({ apiKey: "test" }).image("owner/model"),
|
||||
token: { id: "p_1", getURL: "https://replicate.test/p_1", cancelURL: "https://replicate.test/p_1/cancel" },
|
||||
status: 200,
|
||||
body: {
|
||||
id: "p_1",
|
||||
status: "processing",
|
||||
urls: { get: "https://replicate.test/p_1", cancel: "https://replicate.test/p_1/cancel" },
|
||||
},
|
||||
message: "Replicate generation p_1",
|
||||
},
|
||||
{
|
||||
model: Stability.configure({ apiKey: "test", baseURL: "https://stability.test" }).upscale(),
|
||||
token: { id: "up_1" },
|
||||
status: 202,
|
||||
body: { id: "up_1", status: "in-progress" },
|
||||
message: "Stability AI generation up_1",
|
||||
},
|
||||
]) {
|
||||
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Image.resume(pending.model, pending.token)
|
||||
const error = yield* generation.result().pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toBe(`${pending.message} has not finished; await it before reading the result`)
|
||||
expect(error.reason.body).toBe(JSON.stringify(pending.body))
|
||||
expect(error.reason.http?.status).toBe(pending.status)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(JSON.stringify(pending.body), {
|
||||
status: pending.status,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("classifies terminal outcomes the recordings never saw", () =>
|
||||
Effect.gen(function* () {
|
||||
const bfl = yield* Image.resume(BlackForestLabs.configure({ apiKey: "test" }).image("flux-2-pro"), {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { Effect, Ref, Schema } from "effect"
|
||||
import { FileSystem } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Media, Message } from "../src/index.js"
|
||||
import { AIError, Media, Message } from "../src/index.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse, scriptedResponses } from "./lib/http.js"
|
||||
|
||||
@@ -161,6 +161,56 @@ describe("Media", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps transient url download headers out of toJSON and AssetSchema encoding", () =>
|
||||
Effect.sync(() => {
|
||||
const asset = Media.url("https://cdn.example.test/video.mp4", {
|
||||
mediaType: "video/mp4",
|
||||
expiresAt: 42,
|
||||
headers: { "x-goog-api-key": "secret" },
|
||||
})
|
||||
expect(asset.headers).toEqual({ "x-goog-api-key": "secret" })
|
||||
const source = { type: "url", url: "https://cdn.example.test/video.mp4", mediaType: "video/mp4", expiresAt: 42 }
|
||||
|
||||
expect(asset.toJSON()).not.toHaveProperty("headers")
|
||||
expect(JSON.stringify(asset)).not.toContain("secret")
|
||||
expect(asset.toJSON().source).toEqual(source)
|
||||
|
||||
const encoded = Schema.encodeSync(Media.AssetSchema)(asset)
|
||||
expect(encoded).not.toHaveProperty("headers")
|
||||
expect(encoded.source).toEqual(source)
|
||||
|
||||
const codec = Schema.fromJsonString(Media.AssetSchema)
|
||||
const json = Schema.encodeSync(codec)(asset)
|
||||
expect(json).not.toContain("secret")
|
||||
const restored = Schema.decodeSync(codec)(json)
|
||||
expect(restored).toBeInstanceOf(Media.Asset)
|
||||
expect(restored.source).toEqual(source)
|
||||
expect(restored.expiresAt).toBe(42)
|
||||
expect(restored.headers).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("fails url downloads with non-2xx status as a typed AIError keeping http and body", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = JSON.stringify({ error: { message: "file expired" } })
|
||||
const error = yield* Media.url("https://cdn.example.test/expired.png")
|
||||
.bytes()
|
||||
.pipe(
|
||||
Effect.flip,
|
||||
Effect.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.succeed(input.respond(body, { status: 404, headers: { "content-type": "application/json" } })),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(error).toBeInstanceOf(AIError)
|
||||
expect(error.message).toContain("file expired")
|
||||
expect(error.reason.http?.status).toBe(404)
|
||||
expect(error.reason.http?.url).toBe("https://cdn.example.test/expired.png")
|
||||
expect(error.reason.body).toBe(body)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reads files with sniffed media types and writes materialized assets", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* FileSystem.FileSystem
|
||||
|
||||
@@ -33,6 +33,8 @@ describe("Black Forest Labs Images recorded", () => {
|
||||
|
||||
expect(response.image.source.type).toBe("bytes")
|
||||
expect(dimensions(yield* response.image.bytes())).toEqual({ width: 512, height: 512 })
|
||||
// BFL reports cost on submit only; the Ready result omits it.
|
||||
expect(response.usage).toEqual({ type: "credits", credits: 1.4000000000000001 })
|
||||
}),
|
||||
{ timeout: 15 * 60 * 1000 },
|
||||
)
|
||||
|
||||
@@ -29,7 +29,11 @@ describe("OpenAI Images recorded", () => {
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image.mediaType).toBe("image/jpeg")
|
||||
expect(response.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
|
||||
expect((yield* response.image.bytes()).length).toBeGreaterThan(0)
|
||||
expect(response.providerMetadata).toEqual({
|
||||
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -76,8 +80,13 @@ describe("OpenAI Images recorded", () => {
|
||||
expect(events.map((event) => event.type)).toEqual(["image-partial", "image", "finish"])
|
||||
const image = events.find(ImageEvent.is.image)
|
||||
expect(image?.image.mediaType).toBe("image/jpeg")
|
||||
expect(image?.image.info).toEqual({ format: "jpeg", width: 1024, height: 1024 })
|
||||
expect(dimensions(yield* image!.image.bytes())).toEqual({ width: 1024, height: 1024 })
|
||||
expect(events.find(ImageEvent.is.finish)?.usage).toMatchObject({ type: "tokens" })
|
||||
const finish = events.find(ImageEvent.is.finish)
|
||||
expect(finish?.usage).toMatchObject({ type: "tokens" })
|
||||
expect(finish?.providerMetadata).toEqual({
|
||||
openai: { outputFormat: "jpeg", size: "1024x1024", quality: "low", background: "opaque" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -44,7 +44,12 @@ describe("OpenAI Transcription recorded", () => {
|
||||
expect(deltas.length).toBeGreaterThan(1)
|
||||
expect(deltas.join("")).toBe(finish.text)
|
||||
expect(finish.text).toMatch(TRANSCRIPT)
|
||||
expect(finish.usage).toMatchObject({ type: "tokens", input: expect.any(Number), output: expect.any(Number) })
|
||||
expect(finish.usage).toMatchObject({
|
||||
type: "tokens",
|
||||
input: expect.any(Number),
|
||||
output: expect.any(Number),
|
||||
details: { openai: { input_token_details: { audio_tokens: expect.any(Number) } } },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -32,7 +32,12 @@ describe("Z.ai Images", () => {
|
||||
|
||||
expect(response.images).toHaveLength(1)
|
||||
expect(response.image.mediaType).toBe("application/octet-stream")
|
||||
expect(response.image.source).toEqual({ type: "url", url: "https://cdn.z.ai/generated.png" })
|
||||
// Z.ai documents that output URLs expire 30 days after generation; the test clock starts at 0.
|
||||
expect(response.image.source).toEqual({
|
||||
type: "url",
|
||||
url: "https://cdn.z.ai/generated.png",
|
||||
expiresAt: 30 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
expect(response.notices).toEqual([
|
||||
{
|
||||
type: "moderated",
|
||||
|
||||
@@ -28,10 +28,49 @@ const cartesia = Cartesia.configure({ apiKey: "test", baseURL: "https://cartesia
|
||||
const google = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
|
||||
"gemini-2.5-flash-preview-tts",
|
||||
)
|
||||
const google38 = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
|
||||
"gemini-3.8-flash-tts",
|
||||
)
|
||||
const google38Lite = Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).speech(
|
||||
"gemini-3.8-flash-lite-tts",
|
||||
)
|
||||
const deepgram = Deepgram.configure({ apiKey: "test", baseURL: "https://deepgram.test" }).speech("aura-2-thalia-en")
|
||||
const voice = "JBFqnCBsd6RMkjVDRZzb"
|
||||
|
||||
describe("Speech", () => {
|
||||
it.effect("preserves Google's WAV output instead of describing it as raw PCM", () =>
|
||||
Effect.gen(function* () {
|
||||
const bytes = new TextEncoder().encode("RIFF....WAVEfmt ")
|
||||
const response = yield* Speech.generate({ model: google38, text: "Hi" }).pipe(
|
||||
Effect.provide(
|
||||
respond(
|
||||
JSON.stringify({
|
||||
candidates: [
|
||||
{ content: { parts: [{ inlineData: { mimeType: "audio/wav", data: Encoding.encodeBase64(bytes) } }] } },
|
||||
],
|
||||
}),
|
||||
"application/json",
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(response.audio.mediaType).toBe("audio/wav")
|
||||
expect(response.audio.info?.format).toBe("wav")
|
||||
expect(response.audio.info?.encoding).toBeUndefined()
|
||||
expect(yield* response.audio.bytes()).toEqual(bytes)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects raw PCM for Gemini 3.8 unary requests before sending", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
[google38, google38Lite].map((model) =>
|
||||
Speech.generate({ model, text: "Hi", format: "pcm" }).pipe(Effect.flip),
|
||||
),
|
||||
).pipe(Effect.provide(layer(() => Effect.die("An unsupported request reached the network"))))
|
||||
expect(errors.map((error) => error.reason._tag)).toEqual(["UnsupportedOperation", "UnsupportedOperation"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects what a provider cannot produce before sending anything", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
@@ -58,6 +97,42 @@ describe("Speech", () => {
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
it.effect("treats timestamps: false as not asking for timestamps on routes that cannot return them", () =>
|
||||
Effect.gen(function* () {
|
||||
const bytes = Uint8Array.from([1, 2, 3])
|
||||
const gemini = JSON.stringify({
|
||||
candidates: [
|
||||
{ content: { parts: [{ inlineData: { mimeType: "audio/L16;codec=pcm;rate=24000", data: "AQID" } }] } },
|
||||
],
|
||||
})
|
||||
const responses = yield* Effect.all([
|
||||
Speech.generate({ model: openai, text: "Hi", timestamps: false }).pipe(
|
||||
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
|
||||
),
|
||||
Speech.generate({ model: google, text: "Hi", timestamps: false }).pipe(
|
||||
Effect.provide(respond(gemini, "application/json")),
|
||||
),
|
||||
Speech.generate({ model: deepgram, text: "Hi", timestamps: false }).pipe(
|
||||
Effect.provide(respond(new Blob([bytes]).stream(), "audio/mpeg")),
|
||||
),
|
||||
])
|
||||
for (const response of responses) expect(yield* response.audio.bytes()).toEqual(bytes)
|
||||
|
||||
const errors = yield* Effect.all(
|
||||
[openai, google, deepgram].map((model) =>
|
||||
Speech.generate({ model, text: "Hi", timestamps: true }).pipe(Effect.flip),
|
||||
),
|
||||
).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network"))))
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
],
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("classifies stream failures and keeps the provider payload and HTTP context", () =>
|
||||
Effect.gen(function* () {
|
||||
const badFrame = JSON.stringify({ type: "speech.audio.delta", audio: "not base64!" })
|
||||
|
||||
@@ -5,7 +5,7 @@ import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Media, Transcription, TranscriptionClient } from "../src/index.js"
|
||||
import { AssemblyAI, Deepgram, Google, OpenAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
import { dynamicResponse, json, observe, type Call } from "./lib/http.js"
|
||||
|
||||
const layer = (handler: Parameters<typeof dynamicResponse>[0]) =>
|
||||
TranscriptionClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
||||
@@ -25,7 +25,6 @@ describe("Transcription", () => {
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })),
|
||||
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, diarize: true }),
|
||||
Transcription.generate({ model: openai.transcription("gpt-4o-mini-transcribe"), audio, timestamps: "word" }),
|
||||
Transcription.generate({ model: openai.transcription("gpt-4o-transcribe-diarize"), audio, prompt: "Names" }),
|
||||
@@ -53,7 +52,6 @@ describe("Transcription", () => {
|
||||
)
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "media.stream"],
|
||||
["UnsupportedOperation", "media.diarize"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.prompt"],
|
||||
@@ -70,6 +68,67 @@ describe("Transcription", () => {
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
it.effect("ignores unknown OpenAI stream events and fails on an error event with the frame", () =>
|
||||
Effect.gen(function* () {
|
||||
const sse = (...frames: ReadonlyArray<string>) => frames.map((frame) => `data: ${frame}\n\n`).join("")
|
||||
const failure = `{"type":"error","error":{"type":"server_error","code":"server_error","message":"The server had an error"}}`
|
||||
const bodies = [
|
||||
sse(
|
||||
`{"type":"transcript.text.delta","delta":"Hi"}`,
|
||||
`{"type":"transcript.text.future","payload":1}`,
|
||||
`{"type":"transcript.text.done","text":"Hi"}`,
|
||||
"[DONE]",
|
||||
),
|
||||
sse(`{"type":"transcript.text.delta","delta":"Hi"}`, failure),
|
||||
]
|
||||
const model = openai.transcription("gpt-4o-mini-transcribe")
|
||||
const program = Effect.gen(function* () {
|
||||
const events = Array.from(yield* Stream.runCollect(Transcription.stream({ model, audio })))
|
||||
const error = yield* Stream.runCollect(Transcription.stream({ model, audio })).pipe(Effect.flip)
|
||||
return { events, error }
|
||||
})
|
||||
const { events, error } = yield* program.pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.sync(() =>
|
||||
input.respond(bodies.shift() ?? "", { headers: { "content-type": "text/event-stream" } }),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual(["text-delta", "finish"])
|
||||
expect(error.reason).toMatchObject({ _tag: "ProviderInternal", body: failure })
|
||||
expect(error.message).toContain("The server had an error")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams whisper-1 as a single finish from a plain request", () =>
|
||||
Effect.gen(function* () {
|
||||
const bodies: Array<string> = []
|
||||
const events = Array.from(
|
||||
yield* Stream.runCollect(Transcription.stream({ model: openai.transcription("whisper-1"), audio })).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.sync(() => {
|
||||
bodies.push(input.text)
|
||||
return input.respond(
|
||||
JSON.stringify({ text: "Hello there.", usage: { type: "duration", seconds: 2 } }),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(bodies[0]).not.toContain('name="stream"')
|
||||
expect(events).toEqual([
|
||||
expect.objectContaining({ type: "finish", text: "Hello there.", usage: { type: "seconds", seconds: 2 } }),
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"uploads inline audio to AssemblyAI, resumes polling from a persisted token, and surfaces failed transcripts",
|
||||
() =>
|
||||
@@ -171,4 +230,45 @@ describe("Transcription", () => {
|
||||
expect(failure.reason).toMatchObject({ _tag: "ProviderInternal", body: failed })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("enables AssemblyAI speaker labels when only an expected speaker count is given", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
yield* Transcription.start({ model: assemblyai, audio: Media.url("https://a.test/call.mp3"), speakers: 2 }).pipe(
|
||||
Effect.provide(
|
||||
layer((input) => observe(calls, input).pipe(Effect.as(json(input, { id: "tr_1", status: "queued" })))),
|
||||
),
|
||||
)
|
||||
expect(calls.map((call) => JSON.parse(call.body))).toEqual([
|
||||
{
|
||||
audio_url: "https://a.test/call.mp3",
|
||||
speech_models: ["universal-3-5-pro"],
|
||||
language_detection: true,
|
||||
speaker_labels: true,
|
||||
speakers_expected: 2,
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects reading an AssemblyAI result before the transcript finishes", () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Transcription.resume(assemblyai, { transcriptID: "tr_1" })
|
||||
const error = yield* generation.result().pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toBe("AssemblyAI generation tr_1 has not finished; await it before reading the result")
|
||||
expect(error.reason.body).toBe(JSON.stringify({ id: "tr_1", status: "processing" }))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.succeed(
|
||||
input.respond(JSON.stringify({ id: "tr_1", status: "processing" }), {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -576,7 +576,7 @@ describe("Video / Runway", () => {
|
||||
const model = runway.video("gen4.5")
|
||||
const taskUrl = "https://runway.test/v1/tasks/task_1"
|
||||
|
||||
it.effect("submits image_to_video with the API version header, polls the task, and reports credits", () =>
|
||||
it.effect("submits image_to_video, polls the task, reports credits, and keeps the finished task on cancel", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
const program = Effect.gen(function* () {
|
||||
@@ -624,7 +624,7 @@ describe("Video / Runway", () => {
|
||||
return json(input, { id: "task_1", estimatedCost: { credits: 25 } })
|
||||
}
|
||||
expect(call.url).toBe(taskUrl)
|
||||
if (call.method === "DELETE") return input.respond(null, { status: 204 })
|
||||
if (call.method === "DELETE") return yield* Effect.die("cancel deleted a finished Runway task")
|
||||
if (nth === 1) return json(input, { id: "task_1", status: "PENDING", estimatedCost: { credits: 25 } })
|
||||
if (nth === 2) return json(input, { id: "task_1", status: "THROTTLED", estimatedCost: { credits: 25 } })
|
||||
if (nth === 3) return json(input, { id: "task_1", status: "RUNNING", progress: 0.5 })
|
||||
@@ -653,6 +653,32 @@ describe("Video / Runway", () => {
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
`GET ${taskUrl}`,
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cancels a task that is still running", () =>
|
||||
Effect.gen(function* () {
|
||||
const calls: Array<Call> = []
|
||||
yield* Effect.gen(function* () {
|
||||
const generation = yield* Video.start({ model, prompt: "x" })
|
||||
yield* generation.cancel()
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const { call } = yield* observe(calls, input)
|
||||
if (call.method === "POST") return json(input, { id: "task_1" })
|
||||
if (call.method === "DELETE") return input.respond(null, { status: 204 })
|
||||
return json(input, { id: "task_1", status: "RUNNING", progress: 0.2 })
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
expect(calls.map((call) => `${call.method} ${call.url}`)).toEqual([
|
||||
"POST https://runway.test/v1/text_to_video",
|
||||
`GET ${taskUrl}`,
|
||||
`DELETE ${taskUrl}`,
|
||||
])
|
||||
}),
|
||||
@@ -812,3 +838,55 @@ describe("Video / Runway", () => {
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared queued behavior
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Video / queued result", () => {
|
||||
for (const pending of [
|
||||
{
|
||||
model: Google.configure({ apiKey: "test", baseURL: "https://google.test/v1beta" }).video("veo-3.1"),
|
||||
token: { operation: "models/veo-3.1/operations/op_1" },
|
||||
body: { name: "models/veo-3.1/operations/op_1", done: false },
|
||||
name: "Google Veo",
|
||||
},
|
||||
{
|
||||
model: XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
|
||||
token: { requestID: "req_1" },
|
||||
body: { status: "pending", progress: 40 },
|
||||
name: "xAI Video",
|
||||
},
|
||||
{
|
||||
model: Runway.configure({ apiKey: "test", baseURL: "https://runway.test/v1" }).video("gen4.5"),
|
||||
token: { taskID: "task_1" },
|
||||
body: { status: "RUNNING", progress: 0.5 },
|
||||
name: "Runway",
|
||||
},
|
||||
]) {
|
||||
it.effect(`rejects reading a ${pending.model.provider} result before the generation finishes`, () =>
|
||||
Effect.gen(function* () {
|
||||
const generation = yield* Video.resume(pending.model, pending.token)
|
||||
const error = yield* generation.result().pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
expect(error.message).toBe(
|
||||
`${pending.name} generation ${generation.id} has not finished; await it before reading the result`,
|
||||
)
|
||||
expect(error.reason.body).toBe(JSON.stringify(pending.body))
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, pending.body))))),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("rejects a status that only matches an inherited property", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Video.resume(
|
||||
XAI.configure({ apiKey: "test", baseURL: "https://xai.test/v1" }).video("grok-imagine-video-1.5"),
|
||||
{ requestID: "req_1" },
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidProviderOutput")
|
||||
expect(error.message).toBe('Unknown generation status "constructor"')
|
||||
expect(error.reason.body).toBe(JSON.stringify({ status: "constructor" }))
|
||||
}).pipe(Effect.provide(layer((input) => Effect.succeed(json(input, { status: "constructor" }))))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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,108 @@ test("editing restores the existing draft and replaces only the original queue p
|
||||
expect(mock.log[0]).toBe("prompt:queue")
|
||||
})
|
||||
|
||||
test("Undo 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", "Undo", "Remove"])
|
||||
const undo = row.getByRole("button", { name: "Undo" })
|
||||
await expect(undo).toHaveText("")
|
||||
await expect(undo.locator("svg use")).toHaveAttribute("href", "#opencode-v2-icon-arrow-down-to-line")
|
||||
await undo.hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Undo")
|
||||
await undo.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("Undo appends to 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: "Undo" }).click()
|
||||
await expect(view.rows).toHaveCount(0)
|
||||
await expect(view.input).toHaveText("my draft\n\nqueued 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("Undo 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: "Undo" }).hover()
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Undo")
|
||||
await page.screenshot({ path: testInfo.outputPath("undo-narrow-queue.png") })
|
||||
await row.getByRole("button", { name: "Undo" }).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("Undo 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: "Undo" }).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("Undo 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: "Undo" }).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,
|
||||
|
||||
@@ -47,6 +47,7 @@ export type ComposerDelivery = "steer" | "queue"
|
||||
// is loaded in the editor.
|
||||
export type ComposerQueue = {
|
||||
count: Accessor<number>
|
||||
undoing: 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() {},
|
||||
undoing: () => 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")}
|
||||
|
||||
@@ -371,6 +371,7 @@ export function createComposerModel(adapter: ComposerAdapter, options?: { queue?
|
||||
onSubmit: (submitOptions) => {
|
||||
if (!available()) return
|
||||
const queue = options?.queue
|
||||
if (queue?.undoing()) 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.
|
||||
|
||||
@@ -86,6 +86,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
resolveProject: (session) => home.server.focusedContext()?.projects.forSession(session),
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
@@ -291,6 +292,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
sessions: () => [result],
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
resolveProject: ctx.projects.forSession,
|
||||
})[0]
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function buildHomeSessionRecords(input: {
|
||||
sessions: () => SessionInfo[]
|
||||
projectDirectories: () => string[] | undefined
|
||||
projects: () => LocalProject[]
|
||||
resolveProject?: (session: SessionInfo) => LocalProject | undefined
|
||||
}) {
|
||||
const selected = input.projectDirectories()
|
||||
const directories = selected ? new Set(selected.map(pathKey)) : undefined
|
||||
@@ -27,11 +28,12 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
const project = input.resolveProject?.(session) ??
|
||||
homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -874,6 +874,9 @@ export const dict = {
|
||||
"session.queue.send": "Send",
|
||||
"session.queue.steerTooltip": "Send without interrupting",
|
||||
"session.queue.remove": "Remove",
|
||||
"session.queue.undo": "Undo",
|
||||
"session.queue.undoShell": "Leave shell mode before undoing a queued prompt",
|
||||
"session.queue.undoUnavailable": "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",
|
||||
@@ -1439,6 +1442,15 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
"settings.providers.account.manage": "Manage {{provider}} accounts",
|
||||
"settings.providers.account.group": "Accounts",
|
||||
"settings.providers.account.add": "Add account",
|
||||
"settings.providers.account.remove": "Remove account…",
|
||||
"settings.providers.account.active": "Active",
|
||||
"settings.providers.account.switched.title": "{{provider}} account switched",
|
||||
"settings.providers.account.switched.description": "Now using {{account}}.",
|
||||
"settings.providers.account.removed.title": "{{account}} removed",
|
||||
"settings.providers.account.removed.description": "{{provider}} will no longer use this account.",
|
||||
"settings.providers.console.available.one": "{{count}} provider available",
|
||||
"settings.providers.console.available.other": "{{count}} providers available",
|
||||
"settings.providers.section.popular": "Popular providers",
|
||||
|
||||
@@ -18,6 +18,8 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { resolveProjectForSession, resolveSessionDetailsProject } from "@/shell/layout/helpers"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -180,6 +182,13 @@ function createServerController(
|
||||
}
|
||||
|
||||
const projectsList = createMemo(() => projects.list().map(enrich))
|
||||
const forSession = (session: SessionInfo) => {
|
||||
const project = resolveProjectForSession(session, projectsList(), sync.data.project)
|
||||
if (!project) return
|
||||
return "expanded" in project ? project : { ...project, expanded: false }
|
||||
}
|
||||
const detailsForSession = (session: SessionInfo) =>
|
||||
resolveSessionDetailsProject(session, projectsList(), sync.data.project)
|
||||
const recentlyClosedList = createMemo(() => {
|
||||
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
|
||||
return projects
|
||||
@@ -200,6 +209,8 @@ function createServerController(
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
forSession,
|
||||
detailsForSession,
|
||||
resolve: enrich,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
|
||||
@@ -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.undo")}>
|
||||
<IconButton
|
||||
data-action="session-queue-undo"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
icon={<Icon name="arrow-down-to-line" />}
|
||||
disabled={props.queue.busy()}
|
||||
aria-label={language.t("session.queue.undo")}
|
||||
onClick={() => props.queue.undo(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, queuedPromptUndoDraft, queuedPromptRows } from "./queue"
|
||||
|
||||
const queued = [
|
||||
{
|
||||
@@ -104,3 +104,43 @@ describe("queuedPromptAttachments", () => {
|
||||
expect(queuedPromptAttachments(item)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe("queuedPromptUndoDraft", () => {
|
||||
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(queuedPromptUndoDraft(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(queuedPromptUndoDraft(item)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,10 +3,11 @@ 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"
|
||||
import { clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
import { appendPrompt, clonePrompt, isAttachment, promptLength } from "@/composer/prompt-parts"
|
||||
import { buildPromptRequest } from "@/composer/request"
|
||||
import { blobDataUrl, createLegacyBlobReference } from "@/runtime/persistence/drafts"
|
||||
import { readPromptPresentation } from "@/composer/comment-note"
|
||||
@@ -42,6 +43,7 @@ export function createSessionQueue(input: {
|
||||
mutationFn: async (
|
||||
change:
|
||||
| { type: "reorder"; inboxIDs: string[] }
|
||||
| { type: "undo"; item: QueuedPrompt; prompt: Prompt }
|
||||
| {
|
||||
type: "edit"
|
||||
inboxIDs: string[]
|
||||
@@ -54,6 +56,16 @@ export function createSessionQueue(input: {
|
||||
},
|
||||
) => {
|
||||
if (change.type === "reorder") return rewrite(change.inboxIDs)
|
||||
if (change.type === "undo") {
|
||||
await server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: change.item.id })
|
||||
const draft = input.draft.current()
|
||||
const prompt = promptLength(draft)
|
||||
? appendPrompt(draft, change.prompt)
|
||||
: [...change.prompt, ...draft.filter(isAttachment)]
|
||||
input.draft.set(prompt, promptLength(prompt))
|
||||
input.restoreFocus(promptLength(prompt))
|
||||
return
|
||||
}
|
||||
const replacement = await editedPromptInput(
|
||||
input.sessionID,
|
||||
location().directory,
|
||||
@@ -139,6 +151,21 @@ export function createSessionQueue(input: {
|
||||
if (state.editing?.id === id) cancelEdit()
|
||||
return server.api.session.inbox.cancel({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
|
||||
}
|
||||
const undo = (id: string) => {
|
||||
if (mutation.isPending || state.editing) return
|
||||
const item = queued().find((entry) => entry.id === id)
|
||||
if (!item) return
|
||||
if (input.draft.mode.current() !== "normal") {
|
||||
showToast({ title: language.t("session.queue.undoShell") })
|
||||
return
|
||||
}
|
||||
const prompt = queuedPromptUndoDraft(item)
|
||||
if (!prompt) {
|
||||
showToast({ title: language.t("session.queue.undoUnavailable") })
|
||||
return
|
||||
}
|
||||
mutation.mutate({ type: "undo", item, prompt })
|
||||
}
|
||||
const reorder = (inboxIDs: string[]) => {
|
||||
if (mutation.isPending) return Promise.resolve()
|
||||
return mutation.mutateAsync({ type: "reorder", inboxIDs }).catch(() => undefined)
|
||||
@@ -226,9 +253,11 @@ export function createSessionQueue(input: {
|
||||
editFirst,
|
||||
rows,
|
||||
busy: () => mutation.isPending,
|
||||
undoing: () => mutation.isPending && mutation.variables?.type === "undo",
|
||||
working: input.working,
|
||||
steer,
|
||||
remove,
|
||||
undo,
|
||||
edit,
|
||||
reorder,
|
||||
}
|
||||
@@ -239,7 +268,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" | "undo" | "edit" | "reorder"
|
||||
>
|
||||
|
||||
export function queuedPromptRows(items: QueuedPrompt[], replacement?: { original: string; replacement: string }) {
|
||||
@@ -249,7 +278,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 +317,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 queuedPromptUndoDraft(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.undoing()}
|
||||
suggestionBoundary={props.suggestionBoundary}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
@@ -66,7 +65,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
return info ? server.ctx.projects.detailsForSession(info) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const btw = createSessionBtw(session)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName, errorMessage, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
@@ -319,11 +319,8 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
if (props.session) return server.ctx.projects.forSession(props.session)
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session)
|
||||
return (
|
||||
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
|
||||
)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, Show, type JSX } from "solid-js"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -16,7 +16,6 @@ export function SessionSummaryPanel(props: {
|
||||
shown?: boolean
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
@@ -44,7 +43,7 @@ export function SessionSummaryPanel(props: {
|
||||
return (
|
||||
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
|
||||
<div>
|
||||
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
|
||||
<ProjectSummaryCard project={props.project}>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
|
||||
import { createUniqueId, Show, type ParentProps } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import "./summary.css"
|
||||
|
||||
export function ProjectSummaryCard(
|
||||
props: ParentProps<{
|
||||
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
|
||||
avatar?: JSX.Element
|
||||
}>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
@@ -26,13 +24,7 @@ export function ProjectSummaryCard(
|
||||
aria-controls={contentID}
|
||||
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
|
||||
>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<ProjectIcon project={props.project} />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
|
||||
@@ -31,7 +31,6 @@ import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
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 { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
@@ -143,14 +142,14 @@ function MessageTimelineView(
|
||||
const session = props.session.data.info()
|
||||
const projects = server.ctx.sync.data.project
|
||||
return session
|
||||
? projectForSession(session, projects)
|
||||
? server.ctx.projects.detailsForSession(session)
|
||||
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
|
||||
})
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const avatarProject = createMemo(() => {
|
||||
const headerProject = createMemo(() => {
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list()) ?? project()
|
||||
return server.ctx.projects.forSession(session)
|
||||
})
|
||||
createEffect(() => {
|
||||
const directory = project()?.worktree
|
||||
@@ -408,7 +407,7 @@ function MessageTimelineView(
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
|
||||
<SessionProjectMenu
|
||||
project={avatarProject()}
|
||||
project={headerProject()}
|
||||
directory={sessionDirectory()}
|
||||
workspace={workspaceSession()}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { IntegrationInfo } from "@opencode/client/promise"
|
||||
import { activeProviderAccount, providerAccounts } from "./accounts"
|
||||
|
||||
const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [],
|
||||
connections,
|
||||
})
|
||||
|
||||
describe("provider accounts", () => {
|
||||
test("preserves the server's active-first credential order", () => {
|
||||
const value = integration([
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "env", name: "OPENAI_API_KEY" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
|
||||
])
|
||||
|
||||
expect(providerAccounts(value)).toEqual([
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
|
||||
])
|
||||
expect(activeProviderAccount(value)).toEqual({ type: "credential", id: "cred_work", label: "Work", method: "key" })
|
||||
})
|
||||
|
||||
test("returns no active account for environment-only integrations", () => {
|
||||
const value = integration([{ type: "env", name: "OPENAI_API_KEY" }])
|
||||
|
||||
expect(providerAccounts(value)).toEqual([])
|
||||
expect(activeProviderAccount(value)).toBeUndefined()
|
||||
expect(providerAccounts(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ConnectionInfo, IntegrationInfo } from "@opencode/client/promise"
|
||||
|
||||
export type ProviderAccount = Extract<ConnectionInfo, { type: "credential" }>
|
||||
|
||||
export function providerAccounts(integration: IntegrationInfo | undefined) {
|
||||
return integration?.connections.filter((connection): connection is ProviderAccount => connection.type === "credential") ?? []
|
||||
}
|
||||
|
||||
export function activeProviderAccount(integration: IntegrationInfo | undefined) {
|
||||
return providerAccounts(integration)[0]
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { popularProviders, useProviders } from "@/providers/catalog/providers"
|
||||
@@ -16,6 +17,7 @@ import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/cont
|
||||
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
|
||||
import { ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
|
||||
@@ -47,6 +49,7 @@ export const SettingsProviders: Component<{
|
||||
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
|
||||
consoleExpanded: false,
|
||||
connecting: false,
|
||||
credentialID: undefined as string | undefined,
|
||||
})
|
||||
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
|
||||
setState("disconnecting", (current) => ({
|
||||
@@ -190,6 +193,8 @@ export const SettingsProviders: Component<{
|
||||
return currentSource !== "env" && currentSource !== "config"
|
||||
}
|
||||
|
||||
const canManageAccounts = (item: ProviderItem) => providerAccounts(integration(item)).length > 0
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const disconnect = async (item: ProviderItem, name: string) => {
|
||||
@@ -230,6 +235,132 @@ export const SettingsProviders: Component<{
|
||||
})
|
||||
}
|
||||
|
||||
const refreshAccounts = async () => {
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
data.location.integration.invalidate(location)
|
||||
data.location.provider.invalidate(location)
|
||||
data.location.model.invalidate(location)
|
||||
await Promise.all([
|
||||
data.location.integration.sync(location),
|
||||
data.location.provider.sync(location),
|
||||
data.location.model.sync(location),
|
||||
])
|
||||
}
|
||||
|
||||
const accountError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
if (activeProviderAccount(integration(provider))?.id === account.id) return
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.activate({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.providers.account.switched.title", { provider: providerName }),
|
||||
description: language.t("settings.providers.account.switched.description", { account: account.label }),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
const final = providerAccounts(integration(provider)).length === 1
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.remove({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t(
|
||||
final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title",
|
||||
final ? { provider: providerName } : { account: account.label },
|
||||
),
|
||||
description: language.t(
|
||||
final
|
||||
? "provider.disconnect.toast.disconnected.description"
|
||||
: "settings.providers.account.removed.description",
|
||||
{ provider: providerName },
|
||||
),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) {
|
||||
const accounts = () => providerAccounts(integration(menuProps.provider))
|
||||
const active = () => activeProviderAccount(integration(menuProps.provider))
|
||||
const name = () => menuProps.name ?? menuProps.provider.name
|
||||
|
||||
return (
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
class="settings-provider-account-trigger"
|
||||
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
|
||||
>
|
||||
<span>{active()?.label}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
|
||||
<Menu.RadioGroup
|
||||
class="settings-provider-account-list"
|
||||
value={active()?.id}
|
||||
onChange={(credentialID) => {
|
||||
const account = accounts().find((item) => item.id === credentialID)
|
||||
if (account) void activate(menuProps.provider, name(), account)
|
||||
}}
|
||||
>
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Group>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
|
||||
{language.t("settings.providers.account.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Sub placement="left-start">
|
||||
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
|
||||
{language.t("settings.providers.account.remove")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.SubContent class="settings-provider-account-submenu">
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.Item
|
||||
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
|
||||
onSelect={() => void remove(menuProps.provider, name(), account)}
|
||||
>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.SubContent>
|
||||
</Menu.Sub>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
@@ -268,22 +399,27 @@ export const SettingsProviders: Component<{
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
when={canManageAccounts(item)}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, item.name)}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() =>
|
||||
void disconnect(item, item.name)
|
||||
}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
<AccountMenu provider={item} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
@@ -326,13 +462,20 @@ export const SettingsProviders: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
|
||||
<Show
|
||||
when={canManageAccounts(item)}
|
||||
fallback={
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
<AccountMenu provider={item} name={language.t("provider.connect.opencode.name")} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={state.consoleExpanded}>
|
||||
<div class="settings-provider-console-list">
|
||||
|
||||
@@ -887,6 +887,47 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger {
|
||||
min-width: 0;
|
||||
max-width: min(240px, 45%);
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-provider-account-menu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.settings-provider-account-list {
|
||||
max-height: min(240px, calc(var(--kb-popper-content-available-height) - 120px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-submenu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
max-height: min(360px, var(--kb-popper-content-available-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container settings-panel (max-width: 520px) {
|
||||
.settings-provider-account-trigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-providers-view-all {
|
||||
margin-top: 20px;
|
||||
padding: 0;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName, resolveProjectForSession } from "@/shell/layout/helpers"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
@@ -253,9 +253,7 @@ export function createServerSessionEntries(props: {
|
||||
})
|
||||
if (current.signal.aborted) return []
|
||||
const opened = props.opened()
|
||||
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||
return Promise.all([
|
||||
props.load(search, current.signal).then(
|
||||
(result) => result.data,
|
||||
@@ -271,8 +269,7 @@ export function createServerSessionEntries(props: {
|
||||
[...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()]
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||
const project = resolveProjectForSession(session, opened, stored)
|
||||
return {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
|
||||
@@ -50,6 +50,17 @@ export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID:
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
project.name || getFilename(project.worktree) || project.worktree
|
||||
|
||||
type ProjectAppearance = {
|
||||
name?: string
|
||||
worktree: string
|
||||
icon?: { color?: string; url?: string; override?: string }
|
||||
}
|
||||
|
||||
function withProjectAppearance<T extends ProjectAppearance>(metadata: T, appearance?: ProjectAppearance) {
|
||||
if (!appearance || appearance === metadata) return metadata
|
||||
return { ...metadata, name: displayName(appearance), icon: appearance.icon }
|
||||
}
|
||||
|
||||
export function toggleHomeProjectSelection(
|
||||
current: HomeProjectSelection | undefined,
|
||||
server: ServerConnection.Key,
|
||||
@@ -97,11 +108,59 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
|
||||
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: T[],
|
||||
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
) {
|
||||
const direct = byID.get(session.projectID)
|
||||
if (direct) return direct
|
||||
return projects.find((project) => isProjectDirectory(project, session.location.directory))
|
||||
const matching = projects.filter((project) => project.id === session.projectID)
|
||||
if (matching.length === 1) return matching[0]
|
||||
if (matching.length > 1) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const exact =
|
||||
matching.find((project) => pathKey(project.worktree) === directory) ??
|
||||
matching.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
|
||||
if (exact) return exact
|
||||
return (
|
||||
matching
|
||||
.filter((project) => isProjectDirectory(project, session.location.directory))
|
||||
.sort((a, b) => b.worktree.length - a.worktree.length)[0] ?? matching.at(-1)
|
||||
)
|
||||
}
|
||||
const directory = pathKey(session.location.directory)
|
||||
const exact =
|
||||
projects.find((project) => pathKey(project.worktree) === directory) ??
|
||||
projects.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
|
||||
if (exact) return exact
|
||||
return projects
|
||||
.filter((project) => isProjectDirectory(project, session.location.directory))
|
||||
.sort((a, b) => b.worktree.length - a.worktree.length)[0]
|
||||
}
|
||||
|
||||
export function resolveProjectForSession<
|
||||
T extends { id?: string; worktree: string; sandboxes?: string[] },
|
||||
U extends { id?: string; worktree: string; sandboxes?: string[] },
|
||||
>(session: SessionInfo, opened: T[], stored: U[]) {
|
||||
const current = projectForSession(session, opened)
|
||||
if (current?.id === session.projectID) {
|
||||
const unresolved = opened.find(
|
||||
(project) => !project.id && pathKey(project.worktree) === pathKey(session.location.directory),
|
||||
)
|
||||
if (!unresolved) return current
|
||||
const canonical = projectForSession(session, stored)
|
||||
if (canonical?.id === session.projectID && pathKey(canonical.worktree) === pathKey(unresolved.worktree))
|
||||
return unresolved
|
||||
return current
|
||||
}
|
||||
const synced = projectForSession(session, stored)
|
||||
if (synced?.id !== session.projectID) return current ?? synced
|
||||
if (current && !current.id && pathKey(current.worktree) === pathKey(session.location.directory)) return current
|
||||
return synced
|
||||
}
|
||||
|
||||
export function resolveSessionDetailsProject<
|
||||
T extends ProjectAppearance & { id?: string; sandboxes?: string[] },
|
||||
U extends ProjectAppearance & { id?: string; sandboxes?: string[] },
|
||||
>(session: SessionInfo, opened: T[], stored: U[]) {
|
||||
const metadata = projectForSession(session, stored)
|
||||
if (!metadata) return
|
||||
return withProjectAppearance(metadata, resolveProjectForSession(session, opened, stored))
|
||||
}
|
||||
|
||||
export const errorMessage = (err: unknown, fallback: string) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
|
||||
|
||||
type ProjectIconProps = Omit<ProjectAvatarProps, "fallback" | "src" | "variant"> & {
|
||||
project: LocalProject
|
||||
project: Pick<LocalProject, "id" | "name" | "worktree" | "icon">
|
||||
fallback?: string
|
||||
icon?: LocalProject["icon"]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
return serverCtx()?.projects.forSession(session)
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
@@ -29,7 +29,6 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { updaterAction } from "@/shell/updates/action"
|
||||
import type { UpdaterState } from "@/shell/updates/types"
|
||||
@@ -441,7 +440,7 @@ export function Titlebar(props: {
|
||||
const value = session()
|
||||
if (!tab || !value) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
return projectForSession(value, conn ? global.ensureServerCtx(conn).projects.list() : [])
|
||||
return conn ? global.ensureServerCtx(conn).projects.forSession(value) : undefined
|
||||
})
|
||||
const currentTitle = () => {
|
||||
const tab = currentTab()
|
||||
|
||||
@@ -131,7 +131,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
|
||||
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
|
||||
does not consume are ignored, like JS, and consumed arguments coerce, like JS (`"3.7".replace(/\d\.\d/,
|
||||
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
because `includes` is called without a string `this`.
|
||||
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
@@ -155,8 +155,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
array, an array-like object (its `length` clamped and capped like `Array.from`), or `null`/`undefined`. A
|
||||
bound function is named `bound f`, has its remaining `length`, and is not constructible.
|
||||
- [x] `JSON.parse` revivers and `JSON.stringify` function replacers see the holder object as `this`.
|
||||
- [ ] The optional `thisArg` of iteration methods (`map`, `forEach`, `Map.prototype.forEach`, `Array.from`, …) is
|
||||
accepted but not yet passed as `this`; callbacks run with `this` undefined.
|
||||
- [x] The optional `thisArg` of the Array, Uint8Array, and `Array.from` callback methods and of Map, Set,
|
||||
URLSearchParams, and Headers `forEach` is the callback's `this`: `[1, 2].forEach(function () { this.n++ }, c)`
|
||||
increments `c.n` twice. Arrows ignore it, as in JS; `reduce`/`reduceRight` take an initial value instead.
|
||||
- [ ] User-defined constructor calls.
|
||||
- [ ] Classes and private fields.
|
||||
- [x] Functions are objects: they hold own properties (`fn.count = 1`), enumerate them, and expose read-only `name`
|
||||
@@ -241,11 +242,19 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
throws: `{ valueOf() { return 7 } } * 2` is `14`, `` `${{ toString() { return "x" } }}` `` is `"x"`, and
|
||||
`[1, 2]` with `arr.toString = () => "x"` makes `arr + ""` `"x"`. Dates keep their `Symbol.toPrimitive`
|
||||
behavior (`date + 1` concatenates, `date - date` subtracts).
|
||||
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric built-in arguments outside
|
||||
`Math` and `Date` (`at`, `indexOf` start, `toFixed` digits) still use the built-in form (`NaN`,
|
||||
`"[object Object]"`) and ignore own methods.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
|
||||
string keys.
|
||||
- [x] String and Number method arguments convert through ToPrimitive in spec order, receiver first: search strings,
|
||||
separators, fills, and replacements with the string hint, indexes, counts, digits, and radixes with the number
|
||||
hint (`"abc".indexOf({ toString() { return "b" } })` is `1`, `(255).toString({ valueOf() { return 16 } })` is
|
||||
`"ff"`, `String.prototype.trim.call({ toString() { return " a " } })` is `"a"`). Only consumed positions
|
||||
convert; a RegExp pattern is used as is, and `includes`/`startsWith`/`endsWith` reject one before converting.
|
||||
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric arguments of the Array and
|
||||
Uint8Array methods (`at`, `indexOf` start, `slice`) still use the built-in form (`NaN`, `"[object Object]"`) and
|
||||
ignore own methods.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]` and `x[true]` become string keys, and a data object key
|
||||
converts through its own `toString`/`valueOf` (string hint) exactly once per access, in reads, writes,
|
||||
compound assignment, `++`, `delete`, `in`, object literals, and destructuring:
|
||||
`o[{ toString() { return "id" } }] += 1` updates `o.id`. A nullish base throws before the key converts, as
|
||||
in JS. Opaque values (functions, promises, tool references) keep their built-in string form.
|
||||
|
||||
## Promises and tools
|
||||
|
||||
@@ -306,8 +315,9 @@ reject }` object.
|
||||
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
|
||||
primitive wrapper objects (`Object(1)`) are rejected explicitly.
|
||||
- [x] Computed property names and object spread. Any value works as a key (ToPropertyKey): strings, numbers, and the
|
||||
two confined symbols as themselves, everything else as its string form (`o[null]` is `o["null"]`, `o[{}]` is
|
||||
`o["[object Object]"]`), in reads, writes, literals, `in`, and destructuring.
|
||||
two confined symbols as themselves, data objects through their own `toString` (`o[[1, 2]]` is `o["1,2"]`), and
|
||||
everything else as its string form (`o[null]` is `o["null"]`), in reads, writes, literals, `in`, and
|
||||
destructuring.
|
||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
|
||||
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
|
||||
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
|
||||
@@ -368,7 +378,6 @@ reject }` object.
|
||||
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`.
|
||||
- [x] Canonical array/string index parsing: keys such as `"01"` are ordinary properties rather than aliases of index
|
||||
`1`.
|
||||
@@ -381,9 +390,9 @@ reject }` object.
|
||||
`flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`,
|
||||
`String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString
|
||||
(`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a
|
||||
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, and
|
||||
`parseInt` consult a program object's own `valueOf`/`toString`; the array and number methods do not yet (see
|
||||
ToPrimitive above).
|
||||
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, `parseInt`,
|
||||
and the String and Number methods consult a program object's own `valueOf`/`toString`; the array methods do not
|
||||
yet (see ToPrimitive above).
|
||||
|
||||
## Strings
|
||||
|
||||
@@ -400,14 +409,15 @@ reject }` object.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
|
||||
`repeat` still requires a finite non-negative count.
|
||||
expressions with a native-style `TypeError`. Data objects convert through their own `toString`/`valueOf` (see
|
||||
ToPrimitive above). Opaque runtime references still reject as data errors, and `repeat` still requires a finite
|
||||
non-negative count.
|
||||
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern.
|
||||
- [x] `String.raw`, on a template object or any `{ raw }` object; raw strings and substitutions coerce through their own
|
||||
`toString`.
|
||||
- [x] `match`, `matchAll`, `search`, and `split` read any non-RegExp argument as a pattern string, as `new RegExp(arg)`
|
||||
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, and `undefined` is the empty pattern.
|
||||
Objects use their built-in string form until ToPrimitive lands.
|
||||
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, `undefined` is the empty pattern, and
|
||||
an object supplies its own `toString`.
|
||||
|
||||
## Numbers and Math
|
||||
|
||||
@@ -516,6 +526,11 @@ reject }` object.
|
||||
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
|
||||
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
|
||||
`isSupersetOf`, and `isDisjointFrom`, including supported Set-like operands.
|
||||
- [x] `WeakMap` (`get`, `set`, `has`, `delete`, `getOrInsert`, `getOrInsertComputed`) and `WeakSet` (`add`, `has`,
|
||||
`delete`), constructed from iterables. Keys must be program objects: a primitive or tool reference throws
|
||||
`Invalid value used as weak map key`, while `has`/`delete`/`get` with one answer `false`/`undefined`. Entries are
|
||||
held by a host weak collection, so nothing is retained past the key's own lifetime. As in JS they have no `size`,
|
||||
iteration, or `clear`, `structuredClone` rejects them, and they serialize to `{}` at host boundaries.
|
||||
|
||||
## URL and URI helpers
|
||||
|
||||
|
||||
@@ -67,21 +67,24 @@ export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
|
||||
|
||||
/**
|
||||
* Runs a synchronous native body on its arguments after ToPrimitive, in order, with one hint for all positions or
|
||||
* one per position. Primitive arguments skip the Effect entirely.
|
||||
* Runs a native body on its arguments after ToPrimitive, in order, with one hint for all positions or one per
|
||||
* position. Primitive arguments skip the Effect entirely.
|
||||
*/
|
||||
export const withPrimitives = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
hints: Hint | ReadonlyArray<Hint>,
|
||||
values: Array<Value>,
|
||||
body: (primitives: Array<Value>) => Value,
|
||||
body: (primitives: Array<Value>) => Value | Effect.Effect<Value, unknown, R>,
|
||||
): Value | Effect.Effect<Value, unknown, R> => {
|
||||
if (!values.some((value) => value instanceof Obj)) return body(values)
|
||||
return Effect.map(
|
||||
return Effect.flatMap(
|
||||
Effect.forEach(values, (value, index) =>
|
||||
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
|
||||
),
|
||||
body,
|
||||
(primitives) => {
|
||||
const result = body(primitives)
|
||||
return Effect.isEffect(result) ? result : Effect.succeed(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { Arr, Callable, coerceToInteger, coerceToString, get, Obj, type Value } from "./objects.js"
|
||||
import { arrayGlobal } from "../stdlib/array.js"
|
||||
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
|
||||
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
|
||||
import { mapGlobal, setGlobal, weakMapGlobal, weakSetGlobal } from "../stdlib/collections.js"
|
||||
import { consoleGlobal } from "../stdlib/console.js"
|
||||
import { dateGlobal } from "../stdlib/date.js"
|
||||
import { jsonGlobal } from "../stdlib/json.js"
|
||||
@@ -111,6 +111,8 @@ const table: Record<string, Factory> = {
|
||||
RegExp: (ctx) => regexpGlobal(ctx),
|
||||
Map: (ctx) => mapGlobal(ctx),
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
WeakMap: (ctx) => weakMapGlobal(ctx),
|
||||
WeakSet: (ctx) => weakSetGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
|
||||
@@ -123,6 +123,10 @@ const primitiveOperators = new Set([
|
||||
">>>",
|
||||
])
|
||||
|
||||
/** ToPropertyKey on a primitive (or an opaque value, which keeps its built-in string form). */
|
||||
const propertyKey = (value: Value): PropertyKey =>
|
||||
typeof value === "string" || typeof value === "number" || typeof value === "symbol" ? value : coerceToString(value)
|
||||
|
||||
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
|
||||
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
|
||||
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
|
||||
@@ -1277,7 +1281,7 @@ class Frame<R> {
|
||||
}
|
||||
const keyNode = property.key
|
||||
if (property.computed) {
|
||||
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value))
|
||||
return Effect.flatMap(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
|
||||
}
|
||||
if (keyNode.type === "Identifier") return Effect.succeed(keyNode.name)
|
||||
if (keyNode.type === "Literal") return Effect.succeed(String(keyNode.value))
|
||||
@@ -1400,6 +1404,10 @@ class Frame<R> {
|
||||
// IsLooselyEqual converts only an object facing a non-nullish primitive; two objects (including tool
|
||||
// references, which are not Obj) compare by identity.
|
||||
const equality = operator === "==" || operator === "!="
|
||||
// `in` checks the right operand before ToPropertyKey on the left, so a bad right side wins over a bad key.
|
||||
if (operator === "in" && lhs instanceof Obj && !isOpaque(lhs) && rhs instanceof Obj) {
|
||||
return Effect.map(this.toPropertyKey(lhs, node), (key) => has(rhs, key))
|
||||
}
|
||||
const other = lhs instanceof Obj ? rhs : lhs
|
||||
const converts =
|
||||
primitiveOperators.has(operator) ||
|
||||
@@ -1419,9 +1427,7 @@ class Frame<R> {
|
||||
if (operator === "!==") return lhs !== rhs
|
||||
if (operator === "==") return this.looselyEqual(lhs, rhs, node)
|
||||
if (operator === "!=") return !this.looselyEqual(lhs, rhs, node)
|
||||
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) {
|
||||
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
|
||||
}
|
||||
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) return has(rhs, propertyKey(lhs))
|
||||
if (isOpaque(lhs) || isOpaque(rhs)) {
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
@@ -2046,11 +2052,11 @@ class Frame<R> {
|
||||
let key: PropertyKey
|
||||
|
||||
if (property.computed) {
|
||||
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode))
|
||||
key = yield* self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
|
||||
} else if (keyNode.type === "Identifier") {
|
||||
key = keyNode.name
|
||||
} else if (keyNode.type === "Literal") {
|
||||
key = self.toPropertyKey(literal(keyNode))
|
||||
key = propertyKey(literal(keyNode))
|
||||
} else {
|
||||
throw typeError("Unsupported object property key shape.", keyNode)
|
||||
}
|
||||
@@ -2178,11 +2184,16 @@ class Frame<R> {
|
||||
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((objectValue === null || objectValue === undefined) && node.optional) return OptionalShortCircuit
|
||||
|
||||
const key = node.computed
|
||||
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
|
||||
: propertyNode.type === "Identifier"
|
||||
const keyValue =
|
||||
!node.computed && propertyNode.type === "Identifier"
|
||||
? propertyNode.name
|
||||
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
|
||||
: yield* self.evaluateExpression(propertyNode)
|
||||
// GetValue applies ToObject to the base before ToPropertyKey, so a nullish base throws before the key's own
|
||||
// toString runs.
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${coerceToString(keyValue)}').`, objectNode)
|
||||
}
|
||||
const key = yield* self.toPropertyKey(keyValue, propertyNode)
|
||||
return self.resolveProperty(objectValue, key, objectNode, propertyNode)
|
||||
})
|
||||
}
|
||||
@@ -2314,9 +2325,10 @@ class Frame<R> {
|
||||
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
|
||||
}
|
||||
|
||||
// ToPropertyKey: anything else becomes its string form, so `counts[row.category]` works when the field is null.
|
||||
private toPropertyKey(value: Value): PropertyKey {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "symbol") return value
|
||||
return coerceToString(value)
|
||||
// ToPropertyKey: a data object converts through its own `toString`/`valueOf` first; anything else becomes its
|
||||
// string form synchronously, so `counts[row.category]` works when the field is null.
|
||||
private toPropertyKey(value: Value, node: AstNode): Effect.Effect<PropertyKey, unknown, R> {
|
||||
if (!(value instanceof Obj)) return Effect.succeed(propertyKey(value))
|
||||
return Effect.map(this.toPrimitive(value, "string", node), propertyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ const builtins = [
|
||||
"RegExp",
|
||||
"Map",
|
||||
"Set",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
@@ -88,6 +90,8 @@ export const createBuiltins = (): Builtins => {
|
||||
RegExp: plain(),
|
||||
Map: plain(),
|
||||
Set: plain(),
|
||||
WeakMap: plain(),
|
||||
WeakSet: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
|
||||
@@ -356,6 +356,23 @@ export class SetObj extends Wrapper {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keys are program objects, so a host WeakMap gives the same lifetime rule as JavaScript without any bookkeeping. */
|
||||
export class WeakMapObj extends Wrapper {
|
||||
override readonly tag = "WeakMap"
|
||||
readonly map = new WeakMap<Obj, Value>()
|
||||
override inspect() {
|
||||
return "WeakMap { <items unknown> }"
|
||||
}
|
||||
}
|
||||
|
||||
export class WeakSetObj extends Wrapper {
|
||||
override readonly tag = "WeakSet"
|
||||
readonly set = new WeakSet<Obj>()
|
||||
override inspect() {
|
||||
return "WeakSet { <items unknown> }"
|
||||
}
|
||||
}
|
||||
|
||||
export class URLSearchParamsObj extends Wrapper {
|
||||
override readonly tag = "URLSearchParams"
|
||||
constructor(
|
||||
|
||||
@@ -49,7 +49,7 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
|
||||
const values: Array<Value> = []
|
||||
for (let index = 0; index < arrayLike.length; index += 1) {
|
||||
const item = get(arrayLike.source, index)
|
||||
values.push(apply === undefined ? item : yield* apply([item, index]))
|
||||
values.push(apply === undefined ? item : yield* apply([item, index], args[2]))
|
||||
}
|
||||
return new Arr(proto, values)
|
||||
}
|
||||
@@ -59,7 +59,9 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return new Arr(proto, values)
|
||||
values.push(
|
||||
apply === undefined ? step.value : yield* preserveConsumerError(cursor.close, apply([step.value, index])),
|
||||
apply === undefined
|
||||
? step.value
|
||||
: yield* preserveConsumerError(cursor.close, apply([step.value, index], args[2])),
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
@@ -372,7 +374,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const values: Array<Value> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target.items)) continue
|
||||
const mapped = yield* apply([target.items[index], index, target])
|
||||
const mapped = yield* apply([target.items[index], index, target], args[1])
|
||||
if (mapped instanceof Arr) values.push(...mapped.items)
|
||||
else values.push(mapped)
|
||||
}
|
||||
@@ -411,7 +413,10 @@ export const callbackMethods = <R, T extends Obj>(
|
||||
length,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, name)
|
||||
return body(elements(target), target, applyCollectionCallback(ctx, args[0], `${label}.${name}`), args)
|
||||
const call = applyCollectionCallback(ctx, args[0], `${label}.${name}`)
|
||||
// reduce and reduceRight take an initial value where the others take a thisArg.
|
||||
const thisArg = name.startsWith("reduce") ? undefined : args[1]
|
||||
return body(elements(target), target, (callbackArgs) => call(callbackArgs, thisArg), args)
|
||||
},
|
||||
]
|
||||
return [
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
type Value,
|
||||
WeakMapObj,
|
||||
WeakSetObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, isOpaque } from "../interpreter/references.js"
|
||||
import {
|
||||
@@ -188,7 +190,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Map.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, item] of target.map.entries()) yield* apply([item, key, target])
|
||||
for (const [key, item] of target.map.entries()) yield* apply([item, key, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
@@ -386,7 +388,7 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Set.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const item of target.set.values()) yield* apply([item, item, target])
|
||||
for (const item of target.set.values()) yield* apply([item, item, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
@@ -402,3 +404,134 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
define(proto, IteratorSymbol, get(proto, "values"), hidden)
|
||||
return set
|
||||
}
|
||||
|
||||
// CanBeHeldWeakly: only program objects; tool references are rebuilt on every access, so they could never be found again.
|
||||
const weakKey = (value: Value, label: string) => {
|
||||
if (value instanceof Obj) return value
|
||||
throw typeError(`Invalid value used ${label}: ${describeValue(value)} cannot be held weakly.`)
|
||||
}
|
||||
|
||||
export const weakMapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.WeakMap
|
||||
const weakMap = constructor<R>(builtins, proto, {
|
||||
name: "WeakMap",
|
||||
call: requiresNew("WeakMap"),
|
||||
construct: (args, newTarget) => {
|
||||
const target = new WeakMapObj(prototypeFrom(newTarget, proto))
|
||||
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(args[0]!)
|
||||
if (cursor === undefined) {
|
||||
throw typeError(
|
||||
`new WeakMap(...) expects an iterable of [key, value] pairs, received ${describeValue(args[0])}.`,
|
||||
)
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return target
|
||||
yield* preserveConsumerError(
|
||||
cursor.close,
|
||||
Effect.sync(() => {
|
||||
if (!(step.value instanceof Obj)) {
|
||||
throw typeError("new WeakMap(...) expects [key, value] pairs as entry objects.")
|
||||
}
|
||||
target.map.set(weakKey(getOwn(step.value, 0), "as weak map key"), getOwn(step.value, 1))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
const self = (thisValue: Value, name: string) => receiver(WeakMapObj, thisValue, `WeakMap.prototype.${name}`).map
|
||||
// Lookups pass any key through: the host collection answers false for a non-object, as the spec requires.
|
||||
const key = (value: Value) => weakKey(value, "as weak map key")
|
||||
methods(builtins, proto, [
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "get")
|
||||
return args[0] instanceof Obj ? target.get(args[0]) : undefined
|
||||
},
|
||||
],
|
||||
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
self(thisValue, "set").set(key(args[0]), args[1])
|
||||
return thisValue
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsert",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsert")
|
||||
const k = key(args[0])
|
||||
if (!target.has(k)) target.set(k, args[1])
|
||||
return target.get(k)
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsertComputed",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsertComputed")
|
||||
const k = key(args[0])
|
||||
const apply = applyCollectionCallback(ctx, args[1], "WeakMap.getOrInsertComputed")
|
||||
if (target.has(k)) return target.get(k)
|
||||
return Effect.map(apply([k]), (value) => {
|
||||
target.set(k, value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
return weakMap
|
||||
}
|
||||
|
||||
export const weakSetGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.WeakSet
|
||||
const weakSet = constructor<R>(builtins, proto, {
|
||||
name: "WeakSet",
|
||||
call: requiresNew("WeakSet"),
|
||||
construct: (args, newTarget) => {
|
||||
const target = new WeakSetObj(prototypeFrom(newTarget, proto))
|
||||
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(args[0]!)
|
||||
if (cursor === undefined) {
|
||||
throw typeError(`new WeakSet(...) expects a synchronous iterable, received ${describeValue(args[0])}.`)
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return target
|
||||
yield* preserveConsumerError(
|
||||
cursor.close,
|
||||
Effect.sync(() => {
|
||||
target.set.add(weakKey(step.value, "in weak set"))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
const self = (thisValue: Value, name: string) => receiver(WeakSetObj, thisValue, `WeakSet.prototype.${name}`).set
|
||||
methods(builtins, proto, [
|
||||
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
|
||||
[
|
||||
"add",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
self(thisValue, "add").add(weakKey(args[0], "in weak set"))
|
||||
return thisValue
|
||||
},
|
||||
],
|
||||
])
|
||||
return weakSet
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { constructor, constants, methods } from "../interpreter/native.js"
|
||||
import { constructor, constants, type Method, methods } from "../interpreter/native.js"
|
||||
import { coerceToNumber, type Value } from "../interpreter/objects.js"
|
||||
import { rangeError, typeError } from "../interpreter/model.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { withPrimitives } from "../interpreter/callback.js"
|
||||
import { coerce, coercion } from "./value.js"
|
||||
|
||||
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
@@ -34,32 +35,28 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
if (typeof thisValue === "number") return thisValue
|
||||
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
|
||||
}
|
||||
const optNum = (arg: Value): number | undefined => (arg === undefined ? undefined : coerceToNumber(arg))
|
||||
// The receiver is checked first, then the one argument converts through ToPrimitive with the number hint.
|
||||
const formatting = (name: string, op: (value: number, digits: number | undefined) => string): Method => [
|
||||
name,
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, name)
|
||||
return withPrimitives(ctx, "number", [args[0]], ([digits]) =>
|
||||
op(value, digits === undefined ? undefined : coerceToNumber(digits)),
|
||||
)
|
||||
},
|
||||
]
|
||||
methods(builtins, builtins.Number, [
|
||||
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum(args[0]))],
|
||||
formatting("toFixed", (value, digits) => value.toFixed(digits)),
|
||||
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
|
||||
["toExponential", 1, (thisValue, args) => self(thisValue, "toExponential").toExponential(optNum(args[0]))],
|
||||
[
|
||||
"toPrecision",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, "toPrecision")
|
||||
const digits = optNum(args[0])
|
||||
return digits === undefined ? value.toString() : value.toPrecision(digits)
|
||||
},
|
||||
],
|
||||
[
|
||||
"toString",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, "toString")
|
||||
const radix = optNum(args[0])
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw rangeError("Number.toString radix must be between 2 and 36.")
|
||||
}
|
||||
return value.toString(radix)
|
||||
},
|
||||
],
|
||||
formatting("toExponential", (value, digits) => value.toExponential(digits)),
|
||||
formatting("toPrecision", (value, digits) => (digits === undefined ? value.toString() : value.toPrecision(digits))),
|
||||
formatting("toString", (value, radix) => {
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw rangeError("Number.toString radix must be between 2 and 36.")
|
||||
}
|
||||
return value.toString(radix)
|
||||
}),
|
||||
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
|
||||
])
|
||||
return number
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, isSupportedCallback, toPrimitiveString } from "../interpreter/callback.js"
|
||||
import {
|
||||
applyCollectionCallback,
|
||||
type Hint,
|
||||
isSupportedCallback,
|
||||
toPrimitiveString,
|
||||
withPrimitives,
|
||||
} from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { matchToValue, toHostRegex } from "./regexp.js"
|
||||
import { coercion } from "./value.js"
|
||||
@@ -142,34 +148,72 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
args[index] === undefined ? undefined : num(name, args, index)
|
||||
const optStr = (name: string, args: Array<Value>, index: number): string | undefined =>
|
||||
args[index] === undefined ? undefined : str(name, args, index)
|
||||
const rejectRegex = (name: string, args: Array<Value>): void => {
|
||||
if (args[0] instanceof RegExpObj) {
|
||||
throw typeError(
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
// ToPrimitive in spec order: the receiver, then the arguments the method consumes, one hint per position; the
|
||||
// rest pass through as they are.
|
||||
const simple = (
|
||||
name: string,
|
||||
length: number,
|
||||
op: (value: string, args: Array<Value>) => ReturnType<Impl>,
|
||||
): Method => [name, length, (thisValue, args) => op(self(thisValue, name), args)]
|
||||
const replace = (name: "replace" | "replaceAll") =>
|
||||
simple(name, 2, (value, args) => {
|
||||
if (isSupportedCallback(args[1])) return replaceWithCallback(ctx, value, name, args)
|
||||
if (typeofValue(args[1]) === "function") {
|
||||
hints: ReadonlyArray<Hint> = [],
|
||||
): Method => [
|
||||
name,
|
||||
length,
|
||||
(thisValue, args) => {
|
||||
if (thisValue === null || thisValue === undefined) {
|
||||
throw typeError(`String.prototype.${name} called on null or undefined.`)
|
||||
}
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
["string", ...hints],
|
||||
[thisValue, ...args.slice(0, hints.length)],
|
||||
([value, ...primitives]) => op(coerceToString(value), [...primitives, ...args.slice(hints.length)]),
|
||||
)
|
||||
},
|
||||
]
|
||||
// includes, startsWith, and endsWith reject a RegExp before converting their search string and position.
|
||||
const searching = (name: string, op: (value: string, search: string, position: number | undefined) => boolean) =>
|
||||
simple(name, 1, (value, args) => {
|
||||
if (args[0] instanceof RegExpObj) {
|
||||
throw typeError(
|
||||
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof RegExpObj) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(name, args, 1)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
|
||||
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
||||
}
|
||||
if (name === "replace") return value.replace(str(name, args, 0), str(name, args, 1))
|
||||
return value.replaceAll(str(name, args, 0), str(name, args, 1))
|
||||
return withPrimitives(ctx, ["string", "number"], args.slice(0, 2), (primitives) =>
|
||||
op(value, str(name, primitives, 0), optNum(name, primitives, 1)),
|
||||
)
|
||||
})
|
||||
// match, matchAll, and search read a RegExp as is and convert anything else to its pattern text.
|
||||
const withPattern = (args: Array<Value>, op: (pattern: Value) => Value) =>
|
||||
args[0] instanceof RegExpObj ? op(args[0]) : withPrimitives(ctx, "string", [args[0]], ([text]) => op(text))
|
||||
const replace = (name: "replace" | "replaceAll") =>
|
||||
simple(name, 2, (value, args) => {
|
||||
const pattern = args[0]
|
||||
const replacer = args[1]
|
||||
// A RegExp pattern is used as is; a plain one converts to its search string, then a non-callable replacement.
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
[pattern instanceof RegExpObj ? undefined : pattern, isSupportedCallback(replacer) ? undefined : replacer],
|
||||
([search, replacement]) => {
|
||||
if (isSupportedCallback(replacer)) {
|
||||
return replaceWithCallback(ctx, value, name, [pattern instanceof RegExpObj ? pattern : search, replacer])
|
||||
}
|
||||
if (typeofValue(replacer) === "function") {
|
||||
throw typeError(
|
||||
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
|
||||
)
|
||||
}
|
||||
const primitives = [search, replacement]
|
||||
if (pattern instanceof RegExpObj) {
|
||||
const regex = pattern.regex
|
||||
const text = str(name, primitives, 1)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(regex)
|
||||
return name === "replace" ? value.replace(regex, text) : value.replaceAll(regex, text)
|
||||
}
|
||||
if (name === "replace") return value.replace(str(name, primitives, 0), str(name, primitives, 1))
|
||||
return value.replaceAll(str(name, primitives, 0), str(name, primitives, 1))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
methods(builtins, builtins.String, [
|
||||
@@ -185,107 +229,148 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
simple("trimEnd", 0, (value) => value.trimEnd()),
|
||||
simple("trimRight", 0, (value) => value.trimEnd()),
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
|
||||
simple("normalize", 0, (value, args) => {
|
||||
const form = optStr("normalize", args, 0)
|
||||
try {
|
||||
return value.normalize(form)
|
||||
} catch {
|
||||
throw rangeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0)), ["string"]),
|
||||
simple(
|
||||
"normalize",
|
||||
0,
|
||||
(value, args) => {
|
||||
const form = optStr("normalize", args, 0)
|
||||
try {
|
||||
return value.normalize(form)
|
||||
} catch {
|
||||
throw rangeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
)
|
||||
}
|
||||
},
|
||||
["string"],
|
||||
),
|
||||
simple("split", 2, (value, args) => {
|
||||
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
|
||||
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||
// unless the limit truncates to zero.
|
||||
const requestedLimit = optNum("split", args, 1)
|
||||
if (args[0] === undefined) {
|
||||
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
|
||||
}
|
||||
const parts =
|
||||
args[0] instanceof RegExpObj
|
||||
? value.split(args[0].regex, requestedLimit)
|
||||
: value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
checkArrayLength(parts.length)
|
||||
return wrap(parts)
|
||||
const separator = args[0]
|
||||
// A RegExp separator is used as is; the limit converts before a plain separator does, as in the spec.
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
["number", "string"],
|
||||
[args[1], separator instanceof RegExpObj ? undefined : separator],
|
||||
([limit, pattern]) => {
|
||||
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
|
||||
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||
// unless the limit truncates to zero.
|
||||
const requestedLimit = args[1] === undefined ? undefined : num("split", [pattern, limit], 1)
|
||||
if (separator === undefined) {
|
||||
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
|
||||
}
|
||||
const parts =
|
||||
separator instanceof RegExpObj
|
||||
? value.split(separator.regex, requestedLimit)
|
||||
: value.split(str("split", [pattern], 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
checkArrayLength(parts.length)
|
||||
return wrap(parts)
|
||||
},
|
||||
)
|
||||
}),
|
||||
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1))),
|
||||
simple("includes", 1, (value, args) => {
|
||||
rejectRegex("includes", args)
|
||||
return value.includes(str("includes", args, 0), optNum("includes", args, 1))
|
||||
}),
|
||||
simple("startsWith", 1, (value, args) => {
|
||||
rejectRegex("startsWith", args)
|
||||
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", args, 1))
|
||||
}),
|
||||
simple("endsWith", 1, (value, args) => {
|
||||
rejectRegex("endsWith", args)
|
||||
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", args, 1))
|
||||
}),
|
||||
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1))),
|
||||
simple("lastIndexOf", 1, (value, args) =>
|
||||
value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
|
||||
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1)), [
|
||||
"number",
|
||||
"number",
|
||||
]),
|
||||
searching("includes", (value, search, position) => value.includes(search, position)),
|
||||
searching("startsWith", (value, search, position) => value.startsWith(search, position)),
|
||||
searching("endsWith", (value, search, position) => value.endsWith(search, position)),
|
||||
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1)), [
|
||||
"string",
|
||||
"number",
|
||||
]),
|
||||
simple(
|
||||
"lastIndexOf",
|
||||
1,
|
||||
(value, args) => value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
|
||||
["string", "number"],
|
||||
),
|
||||
replace("replace"),
|
||||
replace("replaceAll"),
|
||||
simple("match", 1, (value, args) => {
|
||||
const pattern = toHostRegex(args[0], "match")
|
||||
const matched = value.match(pattern)
|
||||
if (matched === null) return null
|
||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
||||
if (pattern.global) return new Arr(builtins.Array, [...matched])
|
||||
return matchToValue(builtins, matched)
|
||||
}),
|
||||
simple("matchAll", 1, (value, args) => {
|
||||
const pattern = toHostRegex(args[0], "matchAll", "g")
|
||||
if (!pattern.global) {
|
||||
throw typeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
|
||||
)
|
||||
}
|
||||
const matches: Array<Value> = []
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
checkArrayLength(matches.length + 1)
|
||||
matches.push(matchToValue(builtins, match))
|
||||
}
|
||||
return new Arr(builtins.Array, matches)
|
||||
}),
|
||||
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
|
||||
simple("repeat", 1, (value, args) => {
|
||||
const count = num("repeat", args, 0)
|
||||
if (!Number.isFinite(count) || count < 0) {
|
||||
throw rangeError("String.repeat expects a finite non-negative count.")
|
||||
}
|
||||
checkStringLength(value.length * count)
|
||||
return value.repeat(count)
|
||||
}),
|
||||
simple("padStart", 1, (value, args) => {
|
||||
const length = num("padStart", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padStart(length, optStr("padStart", args, 1))
|
||||
}),
|
||||
simple("padEnd", 1, (value, args) => {
|
||||
const length = num("padEnd", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padEnd(length, optStr("padEnd", args, 1))
|
||||
}),
|
||||
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0)),
|
||||
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0)),
|
||||
simple("substring", 2, (value, args) =>
|
||||
value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
|
||||
simple("match", 1, (value, args) =>
|
||||
withPattern(args, (arg) => {
|
||||
const regex = toHostRegex(arg, "match")
|
||||
const matched = value.match(regex)
|
||||
if (matched === null) return null
|
||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
||||
if (regex.global) return new Arr(builtins.Array, [...matched])
|
||||
return matchToValue(builtins, matched)
|
||||
}),
|
||||
),
|
||||
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1))),
|
||||
simple("matchAll", 1, (value, args) =>
|
||||
withPattern(args, (arg) => {
|
||||
const regex = toHostRegex(arg, "matchAll", "g")
|
||||
if (!regex.global) {
|
||||
throw typeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${regex.source}/${regex.flags}g, or use String.match for a single match.`,
|
||||
)
|
||||
}
|
||||
const matches: Array<Value> = []
|
||||
for (const match of value.matchAll(regex)) {
|
||||
checkArrayLength(matches.length + 1)
|
||||
matches.push(matchToValue(builtins, match))
|
||||
}
|
||||
return new Arr(builtins.Array, matches)
|
||||
}),
|
||||
),
|
||||
simple("search", 1, (value, args) => withPattern(args, (arg) => value.search(toHostRegex(arg, "search")))),
|
||||
simple(
|
||||
"repeat",
|
||||
1,
|
||||
(value, args) => {
|
||||
const count = num("repeat", args, 0)
|
||||
if (!Number.isFinite(count) || count < 0) {
|
||||
throw rangeError("String.repeat expects a finite non-negative count.")
|
||||
}
|
||||
checkStringLength(value.length * count)
|
||||
return value.repeat(count)
|
||||
},
|
||||
["number"],
|
||||
),
|
||||
simple(
|
||||
"padStart",
|
||||
1,
|
||||
(value, args) => {
|
||||
const length = num("padStart", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padStart(length, optStr("padStart", args, 1))
|
||||
},
|
||||
["number", "string"],
|
||||
),
|
||||
simple(
|
||||
"padEnd",
|
||||
1,
|
||||
(value, args) => {
|
||||
const length = num("padEnd", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padEnd(length, optStr("padEnd", args, 1))
|
||||
},
|
||||
["number", "string"],
|
||||
),
|
||||
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0), ["number"]),
|
||||
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0), ["number"]),
|
||||
simple(
|
||||
"substring",
|
||||
2,
|
||||
(value, args) => value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
|
||||
["number", "number"],
|
||||
),
|
||||
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1)), [
|
||||
"number",
|
||||
"number",
|
||||
]),
|
||||
simple("isWellFormed", 0, (value) => value.isWellFormed()),
|
||||
simple("toWellFormed", 0, (value) => value.toWellFormed()),
|
||||
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0)),
|
||||
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0)),
|
||||
simple("concat", 1, (value, args) => {
|
||||
const joined = value.concat(...args.map((_, index) => str("concat", args, index)))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
}),
|
||||
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0), ["number"]),
|
||||
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0), ["number"]),
|
||||
simple("concat", 1, (value, args) =>
|
||||
withPrimitives(ctx, "string", args, (parts) => {
|
||||
const joined = value.concat(...parts.map((_, index) => str("concat", parts, index)))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
}),
|
||||
),
|
||||
])
|
||||
define(
|
||||
builtins.String,
|
||||
|
||||
@@ -281,7 +281,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "URLSearchParams.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Obj,
|
||||
RegExpObj,
|
||||
SetObj,
|
||||
WeakMapObj,
|
||||
WeakSetObj,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
@@ -84,7 +86,9 @@ export const structuredCloneGlobal = <R>(ctx: Interpreter<R>) =>
|
||||
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.`)
|
||||
if (isRuntimeReference(value) || value instanceof WeakMapObj || value instanceof WeakSetObj) {
|
||||
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),
|
||||
)
|
||||
|
||||
@@ -1737,3 +1737,250 @@ describe("Date components convert through ToPrimitive", () => {
|
||||
).toEqual([true, 0, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe("iteration callbacks receive thisArg", () => {
|
||||
test("Array, Array.from, Map, Set, URLSearchParams, Headers, and Uint8Array pass it as this", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const c = { n: 0 }
|
||||
const count = function () { this.n++ }
|
||||
;[1, 2].forEach(count, c)
|
||||
;[1].map(count, c)
|
||||
;[1].filter(count, c)
|
||||
;[1].find(count, c)
|
||||
;[1].findIndex(count, c)
|
||||
;[1].findLast(count, c)
|
||||
;[1].findLastIndex(count, c)
|
||||
;[1].some(count, c)
|
||||
;[1].every(count, c)
|
||||
;[1].flatMap(count, c)
|
||||
Array.from([1], count, c)
|
||||
Array.from({ length: 1 }, count, c)
|
||||
new Map([[1, 1]]).forEach(count, c)
|
||||
new Set([1]).forEach(count, c)
|
||||
new URLSearchParams("a=1").forEach(count, c)
|
||||
new Headers({ a: "1" }).forEach(count, c)
|
||||
new Uint8Array([1]).forEach(count, c)
|
||||
return c.n
|
||||
`),
|
||||
).toBe(18)
|
||||
expect(await value(`return [1, 2].map(function (x) { return x + this.v }, { v: 10 })`)).toEqual([11, 12])
|
||||
})
|
||||
|
||||
test("arrows keep their lexical this, reduce takes an initial value instead, and opaque values are only bound", async () => {
|
||||
expect(await value(`return [1].map(() => typeof this, { v: 1 })`)).toEqual(["undefined"])
|
||||
expect(
|
||||
await value(`return [1, 2].reduce(function (a, b) { return a + b + (this === undefined ? 0 : 100) }, 0)`),
|
||||
).toBe(3)
|
||||
expect(
|
||||
await value(`
|
||||
let seen
|
||||
;[1].forEach(function () { seen = this }, tools.nowhere)
|
||||
return typeof seen
|
||||
`),
|
||||
).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("computed property keys convert through the object's own toString", () => {
|
||||
test("reads, writes, compound assignment, in, delete, literals, and destructuring share one conversion", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const key = { toString() { return "id" } }
|
||||
const o = {}
|
||||
o[key] = 1
|
||||
o[key] += 1
|
||||
const literal = { [key]: "lit" }
|
||||
const had = key in o
|
||||
delete literal[key]
|
||||
return [o.id, had, (({ [key]: v }) => v)(o), literal, o[[1, 2]] === undefined]
|
||||
`),
|
||||
).toEqual([2, true, 2, {}, true])
|
||||
expect(
|
||||
await value(`
|
||||
const seen = []
|
||||
const base = { x: 1 }
|
||||
base[{ toString() { seen.push(1); return "" } }] ^= 0
|
||||
base[{ toString() { seen.push(2); return "x" } }]++
|
||||
return [seen, base[""], base.x]
|
||||
`),
|
||||
).toEqual([[1, 2], 0, 2])
|
||||
})
|
||||
|
||||
test("valueOf is the fallback, a symbol result stays a symbol, and conversion failures surface", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = { 7: "seven" }
|
||||
const sym = { toString() { return Symbol.iterator } }
|
||||
o[sym] = 1
|
||||
return [o[{ valueOf() { return 7 }, toString: undefined }], typeof o[Symbol.iterator], Object.keys(o)]
|
||||
`),
|
||||
).toEqual(["seven", "number", ["7"]])
|
||||
expect((await error(`({})[{ toString() { throw new RangeError("bad key") } }]`)).message).toContain("bad key")
|
||||
expect((await error(`({})[{ toString() { return {} }, valueOf() { return {} } }]`)).message).toContain(
|
||||
"Cannot convert object to primitive value",
|
||||
)
|
||||
expect((await error(`const key = { toString() { return "a" } }; key in 5`)).message).toContain(
|
||||
"requires a data object on the right-hand side",
|
||||
)
|
||||
})
|
||||
|
||||
test("a nullish base throws before the key converts, as ToObject precedes ToPropertyKey", async () => {
|
||||
const failure = await error(`const base = null; base[{ toString() { throw new RangeError("key evaluated") } }]`)
|
||||
expect(failure.message).toContain("Cannot read properties of null")
|
||||
})
|
||||
|
||||
test("opaque values keep their built-in key form and a tool reference toString is never called", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = { "[object Function]": 1, "[object Promise]": 2 }
|
||||
return [o[() => 1], o[Promise.resolve("k")]]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
expect((await error(`({})[{ toString: tools.nowhere }] = 1`)).message).toContain(
|
||||
"Cannot convert object to primitive value",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("String and Number method arguments convert through ToPrimitive", () => {
|
||||
test("string positions use the string hint and numeric positions the number hint", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const s = { toString() { return "b" } }
|
||||
const n = { valueOf() { return 1 } }
|
||||
return [
|
||||
"abc".indexOf(s), "abc".lastIndexOf(s), "abc".includes(s), "abc".startsWith(s, n), "abc".endsWith(s, 2),
|
||||
"abc".charAt(n), "abc".at({ valueOf() { return -1 } }), "abc".slice(n), "abc".substring(n, 2),
|
||||
"abc".charCodeAt(n), "a".padStart({ valueOf() { return 3 } }, s), "x".padEnd(3, s), "ab".repeat({ valueOf() { return 2 } }),
|
||||
"a".concat(s, { valueOf() { return 1 }, toString() { return "T" } }), "b".localeCompare(s),
|
||||
(1.005).toFixed({ valueOf() { return 2 } }), (255).toString({ valueOf() { return 16 } }),
|
||||
(1234.5678).toPrecision({ valueOf() { return 6 } }), (12345).toExponential({ valueOf() { return 2 } }),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"b",
|
||||
"c",
|
||||
"bc",
|
||||
"b",
|
||||
98,
|
||||
"bba",
|
||||
"xbb",
|
||||
"abab",
|
||||
"abT",
|
||||
0,
|
||||
"1.00",
|
||||
"ff",
|
||||
"1234.57",
|
||||
"1.23e+4",
|
||||
])
|
||||
})
|
||||
|
||||
test("split, replace, match, and search convert a plain pattern but keep a RegExp as is", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const s = { toString() { return "b" } }
|
||||
return [
|
||||
"abc".split(s), "abc".split(/b/, { valueOf() { return 1 } }), "abc".split(undefined, { valueOf() { return undefined } }),
|
||||
"abc".replace(s, "X"), "abc".replace(/b/, { toString() { return "R" } }), "abc".replaceAll(s, s),
|
||||
"abc".replace(s, (m) => m.toUpperCase()), "abc".match(s)[0], "abcb".matchAll(s).length, "abc".search(s),
|
||||
]
|
||||
`),
|
||||
).toEqual([["a", "c"], ["a"], [], "aXc", "aRc", "abc", "aBc", "b", 2, 1])
|
||||
expect((await error(`"abc".includes(/b/)`)).message).toContain("cannot take a regular expression")
|
||||
})
|
||||
|
||||
test("the receiver converts first, then each consumed argument, in spec order; extra arguments are untouched", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const log = []
|
||||
const observer = (name, string, number) => ({
|
||||
toString() { log.push("toString:" + name); return string },
|
||||
valueOf() { log.push("valueOf:" + name); return number },
|
||||
})
|
||||
const padded = String.prototype.padStart.call(observer("receiver", {}, "abc"), observer("maxLength", 11, {}), observer("fillString", {}, "def"))
|
||||
const extra = "abc".indexOf("b", 1, { valueOf() { throw new Error("extra argument converted") } })
|
||||
return [padded, log, extra, String.prototype.trim.call({ toString() { return " abc " } })]
|
||||
`),
|
||||
).toEqual([
|
||||
"defdefdeabc",
|
||||
[
|
||||
"toString:receiver",
|
||||
"valueOf:receiver",
|
||||
"valueOf:maxLength",
|
||||
"toString:maxLength",
|
||||
"toString:fillString",
|
||||
"valueOf:fillString",
|
||||
],
|
||||
1,
|
||||
"abc",
|
||||
])
|
||||
})
|
||||
|
||||
test("conversion failures surface and opaque arguments still reject", async () => {
|
||||
expect((await error(`"abc".indexOf({ toString() { throw new RangeError("intostr") } })`)).message).toContain(
|
||||
"intostr",
|
||||
)
|
||||
expect((await error(`(1).toString({ valueOf() { throw new SyntaxError("poison") } })`)).message).toContain("poison")
|
||||
expect((await error(`(1).toFixed({ toString() { return {} }, valueOf() { return {} } })`)).message).toContain(
|
||||
"Cannot convert object to primitive value",
|
||||
)
|
||||
expect((await error(`"abc".indexOf(tools.nowhere)`)).message).toContain("expects argument 1 to be a data value")
|
||||
expect((await error(`"abc".indexOf(Promise.resolve("b"))`)).message).toContain(
|
||||
"expects argument 1 to be a data value",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("WeakMap and WeakSet", () => {
|
||||
test("hold program objects by identity and answer like JS for non-object keys", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const k = {}
|
||||
const f = () => 1
|
||||
const wm = new WeakMap([[k, 1]])
|
||||
const ws = new WeakSet([k])
|
||||
return [
|
||||
wm.set(f, "fn") === wm, wm.get(k), wm.get(f), wm.has({}), wm.get(1), wm.has(1), wm.delete("s"),
|
||||
wm.getOrInsert(k, 9), wm.getOrInsertComputed({}, (key) => typeof key),
|
||||
ws.add(f) === ws, ws.has(k), ws.has(f), ws.has(1), ws.delete(k), ws.has(k),
|
||||
String(wm), wm.size, "clear" in wm, Symbol.iterator in ws, JSON.stringify(wm),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
true,
|
||||
1,
|
||||
"fn",
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
"object",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[object WeakMap]",
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
"{}",
|
||||
])
|
||||
})
|
||||
|
||||
test("reject primitive keys, plain calls, bad receivers, and cloning", async () => {
|
||||
expect((await error(`new WeakMap().set(1, 1)`)).message).toContain("Invalid value used as weak map key")
|
||||
expect((await error(`new WeakSet([1])`)).message).toContain("Invalid value used in weak set")
|
||||
expect((await error(`WeakMap()`)).message).toContain("new")
|
||||
expect((await error(`WeakMap.prototype.get.call(new Map(), {})`)).message).toContain("incompatible receiver")
|
||||
expect((await error(`structuredClone(new WeakSet())`)).message).toContain("DataCloneError")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
|
||||
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 files. Boundaries are
|
||||
intentional limits of the interpreter, not compatibility work: classes, prototype objects, property descriptors,
|
||||
accessors, boxed primitives, typed arrays and buffers, weak collections, `Reflect` and `Proxy`, sloppy mode, `eval`,
|
||||
accessors, boxed primitives, typed arrays and buffers, `WeakRef` and `FinalizationRegistry`, `Reflect` and `Proxy`, 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.
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
|
||||
"boxed primitives": "\\b(new\\s+(String|Number|Boolean)\\b|Object\\s*\\(\\s*(true|false|-?\\d|['\"]))",
|
||||
"typed arrays and buffers": "\\b(ArrayBuffer|SharedArrayBuffer|DataView|Int8Array|Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|Uint32Array|Float16Array|Float32Array|Float64Array|BigInt64Array|BigUint64Array)\\b",
|
||||
"weak collections": "\\b(WeakMap|WeakSet|WeakRef|FinalizationRegistry)\\b",
|
||||
"weak collections": "\\b(WeakRef|FinalizationRegistry)\\b",
|
||||
"Reflect and Proxy": "\\b(Reflect|Proxy)\\b",
|
||||
"sloppy mode": "\\bwith\\s*\\(",
|
||||
"eval": "\\b(eval|Function)\\b",
|
||||
@@ -74,8 +74,6 @@
|
||||
"Uint16Array",
|
||||
"Uint32Array",
|
||||
"uint8array-base64",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"WeakRef",
|
||||
"FinalizationRegistry",
|
||||
"Intl-enumeration",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
built-ins/AggregateError/message-undefined-no-prop.js # explicit Expected SameValue(«true», «false») to be true
|
||||
built-ins/Array/S15.4.5.2_A3_T3.js # RangeError: Invalid array length
|
||||
built-ins/Array/S15.4_A1.1_T9.js # The value of x[0] is expected to be 0 Expected SameValue(«undefined», «0») to be true
|
||||
built-ins/Array/from/Array.from_forwards-length-for-array-likes.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/from/calling-from-valid-2.js # The value of calls[0].thisArg is expected to equal the value of thisArg Expected SameValue(«undefine
|
||||
built-ins/Array/from/elements-deleted-after.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/from/iter-cstm-ctor-err.js # Array.from.call(C, items) throws a Test262Error exception Expected a Test262Error but got a TypeErro
|
||||
built-ins/Array/from/iter-cstm-ctor.js # TypeError: The right-hand side of '…' has no '…' object.
|
||||
built-ins/Array/from/iter-map-fn-this-arg.js # The value of thisVals[0] is expected to equal the value of thisVal Expected SameValue(«undefined», «
|
||||
built-ins/Array/from/source-array-boundary.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/from/source-object-constructor.js # The value of Array.from.call(Object, []).constructor is expected to equal the value of Object Expect
|
||||
built-ins/Array/isArray/15.4.3.2-2-1.js # TypeError: Con cannot be constructed: user-defined constructors and classes are not supported. Call
|
||||
@@ -73,22 +70,8 @@ built-ins/Array/prototype/every/15.4.4.16-3-6.js # TypeError: Array.prototype.e
|
||||
built-ins/Array/prototype/every/15.4.4.16-3-7.js # TypeError: Array.prototype.every called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/every/15.4.4.16-3-8.js # TypeError: Array.prototype.every called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/every/15.4.4.16-3-9.js # TypeError: Array.prototype.every called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-10.js # [11].every(callbackfn, objArray) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-14.js # [11].every(callbackfn, Math) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-15.js # [11].every(callbackfn, objDate) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-16.js # [11].every(callbackfn, objRegExp) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-17.js # [11].every(callbackfn, JSON) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-18.js # [11].every(callbackfn, objError) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-19.js # [11].every(callbackfn, arg) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-2.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-22.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-23.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-24.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-3.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-4.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-5.js # TypeError: foo cannot be constructed: user-defined constructors and classes are not supported. Call
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-6.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/every/15.4.4.16-5-9.js # [11].every(callbackfn, objFunction) !== true
|
||||
built-ins/Array/prototype/every/15.4.4.16-7-6.js # res Expected SameValue(«true», «false») to be true
|
||||
built-ins/Array/prototype/every/15.4.4.16-7-8.js # TypeError: Array.prototype.every called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/every/15.4.4.16-7-c-i-1.js # TypeError: Array.prototype.every called on incompatible receiver a data object.
|
||||
@@ -168,22 +151,8 @@ built-ins/Array/prototype/filter/15.4.4.20-3-5.js # TypeError: Array.prototype.
|
||||
built-ins/Array/prototype/filter/15.4.4.20-3-6.js # TypeError: Array.prototype.filter called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/filter/15.4.4.20-3-7.js # TypeError: Array.prototype.filter called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/filter/15.4.4.20-3-9.js # TypeError: Array.prototype.filter called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-10.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-14.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-15.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-16.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-17.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-18.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-19.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-2.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-22.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-23.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-24.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-3.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-4.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-5.js # TypeError: foo cannot be constructed: user-defined constructors and classes are not supported. Call
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-6.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/filter/15.4.4.20-5-9.js # newArr[0] Expected SameValue(«undefined», «11») to be true
|
||||
built-ins/Array/prototype/filter/15.4.4.20-6-2.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct
|
||||
built-ins/Array/prototype/filter/15.4.4.20-6-3.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct
|
||||
built-ins/Array/prototype/filter/15.4.4.20-6-4.js # … cannot be constructed: user-defined constructors and classes are not supported. Call it as a funct
|
||||
@@ -224,20 +193,15 @@ built-ins/Array/prototype/filter/15.4.4.20-9-c-iii-5.js # TypeError: Array.prot
|
||||
built-ins/Array/prototype/filter/call-with-boolean.js # TypeError: Array.prototype.filter called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/filter/create-ctor-non-object.js # null value Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/Array/prototype/find/call-with-boolean.js # TypeError: Array.prototype.find called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/find/predicate-call-this-strict.js # Expected SameValue(«undefined», «object») to be true
|
||||
built-ins/Array/prototype/findIndex/call-with-boolean.js # TypeError: Array.prototype.findIndex called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/findIndex/predicate-call-this-strict.js # Expected SameValue(«undefined», «object») to be true
|
||||
built-ins/Array/prototype/findLast/call-with-boolean.js # TypeError: Array.prototype.findLast called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/findLast/maximum-index.js # TypeError: Array.prototype.findLast called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/findLast/predicate-call-this-strict.js # Expected SameValue(«undefined», «object») to be true
|
||||
built-ins/Array/prototype/findLastIndex/call-with-boolean.js # TypeError: Array.prototype.findLastIndex called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/findLastIndex/maximum-index.js # TypeError: Array.prototype.findLastIndex called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/findLastIndex/predicate-call-this-strict.js # Expected SameValue(«undefined», «object») to be true
|
||||
built-ins/Array/prototype/flat/array-like-objects.js # TypeError: Array.prototype.flat called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/flat/call-with-boolean.js # TypeError: Array.prototype.flat called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/flat/non-object-ctor-throws.js # null value Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/Array/prototype/flatMap/call-with-boolean.js # TypeError: Array.prototype.flatMap called on incompatible receiver a boolean.
|
||||
built-ins/Array/prototype/flatMap/thisArg-argument.js # Actual [undefined] and expected ["TestString"] should have the same contents. The value of actual is
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-1-10.js # TypeError: Array.prototype.forEach called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-1-11.js # TypeError: Array.prototype.forEach called on incompatible receiver a Date.
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-1-12.js # TypeError: Array.prototype.forEach called on incompatible receiver a RegExp.
|
||||
@@ -276,22 +240,8 @@ built-ins/Array/prototype/forEach/15.4.4.18-3-5.js # TypeError: Array.prototype
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-3-6.js # TypeError: Array.prototype.forEach called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-3-7.js # TypeError: Array.prototype.forEach called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-3-9.js # TypeError: Array.prototype.forEach called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-10.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-14.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-15.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-16.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-17.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-18.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-19.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-2.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-22.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-23.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-24.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-3.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-4.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-5.js # TypeError: foo cannot be constructed: user-defined constructors and classes are not supported. Call
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-6.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-5-9.js # result !== true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-7-5.js # callCnt Expected SameValue(«4», «5») to be true
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-7-8.js # TypeError: Array.prototype.forEach called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/forEach/15.4.4.18-7-c-i-1.js # TypeError: Array.prototype.forEach called on incompatible receiver a data object.
|
||||
@@ -513,22 +463,8 @@ built-ins/Array/prototype/map/15.4.4.19-3-6.js # TypeError: Array.prototype.map
|
||||
built-ins/Array/prototype/map/15.4.4.19-3-7.js # TypeError: Array.prototype.map called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/map/15.4.4.19-3-8.js # Expected a RangeError but got a TypeError
|
||||
built-ins/Array/prototype/map/15.4.4.19-3-9.js # TypeError: Array.prototype.map called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-10.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-14.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-15.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-16.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-17.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-18.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-19.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-2.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-22.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-23.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-24.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-3.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-4.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-5.js # TypeError: foo cannot be constructed: user-defined constructors and classes are not supported. Call
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-6.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/map/15.4.4.19-5-9.js # testResult[0] Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-8-6.js # resArr[4] Expected SameValue(«undefined», «1») to be true
|
||||
built-ins/Array/prototype/map/15.4.4.19-8-8.js # TypeError: Array.prototype.map called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/map/15.4.4.19-8-c-i-1.js # TypeError: Array.prototype.map called on incompatible receiver a data object.
|
||||
@@ -843,22 +779,8 @@ built-ins/Array/prototype/some/15.4.4.17-3-6.js # TypeError: Array.prototype.so
|
||||
built-ins/Array/prototype/some/15.4.4.17-3-7.js # TypeError: Array.prototype.some called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/some/15.4.4.17-3-8.js # TypeError: Array.prototype.some called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/some/15.4.4.17-3-9.js # TypeError: Array.prototype.some called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-10.js # [11].some(callbackfn, objArray) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-14.js # [11].some(callbackfn, Math) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-15.js # [11].some(callbackfn, objDate) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-16.js # [11].some(callbackfn, objRegExp) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-17.js # [11].some(callbackfn, JSON) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-18.js # [11].some(callbackfn, objError) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-19.js # [11].some(callbackfn, arg) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-2.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-22.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-23.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-24.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-3.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-4.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-5.js # TypeError: foo cannot be constructed: user-defined constructors and classes are not supported. Call
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-6.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/Array/prototype/some/15.4.4.17-5-9.js # [11].some(callbackfn, objFunction) !== true
|
||||
built-ins/Array/prototype/some/15.4.4.17-7-6.js # res Expected SameValue(«false», «true») to be true
|
||||
built-ins/Array/prototype/some/15.4.4.17-7-8.js # TypeError: Array.prototype.some called on incompatible receiver a data object.
|
||||
built-ins/Array/prototype/some/15.4.4.17-7-c-i-1.js # TypeError: Array.prototype.some called on incompatible receiver a data object.
|
||||
@@ -1032,19 +954,15 @@ built-ins/Map/iterable-calls-set.js # `Map.prototype.set` called twice. Expecte
|
||||
built-ins/Map/iterator-close-after-set-failure.js # TimeoutExceeded: Execution timed out after 5000ms.
|
||||
built-ins/Map/iterator-close-failure-after-set-failure.js # TimeoutExceeded: Execution timed out after 5000ms.
|
||||
built-ins/Map/map-iterable-throws-when-set-is-not-callable.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/Map/prototype/forEach/second-parameter-as-callback-context.js # Expected SameValue(«undefined», «object») to be true
|
||||
built-ins/Math/sumPrecise/takes-iterable.js # Expected SameValue(«4», «3») to be true
|
||||
built-ins/Number/prototype/S15.7.3.1_A2_T1.js # Number.prototype.toString() must return "[object Number]" Expected SameValue(«"[object Object]"», «"
|
||||
built-ins/Number/prototype/S15.7.3.1_A2_T2.js # Number.prototype.toString() must return "[object Number]" Expected SameValue(«"[object Object]"», «"
|
||||
built-ins/Number/prototype/S15.7.3.1_A3.js # TypeError: Number.prototype.valueOf requires that '…' be a Number.
|
||||
built-ins/Number/prototype/S15.7.4_A1.js # TypeError: Number.prototype.valueOf requires that '…' be a Number.
|
||||
built-ins/Number/prototype/toExponential/return-abrupt-tointeger-fractiondigits.js # valueOf Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/Number/prototype/toExponential/this-is-0-fractiondigits-is-0.js # TypeError: Number.prototype.toExponential requires that '…' be a Number.
|
||||
built-ins/Number/prototype/toFixed/S15.7.4.5_A1.1_T01.js # TypeError: Number.prototype.toFixed requires that '…' be a Number.
|
||||
built-ins/Number/prototype/toFixed/toFixed-tonumber-throws-typeerror-toprimitive.js # TypeError is thrown when _fractionDigits_ cannot be converted to a primitive Expected a TypeError to
|
||||
built-ins/Number/prototype/toPrecision/return-abrupt-tointeger-precision.js # valueOf Expected a Test262Error but got a TypeError
|
||||
built-ins/Number/prototype/toPrecision/this-is-0-precision-is-1.js # TypeError: Number.prototype.toPrecision requires that '…' be a Number.
|
||||
built-ins/Number/prototype/toString/numeric-literal-tostring-radix-poisoned.js # Expected a Test262Error but got a RangeError
|
||||
built-ins/Object/S15.2.1.1_A2_T1.js # TypeError: Object(boolean) wrapper objects are not supported; use the primitive value directly.
|
||||
built-ins/Object/S15.2.1.1_A2_T13.js # TypeError: Object(boolean) wrapper objects are not supported; use the primitive value directly.
|
||||
built-ins/Object/S15.2.1.1_A2_T2.js # TypeError: Object(number) wrapper objects are not supported; use the primitive value directly.
|
||||
@@ -1393,7 +1311,6 @@ built-ins/RegExp/prototype/exec/success-lastindex-access.js # Expected SameValu
|
||||
built-ins/RegExp/prototype/test/S15.10.6.3_A1_T7.js # The value of e is expected to be "intostr" Expected SameValue(«Test262Error: #1.1: /[a-z]/.test({toS
|
||||
built-ins/RegExp/prototype/test/S15.10.6.3_A1_T8.js # The value of e is expected to be "invalof" Expected SameValue(«Test262Error: #1.1: /[a-z]/.test({toS
|
||||
built-ins/Set/prototype/difference/size-is-a-number.js # TypeError: BigInt literals are not supported.
|
||||
built-ins/Set/prototype/forEach/this-arg-explicit.js # `this` is `thisArg` Expected SameValue(«undefined», «object») to be true
|
||||
built-ins/Set/prototype/intersection/size-is-a-number.js # TypeError: BigInt literals are not supported.
|
||||
built-ins/Set/prototype/isDisjointFrom/set-like-iter-return.js # Expected SameValue(«4», «1») to be true
|
||||
built-ins/Set/prototype/isDisjointFrom/size-is-a-number.js # TypeError: BigInt literals are not supported.
|
||||
@@ -1415,104 +1332,16 @@ built-ins/String/prototype/S15.5.4_A1.js # #1: delete String.prototype.toString
|
||||
built-ins/String/prototype/S15.5.4_A2.js # #1: String.prototype =="". Actual: String.prototype ==[object Object]
|
||||
built-ins/String/prototype/S15.5.4_A3.js # #2: delete String.prototype.toString; String.prototype.toString() == "[object "+"String"+"]". Actual
|
||||
built-ins/String/prototype/Symbol.iterator/this-val-to-str-err.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/at/index-argument-tointeger.js # TypeError: String.at expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/at/index-non-numeric-argument-tointeger.js # TypeError: String.at expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/charAt/S15.5.4.4_A1_T10.js # TypeError: String.charAt expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/charAt/S15.5.4.4_A2.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/String/prototype/charAt/S15.5.4.4_A5.js # #1.1: Exception === '…'. Actual: exception ===[object Error]
|
||||
built-ins/String/prototype/charAt/pos-coerce-err.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/charCodeAt/S15.5.4.5_A1_T10.js # TypeError: String.charCodeAt expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/charCodeAt/S15.5.4.5_A2.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/String/prototype/charCodeAt/S15.5.4.5_A4.js # #1.1: Exception === '…'. Actual: exception ===[object Error]
|
||||
built-ins/String/prototype/charCodeAt/pos-coerce-err.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/codePointAt/return-abrupt-from-object-pos-to-integer.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/codePointAt/return-abrupt-from-this.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/concat/S15.5.4.6_A1_T10.js # TypeError: String.concat expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/concat/S15.5.4.6_A4_T1.js # #1: var x; __instance = {toString:function(){return "one"}}; __instance.concat = String.prototype.co
|
||||
built-ins/String/prototype/concat/S15.5.4.6_A4_T2.js # #1: e === "intostring". Actual: TypeError: String.concat expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/constructor/S15.5.4.1_A1_T2.js # TypeError: new __constr(...) is not supported; call __constr(...) without new instead.
|
||||
built-ins/String/prototype/endsWith/return-abrupt-from-position.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/endsWith/return-abrupt-from-searchstring.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/endsWith/return-abrupt-from-this.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/includes/return-abrupt-from-position.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/includes/return-abrupt-from-searchstring.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/includes/return-abrupt-from-this.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/indexOf/S15.5.4.7_A1_T10.js # TypeError: String.indexOf expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/indexOf/S15.5.4.7_A4_T1.js # #1.1: Exception === "intointeger". Actual: TypeError: String.indexOf expects argument 1 to be a data
|
||||
built-ins/String/prototype/indexOf/S15.5.4.7_A4_T3.js # TypeError: String.indexOf expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/isWellFormed/to-string.js # coerces the receiver to a string Expected a Test262Error to be thrown but no exception was thrown at
|
||||
built-ins/String/prototype/lastIndexOf/S15.5.4.8_A1_T10.js # TypeError: String.lastIndexOf expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/lastIndexOf/S15.5.4.8_A4_T1.js # #1.1: Exception === "intointeger". Actual: TypeError: String.lastIndexOf expects argument 1 to be a
|
||||
built-ins/String/prototype/lastIndexOf/S15.5.4.8_A4_T3.js # TypeError: String.lastIndexOf expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/match/S15.5.4.10_A1_T10.js # TypeError: Cannot read properties of null (reading '…').
|
||||
built-ins/String/prototype/match/S15.5.4.10_A1_T11.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/match/S15.5.4.10_A1_T13.js # TypeError: Cannot read properties of null (reading '…').
|
||||
built-ins/String/prototype/normalize/return-abrupt-from-form.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/normalize/return-abrupt-from-this.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/normalize/return-normalized-string-from-coerced-form.js # TypeError: String.normalize expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/padEnd/exception-not-object-coercible.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/padEnd/observable-operations.js # TypeError: String.padEnd expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/padStart/exception-not-object-coercible.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/padStart/observable-operations.js # TypeError: String.padStart expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/repeat/count-coerced-to-zero-returns-empty-string.js # RangeError: String.repeat expects a finite non-negative count.
|
||||
built-ins/String/prototype/repeat/return-abrupt-from-count.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/repeat/return-abrupt-from-this.js # Expected a Test262Error but got a RangeError
|
||||
built-ins/String/prototype/replace/S15.5.4.11_A1_T10.js # TypeError: String.replace expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/replace/S15.5.4.11_A1_T11.js # #1.1: Exception === "insearchValue". Actual: TypeError: String.replace expects argument 1 to be a da
|
||||
built-ins/String/prototype/replace/S15.5.4.11_A1_T13.js # #1.1: Exception === "inreplaceValue". Actual: TypeError: String.replace expects argument 1 to be a d
|
||||
built-ins/String/prototype/replace/replaceValue-evaluation-order-regexp-object.js # Expected SameValue(«0», «1») to be true
|
||||
built-ins/String/prototype/replace/replaceValue-evaluation-order.js # TypeError: String.replace expects argument 2 to be a data value.
|
||||
built-ins/String/prototype/replace/tostring-this-throws-toprimitive.js # String.prototype.replace should throw a TypeError in its ToPrimitive step. Expected a TypeError to b
|
||||
built-ins/String/prototype/search/S15.5.4.12_A1_T10.js # #1: var __obj = {toString:function(){return "AB";}}; var __str = "ssABBABABAB"; __str.search(__obj)
|
||||
built-ins/String/prototype/search/S15.5.4.12_A1_T11.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/search/S15.5.4.12_A1_T13.js # #1: var __obj = {toString:function(){return {};},valueOf:function(){return 1;}}; "ABB\u0041B\u0031AB
|
||||
built-ins/String/prototype/slice/S15.5.4.13_A1_T10.js # TypeError: String.slice expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/slice/S15.5.4.13_A1_T11.js # #1.1: Exception === "instart". Actual: TypeError: String.slice expects argument 1 to be a data value
|
||||
built-ins/String/prototype/slice/S15.5.4.13_A1_T13.js # #1.1: Exception === "inend". Actual: TypeError: String.slice expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/slice/S15.5.4.13_A3_T3.js # #1: __instance = function(){}; __instance.slice = String.prototype.slice; __instance.slice(-Infinity
|
||||
built-ins/String/prototype/slice/S15.5.4.13_A3_T4.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/String/prototype/slice/this-value-tostring-throws-toprimitive.js # String.prototype.slice throws in its toprimitive step. Expected a TypeError to be thrown but no exce
|
||||
built-ins/String/prototype/split/instance-is-function.js # __split[0].substring(0, 8) must return "function" Expected SameValue(«"[object "», «"function"») to
|
||||
built-ins/String/prototype/split/instance-is-math.js # The value of __split[0] is "[object Math]" Expected SameValue(«"[object Object]"», «"[object Math]"»
|
||||
built-ins/String/prototype/split/separator-override-tostring-limit-override-valueof-throws.js # The value of `e` is "intointeger" Expected SameValue(«TypeError: String.split expects argument 2 to
|
||||
built-ins/String/prototype/split/separator-override-tostring-limit-override-valueof-tostring.js # TypeError: String.split expects argument 2 to be a data value.
|
||||
built-ins/String/prototype/split/separator-override-tostring-limit-override-valueof.js # TypeError: String.split expects argument 2 to be a data value.
|
||||
built-ins/String/prototype/split/separator-tostring-error.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/String/prototype/split/separator-undef-limit-custom.js # TypeError: String.split expects argument 2 to be a data value.
|
||||
built-ins/String/prototype/split/separator-undef-limit-zero.js # TypeError: String.split expects argument 2 to be a data value.
|
||||
built-ins/String/prototype/split/transferred-to-custom.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/String/prototype/split/valueOf-is-called-for-limit-argument.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/startsWith/return-abrupt-from-position.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/startsWith/return-abrupt-from-searchstring.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/String/prototype/startsWith/return-abrupt-from-this.js # Expected a Test262Error to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/substring/S15.5.4.15_A1_T10.js # TypeError: String.substring expects argument 1 to be a data value.
|
||||
built-ins/String/prototype/substring/S15.5.4.15_A1_T11.js # #1.1: Exception === "instart". Actual: TypeError: String.substring expects argument 1 to be a data v
|
||||
built-ins/String/prototype/substring/S15.5.4.15_A1_T13.js # #1.1: Exception === "inend". Actual: TypeError: String.substring expects argument 1 to be a data val
|
||||
built-ins/String/prototype/substring/S15.5.4.15_A3_T10.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/String/prototype/substring/S15.5.4.15_A3_T7.js # #1: __instance = function(){}; __instance.substring = String.prototype.substring; __instance.substr
|
||||
built-ins/String/prototype/substring/this-value-tostring-throws-toprimitive.js # String.prototype.substring throws in its toprimitive step. Expected a TypeError to be thrown but no
|
||||
built-ins/String/prototype/toLocaleLowerCase/S15.5.4.17_A1_T10.js # #1: var __obj = {toString:function(){return "AB";}}; __obj.toLocaleLowerCase = String.prototype.toLo
|
||||
built-ins/String/prototype/toLocaleLowerCase/S15.5.4.17_A1_T11.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toLocaleLowerCase/S15.5.4.17_A1_T12.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toLocaleLowerCase/S15.5.4.17_A1_T13.js # #1: var __obj = {toString:function(){return {};},valueOf:function(){return 1;}}; __obj.toLocaleLower
|
||||
built-ins/String/prototype/toLocaleUpperCase/S15.5.4.19_A1_T10.js # #1: var __obj = {toString:function(){return "Ab";}}; __obj.toLocaleUpperCase = String.prototype.toLo
|
||||
built-ins/String/prototype/toLocaleUpperCase/S15.5.4.19_A1_T11.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toLocaleUpperCase/S15.5.4.19_A1_T12.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toLocaleUpperCase/S15.5.4.19_A1_T13.js # #1: var __obj = {toString:function(){return {};},valueOf:function(){return 1;}}; __obj.toLocaleUpper
|
||||
built-ins/String/prototype/toLowerCase/S15.5.4.16_A1_T10.js # #1: var __obj = {toString:function(){return "AB";}}; __obj.toLowerCase = String.prototype.toLowerCas
|
||||
built-ins/String/prototype/toLowerCase/S15.5.4.16_A1_T11.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toLowerCase/S15.5.4.16_A1_T12.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toLowerCase/S15.5.4.16_A1_T13.js # #1: var __obj = {toString:function(){return {};},valueOf:function(){return 1;}}; __obj.toLowerCase =
|
||||
built-ins/String/prototype/toLowerCase/this-value-tostring-throws-toprimitive.js # String.prototype.toLowerCase throws in its toprimitive step. Expected a TypeError to be thrown but n
|
||||
built-ins/String/prototype/toUpperCase/S15.5.4.18_A1_T10.js # #1: var __obj = {toString:function(){return "Ab";}}; __obj.toUpperCase = String.prototype.toUpperCas
|
||||
built-ins/String/prototype/toUpperCase/S15.5.4.18_A1_T11.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toUpperCase/S15.5.4.18_A1_T12.js # #1.1: Exception === "intostr". Actual: [object Error]
|
||||
built-ins/String/prototype/toUpperCase/S15.5.4.18_A1_T13.js # #1: var __obj = {toString:function(){return {};},valueOf:function(){return 1;}}; __obj.toUpperCase =
|
||||
built-ins/String/prototype/toWellFormed/to-string.js # coerces the receiver to a string Expected a Test262Error to be thrown but no exception was thrown at
|
||||
built-ins/String/prototype/trim/15.5.4.20-2-38.js # String.prototype.trim.call(obj) Expected SameValue(«"[object Object]"», «"abc"») to be true
|
||||
built-ins/String/prototype/trim/15.5.4.20-2-40.js # String.prototype.trim.call(obj) Expected SameValue(«"[object Object]"», «"abc"») to be true
|
||||
built-ins/String/prototype/trim/15.5.4.20-2-41.js # String.prototype.trim.call(obj) Expected SameValue(«"[object Object]"», «"abc"») to be true
|
||||
built-ins/String/prototype/trim/15.5.4.20-2-42.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/String/prototype/trim/15.5.4.20-2-43.js # TypeError: Con cannot be constructed: user-defined constructors and classes are not supported. Call
|
||||
built-ins/decodeURI/S15.1.3.1_A1.10_T1.js # Execution exceeded the maximum nesting depth.
|
||||
built-ins/decodeURI/S15.1.3.1_A1.11_T1.js # Execution exceeded the maximum nesting depth.
|
||||
@@ -1686,7 +1515,6 @@ language/expressions/new/spread-sngl-iter.js # TypeError: The called value cann
|
||||
language/expressions/new/spread-sngl-literal.js # TypeError: The called value cannot be constructed: user-defined constructors and classes are not sup
|
||||
language/expressions/object/__proto__-duplicate-computed.js # prototype is defined Expected SameValue(«object», «object») to be true
|
||||
language/expressions/object/accessor-name-literal-string-line-continuation.js # TypeError: Only init object properties are supported.
|
||||
language/expressions/object/computed-property-name-topropertykey-before-value-evaluation.js # Expected SameValue(«undefined», «"ok"») to be true
|
||||
language/expressions/object/dstr/async-gen-meth-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
language/expressions/object/dstr/async-gen-meth-ary-ptrn-elem-id-iter-val-array-prototype.js # Expected SameValue(«3», «42») to be true
|
||||
language/expressions/object/dstr/async-gen-meth-dflt-ary-init-iter-get-err-array-prototype.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
@@ -1867,3 +1695,9 @@ language/types/object/S8.6.2_A5_T3.js # TypeError: Cannot read properties of un
|
||||
language/types/object/S8.6.2_A5_T4.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
language/types/reference/S8.7.1_A1.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
language/types/reference/S8.7.2_A3.js # TypeError: Cannot read properties of undefined (reading '…').
|
||||
built-ins/WeakMap/iterable-with-object-keys.js # Called WeakMap#set for each object Expected SameValue(«0», «2») to be true
|
||||
built-ins/WeakMap/iterator-close-after-set-failure.js # Expected a Test262Error but got a TypeError
|
||||
built-ins/WeakMap/set-not-callable-throws.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/WeakSet/add-not-callable-throws.js # Expected a TypeError to be thrown but no exception was thrown at all
|
||||
built-ins/WeakSet/iterable-with-object-values.js # Called WeakSet#add for each object Expected SameValue(«0», «2») to be true
|
||||
built-ins/WeakSet/iterator-close-after-add-failure.js # Expected a Test262Error but got a TypeError
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -13,6 +13,7 @@ import { Provider } from "../../provider.js"
|
||||
import { WebSearch } from "../../websearch.js"
|
||||
import { ConfigPolicy } from "@opencode/schema/config/policy"
|
||||
import { ConfigProvider } from "@opencode/schema/config/provider"
|
||||
import { Mcp } from "@opencode/schema/mcp"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
|
||||
const defaultServer = "https://opencode.ai/console"
|
||||
@@ -23,6 +24,15 @@ const RemoteResponse = Schema.Struct({
|
||||
websearch: Schema.Struct({
|
||||
providerID: WebSearch.ID,
|
||||
}).pipe(Schema.optional),
|
||||
// MCP servers by name, in the same shape as a remote server in local config. Only remote servers are
|
||||
// accepted so the Console can never make the client run a command. `auth: "console"` asks the client
|
||||
// to attach its own Console credential to that server's requests.
|
||||
mcp: Schema.Struct({
|
||||
servers: Schema.Record(
|
||||
Schema.String,
|
||||
Schema.Struct({ ...Mcp.RemoteConfig.fields, auth: Schema.Literal("console").pipe(Schema.optional) }),
|
||||
),
|
||||
}).pipe(Schema.optional),
|
||||
// Organization policy compiled for the authenticated caller; omitted when there is none.
|
||||
experimental: Schema.Struct({
|
||||
policies: Schema.Array(ConfigPolicy.Info).pipe(Schema.optional),
|
||||
@@ -134,19 +144,25 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
config: typeof RemoteResponse.Type | undefined
|
||||
connection: ActiveConnection
|
||||
organization: string | undefined
|
||||
} = { config: undefined, connection: undefined, organization: undefined }
|
||||
// Console MCP servers carry the credential in their headers, so a rotated token changes the snapshot.
|
||||
mcp:
|
||||
| { servers: NonNullable<typeof RemoteResponse.Type.mcp>["servers"]; headers: Record<string, string> }
|
||||
| undefined
|
||||
} = { config: undefined, connection: undefined, organization: undefined, mcp: undefined }
|
||||
|
||||
const load = Effect.fn("OpencodePlugin.load")(function* () {
|
||||
const connection = yield* ctx.integration.connection.active("opencode")
|
||||
if (!connection) return { config: undefined, connection, organization: undefined }
|
||||
if (!connection) return { config: undefined, connection, organization: undefined, mcp: undefined }
|
||||
return yield* ctx.integration.connection.resolve(connection).pipe(
|
||||
Effect.flatMap((credential) => {
|
||||
if (!credential) return Effect.succeed({ config: undefined, connection, organization: undefined })
|
||||
if (!credential)
|
||||
return Effect.succeed({ config: undefined, connection, organization: undefined, mcp: undefined })
|
||||
return fetchConfig(http, credential).pipe(
|
||||
Effect.map((config) => ({
|
||||
config,
|
||||
connection,
|
||||
organization: typeof credential.metadata?.orgName === "string" ? credential.metadata.orgName : undefined,
|
||||
mcp: config?.mcp && { servers: config.mcp.servers, headers: credentialHeaders(credential) },
|
||||
})),
|
||||
)
|
||||
}),
|
||||
@@ -156,8 +172,8 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
// would lift organization policy while personal credentials keep working.
|
||||
Effect.as(
|
||||
IntegrationConnection.key(connection) === IntegrationConnection.key(snapshot.connection)
|
||||
? { config: snapshot.config, connection, organization: snapshot.organization }
|
||||
: { config: undefined, connection, organization: undefined },
|
||||
? { config: snapshot.config, connection, organization: snapshot.organization, mcp: snapshot.mcp }
|
||||
: { config: undefined, connection, organization: undefined, mcp: undefined },
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -337,10 +353,24 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
editor.default.set(descriptor.providerID)
|
||||
})
|
||||
|
||||
yield* ctx.mcp.transform((editor) => {
|
||||
const mcp = snapshot.mcp
|
||||
if (!mcp) return
|
||||
for (const [name, server] of Object.entries(mcp.servers)) {
|
||||
// A server the user configured under the same name wins.
|
||||
if (editor.get(name)) continue
|
||||
const { auth, ...config } = server
|
||||
editor.set(name, auth === "console" ? { ...config, headers: { ...config.headers, ...mcp.headers } } : config)
|
||||
}
|
||||
})
|
||||
|
||||
const apply = Effect.fn("OpencodePlugin.apply")(function* (next: typeof snapshot) {
|
||||
snapshot = next
|
||||
yield* publish(next)
|
||||
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload()], { concurrency: 2, discard: true })
|
||||
yield* Effect.all([ctx.provider.reload(), ctx.websearch.reload(), ctx.mcp.reload()], {
|
||||
concurrency: 3,
|
||||
discard: true,
|
||||
})
|
||||
})
|
||||
const refresh = () => loading.withPermit(load().pipe(Effect.andThen(apply)))
|
||||
yield* bus.subscribe(Credential.Event.Switched).pipe(
|
||||
@@ -364,15 +394,11 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
|
||||
})
|
||||
|
||||
function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
const metadata = value.metadata
|
||||
const orgID = typeof metadata?.orgID === "string" ? metadata.orgID : undefined
|
||||
const token = value.type === "oauth" ? value.access : value.key
|
||||
return http
|
||||
.execute(
|
||||
HttpClientRequest.get(`${serverUrl(value)}/api/v2/config`).pipe(
|
||||
HttpClientRequest.acceptJson,
|
||||
HttpClientRequest.bearerToken(token),
|
||||
HttpClientRequest.setHeaders(orgID ? { "x-org-id": orgID } : {}),
|
||||
HttpClientRequest.setHeaders(credentialHeaders(value)),
|
||||
),
|
||||
)
|
||||
.pipe(
|
||||
@@ -385,6 +411,14 @@ function fetchConfig(http: HttpClient.HttpClient, value: Credential.Value) {
|
||||
)
|
||||
}
|
||||
|
||||
function credentialHeaders(value: Credential.Value): Record<string, string> {
|
||||
const orgID = value.metadata?.orgID
|
||||
return {
|
||||
authorization: `Bearer ${value.type === "oauth" ? value.access : value.key}`,
|
||||
...(typeof orgID === "string" ? { "x-org-id": orgID } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
function serverUrl(value: Credential.Value) {
|
||||
return typeof value.metadata?.server === "string" ? value.metadata.server : defaultServer
|
||||
}
|
||||
|
||||
@@ -5,26 +5,26 @@ import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { location } from "./location"
|
||||
|
||||
export const emptyMcpLayer = Layer.succeed(
|
||||
Mcp.Service,
|
||||
Mcp.Service.of({
|
||||
transform: () => Effect.die("unused mcp.transform"),
|
||||
reload: () => Effect.die("unused mcp.reload"),
|
||||
servers: () => Effect.succeed([]),
|
||||
add: () => Effect.die("unused mcp.add"),
|
||||
connect: () => Effect.die("unused mcp.connect"),
|
||||
disconnect: () => Effect.die("unused mcp.disconnect"),
|
||||
remove: () => Effect.die("unused mcp.remove"),
|
||||
tools: () => Effect.succeed([]),
|
||||
callTool: () => Effect.die("unused mcp.callTool"),
|
||||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.undefined,
|
||||
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
resources: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.undefined,
|
||||
}),
|
||||
)
|
||||
// Plugins may register MCP transforms at startup; with no servers there is nothing to rebuild.
|
||||
export const emptyMcp = Mcp.Service.of({
|
||||
transform: () => Effect.succeed({ dispose: Effect.void }),
|
||||
reload: () => Effect.void,
|
||||
servers: () => Effect.succeed([]),
|
||||
add: () => Effect.die("unused mcp.add"),
|
||||
connect: () => Effect.die("unused mcp.connect"),
|
||||
disconnect: () => Effect.die("unused mcp.disconnect"),
|
||||
remove: () => Effect.die("unused mcp.remove"),
|
||||
tools: () => Effect.succeed([]),
|
||||
callTool: () => Effect.die("unused mcp.callTool"),
|
||||
instructions: () => Effect.succeed([]),
|
||||
prompts: () => Effect.succeed([]),
|
||||
prompt: () => Effect.undefined,
|
||||
resourceCatalog: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
resources: () => Effect.succeed(Mcp.ResourceCatalog.make({ resources: [], templates: [] })),
|
||||
readResource: () => Effect.undefined,
|
||||
})
|
||||
|
||||
export const emptyMcpLayer = Layer.succeed(Mcp.Service, emptyMcp)
|
||||
|
||||
export const emptyConfigLayer = Config.testLayer()
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import { ConfigPolicyPlugin } from "@opencode/core/config/plugin/policy"
|
||||
import { Credential } from "@opencode/core/credential"
|
||||
import { Integration } from "@opencode/core/integration"
|
||||
import { ManagedPolicy } from "@opencode/core/managed-policy"
|
||||
import { Mcp } from "@opencode/core/mcp/index"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { ModelResolver } from "@opencode/core/model-resolver"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
@@ -20,6 +21,7 @@ import { OpencodePlugin } from "@opencode/core/plugin/provider/opencode"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { WebSearch } from "@opencode/core/websearch"
|
||||
import { withEnv } from "../fixture/env"
|
||||
import { emptyMcp } from "../fixture/mcp"
|
||||
import { drain } from "../lib/clock"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { PluginTestLayer } from "./fixture"
|
||||
@@ -642,6 +644,110 @@ describe("OpencodePlugin", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("registers the Console's MCP servers as sent, attaching the credential only where asked", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => {
|
||||
const state = { advertised: true }
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch: (request) =>
|
||||
Response.json({
|
||||
providers: {},
|
||||
...(state.advertised
|
||||
? {
|
||||
mcp: {
|
||||
servers: {
|
||||
opencode_console: {
|
||||
type: "remote",
|
||||
url: `${new URL(request.url).origin}/console/mcp`,
|
||||
headers: { "x-client": "opencode", authorization: "Bearer forged" },
|
||||
oauth: false,
|
||||
auth: "console",
|
||||
},
|
||||
opencode_console_oauth: {
|
||||
type: "remote",
|
||||
url: `${new URL(request.url).origin}/console/oauth/mcp`,
|
||||
oauth: { scope: "workspace" },
|
||||
timeout: { startup: 5000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}),
|
||||
})
|
||||
return { server, state }
|
||||
}),
|
||||
({ server, state }) =>
|
||||
Effect.gen(function* () {
|
||||
const credentials = yield* Credential.Service
|
||||
const transforms: Array<(editor: Mcp.Editor) => void> = []
|
||||
const reloads = { count: 0 }
|
||||
const servers = () => {
|
||||
const configured = new Map<string, unknown>()
|
||||
transforms.forEach((transform) =>
|
||||
transform({
|
||||
list: () => [],
|
||||
get: (name) => (configured.has(name) ? { type: "remote", url: "user" } : undefined),
|
||||
set: (name, config) => configured.set(name, config),
|
||||
update: () => {},
|
||||
remove: (name) => configured.delete(name),
|
||||
}),
|
||||
)
|
||||
return Object.fromEntries(configured)
|
||||
}
|
||||
yield* credentials.create({
|
||||
integrationID: Integration.ID.make("opencode"),
|
||||
value: Credential.Key.make({
|
||||
type: "key",
|
||||
key: "secret",
|
||||
metadata: { server: server.url.origin, orgID: "org-a" },
|
||||
}),
|
||||
})
|
||||
yield* addPlugin().pipe(
|
||||
Effect.provideService(
|
||||
Mcp.Service,
|
||||
Mcp.Service.of({
|
||||
...emptyMcp,
|
||||
transform: (transform) =>
|
||||
Effect.sync(() => {
|
||||
transforms.push(transform)
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
reload: () =>
|
||||
Effect.sync(() => {
|
||||
reloads.count++
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
yield* drain
|
||||
|
||||
expect(servers()).toEqual({
|
||||
opencode_console: {
|
||||
type: "remote",
|
||||
url: `${server.url.origin}/console/mcp`,
|
||||
headers: { "x-client": "opencode", authorization: "Bearer secret", "x-org-id": "org-a" },
|
||||
oauth: false,
|
||||
},
|
||||
opencode_console_oauth: {
|
||||
type: "remote",
|
||||
url: `${server.url.origin}/console/oauth/mcp`,
|
||||
oauth: { scope: "workspace" },
|
||||
timeout: { startup: 5000 },
|
||||
},
|
||||
})
|
||||
|
||||
state.advertised = false
|
||||
yield* TestClock.adjust("1 minute")
|
||||
yield* drain
|
||||
expect(servers()).toEqual({})
|
||||
expect(reloads.count).toBe(1)
|
||||
}),
|
||||
({ server }) => Effect.promise(() => server.stop(true)),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enforces organization policy statements from the Console", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() =>
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"@opentui/core": ">=0.5.12",
|
||||
"@opentui/solid": ">=0.5.12",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
@@ -1,31 +1,61 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
import { DiffRenderable, LineNumberRenderable, type ColorInput } from "@opentui/core"
|
||||
import {
|
||||
BoxRenderable,
|
||||
CodeRenderable,
|
||||
DiffRenderable,
|
||||
getTreeSitterClient,
|
||||
LineNumberRenderable,
|
||||
type ColorInput,
|
||||
type OnHighlightCallback,
|
||||
type Renderable,
|
||||
type ScrollBoxRenderable,
|
||||
type SimpleHighlight,
|
||||
} from "@opentui/core"
|
||||
import type { JSX } from "@opentui/solid"
|
||||
import { createMemo, For, Show, splitProps } from "solid-js"
|
||||
import { splitPatchHunks } from "../util/diff"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { batch, createMemo, createSignal, For, onCleanup, Show, splitProps } from "solid-js"
|
||||
import { splitAddedPatch, splitPatchHunks, type AddedPatchChunk } from "../util/diff"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
|
||||
export interface PatchDiffRef {
|
||||
readonly hunks: () => readonly DiffRenderable[]
|
||||
readonly hunks: () => readonly (DiffRenderable | BoxRenderable)[]
|
||||
}
|
||||
|
||||
// Smaller patches render fine as a single DiffRenderable; only split files large enough to stall the TUI.
|
||||
const VIRTUAL_MIN_LINES = 3000
|
||||
const VIRTUAL_CHUNK_LINES = 128
|
||||
|
||||
type Props = Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref"> & {
|
||||
diff: string
|
||||
hunkFg: ColorInput
|
||||
lineNumberBg: ColorInput
|
||||
ref?: (value: PatchDiffRef) => void
|
||||
scroll?: () => ScrollBoxRenderable | undefined
|
||||
}
|
||||
|
||||
export function PatchDiff(props: Props) {
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref"])
|
||||
const [local, diffProps] = splitProps(props, ["diff", "hunkFg", "lineNumberBg", "ref", "scroll"])
|
||||
const hunks = createMemo(() => splitPatchHunks(local.diff))
|
||||
const chunks = createMemo(() => {
|
||||
if (!local.scroll) return
|
||||
const result = splitAddedPatch(local.diff, VIRTUAL_CHUNK_LINES)
|
||||
return result && lineCount(result) > VIRTUAL_MIN_LINES ? result : undefined
|
||||
})
|
||||
// Virtual chunks mount independently, so size the gutter for the whole file rather than the mounted chunks.
|
||||
const minDigits = createMemo(() => {
|
||||
const items = chunks()
|
||||
return items ? String(lineCount(items)).length : 0
|
||||
})
|
||||
const nodes = new Map<number, DiffRenderable>()
|
||||
let virtualRoot: BoxRenderable | undefined
|
||||
local.ref?.({
|
||||
hunks: () =>
|
||||
[...nodes.entries()]
|
||||
hunks: () => {
|
||||
if (chunks()) return virtualRoot && !virtualRoot.isDestroyed ? [virtualRoot] : []
|
||||
return [...nodes.entries()]
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, node]) => node)
|
||||
.filter((node) => !node.isDestroyed),
|
||||
.filter((node) => !node.isDestroyed)
|
||||
},
|
||||
})
|
||||
const syncGutters = (attempt = 0) => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -44,10 +74,11 @@ export function PatchDiff(props: Props) {
|
||||
const maxAfter = Math.max(...after)
|
||||
if (!maxDigits && attempt < 2) return syncGutters(attempt + 1)
|
||||
if (!maxDigits) return
|
||||
const width = Math.max(maxDigits, minDigits())
|
||||
sides.forEach((side) => {
|
||||
const index = sides.indexOf(side)
|
||||
const signs = new Map([...side.getLineSigns()].filter(([line]) => line >= 0))
|
||||
signs.set(-1, { after: " ".repeat(maxAfter + maxDigits - digits[index]) })
|
||||
signs.set(-1, { after: " ".repeat(maxAfter + width - digits[index]) })
|
||||
side.setLineNumbers(lineNumbers[index])
|
||||
side.setLineSigns(signs)
|
||||
})
|
||||
@@ -55,29 +86,143 @@ export function PatchDiff(props: Props) {
|
||||
}
|
||||
const register = (index: number, node: DiffRenderable) => {
|
||||
nodes.set(index, node)
|
||||
onCleanup(() => nodes.delete(index))
|
||||
syncGutters()
|
||||
}
|
||||
|
||||
return (
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
|
||||
<text fg={local.hunkFg} bg={local.lineNumberBg}>
|
||||
{` ${hunk.header ?? ""}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<diff
|
||||
{...diffProps}
|
||||
ref={(node: DiffRenderable) => register(index(), node)}
|
||||
diff={hunk.patch}
|
||||
minHeight={hunk.rows}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
/>
|
||||
</>
|
||||
<Show
|
||||
when={chunks()}
|
||||
fallback={
|
||||
<For each={hunks()}>
|
||||
{(hunk, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<box width="100%" height={1} backgroundColor={local.lineNumberBg}>
|
||||
<text fg={local.hunkFg} bg={local.lineNumberBg}>
|
||||
{` ${hunk.header ?? ""}`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<diff
|
||||
{...diffProps}
|
||||
ref={(node: DiffRenderable) => register(index(), node)}
|
||||
diff={hunk.patch}
|
||||
minHeight={hunk.rows}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
}
|
||||
>
|
||||
{(items) => (
|
||||
<VirtualAddedPatch
|
||||
chunks={items()}
|
||||
scroll={local.scroll!}
|
||||
diffProps={diffProps}
|
||||
lineNumberBg={local.lineNumberBg}
|
||||
register={register}
|
||||
registerRoot={(root) => (virtualRoot = root)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// Chunks render without wrapping so each one is exactly `rows` tall. Offscreen chunks become fixed-height
|
||||
// placeholders, and the chunks overlapping the viewport (plus one on each side) follow from the scroll offset.
|
||||
function VirtualAddedPatch(props: {
|
||||
chunks: readonly AddedPatchChunk[]
|
||||
scroll: () => ScrollBoxRenderable | undefined
|
||||
diffProps: Omit<JSX.IntrinsicElements["diff"], "diff" | "lineNumberBg" | "ref">
|
||||
lineNumberBg: ColorInput
|
||||
register: (index: number, node: DiffRenderable) => void
|
||||
registerRoot: (root: BoxRenderable) => void
|
||||
}) {
|
||||
const renderer = useRenderer()
|
||||
const [first, setFirst] = createSignal(0)
|
||||
const [last, setLast] = createSignal(0)
|
||||
// A chunk is not valid source on its own (a slice of a JSON object parses as an error), so highlight
|
||||
// the whole file once and give each chunk its slice of the result.
|
||||
const contents = createMemo(() => props.chunks.map((chunk) => chunk.lines.map((line) => line.slice(1)).join("\n")))
|
||||
const offsets = createMemo(() =>
|
||||
contents().map((_, index, all) => all.slice(0, index).reduce((sum, content) => sum + content.length + 1, 0)),
|
||||
)
|
||||
const fileHighlights = createMemo(() => {
|
||||
const filetype = props.diffProps.filetype
|
||||
if (!filetype) return
|
||||
return (
|
||||
getTreeSitterClient()
|
||||
.highlightOnce(contents().join("\n"), filetype)
|
||||
.then((result) => result.highlights)
|
||||
// Rejects when the renderer tears down the client mid-parse; chunks then keep their own highlights.
|
||||
.catch(() => undefined)
|
||||
)
|
||||
})
|
||||
const chunkHighlights =
|
||||
(index: number): OnHighlightCallback =>
|
||||
async () => {
|
||||
const all = await fileHighlights()
|
||||
if (!all) return
|
||||
const start = offsets()[index]
|
||||
const end = start + contents()[index].length
|
||||
return all.flatMap((highlight): SimpleHighlight[] =>
|
||||
highlight[0] < end && highlight[1] > start
|
||||
? [[Math.max(highlight[0], start) - start, Math.min(highlight[1], end) - start, highlight[2], highlight[3]]]
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<box
|
||||
width="100%"
|
||||
ref={(root: BoxRenderable) => {
|
||||
props.registerRoot(root)
|
||||
root.onLifecyclePass = () => {
|
||||
const scroll = props.scroll()
|
||||
if (!scroll) return
|
||||
// ScrollBox's scroll position is not a Solid signal; observe it during the render pass.
|
||||
const top = scroll.scrollTop - (root.y - scroll.content.y)
|
||||
batch(() => {
|
||||
setFirst(Math.floor(top / VIRTUAL_CHUNK_LINES))
|
||||
setLast(Math.floor((top + scroll.viewport.height) / VIRTUAL_CHUNK_LINES))
|
||||
})
|
||||
}
|
||||
renderer.registerLifecyclePass(root)
|
||||
onCleanup(() => renderer.unregisterLifecyclePass(root))
|
||||
}}
|
||||
>
|
||||
<For each={props.chunks}>
|
||||
{(chunk, index) => (
|
||||
<Show when={index() >= first() - 1 && index() <= last() + 1} fallback={<box height={chunk.rows} />}>
|
||||
<diff
|
||||
{...props.diffProps}
|
||||
ref={(node: DiffRenderable) => {
|
||||
props.register(index(), node)
|
||||
// DiffRenderable creates its CodeRenderable after ref runs; setting onHighlight re-highlights.
|
||||
queueMicrotask(() => {
|
||||
const code = findCode(node)
|
||||
if (code) code.onHighlight = chunkHighlights(index())
|
||||
})
|
||||
}}
|
||||
diff={chunk.patch}
|
||||
wrapMode="none"
|
||||
height={chunk.rows}
|
||||
lineNumberBg={props.lineNumberBg}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function findCode(node: Renderable): CodeRenderable | undefined {
|
||||
if (node instanceof CodeRenderable) return node
|
||||
return node.getChildren().reduce<CodeRenderable | undefined>((found, child) => found ?? findCode(child), undefined)
|
||||
}
|
||||
|
||||
function lineCount(chunks: readonly AddedPatchChunk[]) {
|
||||
return chunks.reduce((count, chunk) => count + chunk.rows, 0)
|
||||
}
|
||||
|
||||
@@ -90,6 +90,8 @@ export type PromptProps = {
|
||||
export type PromptRef = {
|
||||
focused: boolean
|
||||
current: PromptInfo
|
||||
mode: "normal" | "shell"
|
||||
setMode(mode: "normal" | "shell"): void
|
||||
set(prompt: PromptInfo): void
|
||||
reset(): void
|
||||
blur(): void
|
||||
@@ -672,6 +674,9 @@ export function Prompt(props: PromptProps) {
|
||||
get current() {
|
||||
return store.prompt
|
||||
},
|
||||
get mode() {
|
||||
return store.mode
|
||||
},
|
||||
focus() {
|
||||
if (disabled()) return
|
||||
input.focus()
|
||||
@@ -679,6 +684,9 @@ export function Prompt(props: PromptProps) {
|
||||
blur() {
|
||||
input.blur()
|
||||
},
|
||||
setMode(mode) {
|
||||
setStore("mode", mode)
|
||||
},
|
||||
set(prompt) {
|
||||
input.setText(prompt.text)
|
||||
setStore("prompt", prompt)
|
||||
|
||||
@@ -129,6 +129,7 @@ export const Definitions = {
|
||||
"session.aside": keybind("none", "Ask a side question"),
|
||||
"session.cd": keybind("none", "Change working directory"),
|
||||
"session.queued_prompts": keybind("<leader>q", "Manage queued prompts"),
|
||||
"queued_prompt.undo": keybind("ctrl+u", "Undo queued prompt"),
|
||||
"queued_prompt.delete": keybind("ctrl+d", "Delete queued prompt"),
|
||||
"session.toggle.exploration_grouping": keybind("none", "Toggle related tool call grouping"),
|
||||
"session.verbosity.cycle": keybind("none", "Cycle transcript verbosity"),
|
||||
|
||||
@@ -1024,6 +1024,7 @@ export function DiffViewerContent(props: {
|
||||
onCleanup(() => patchDiffByFileIndex.delete(entry.fileIndex))
|
||||
}}
|
||||
diff={patch()}
|
||||
scroll={() => scroll}
|
||||
hunkFg={theme.diff.text.hunkHeader}
|
||||
view={entry.file.status === "modified" ? view() : "unified"}
|
||||
filetype={filetype(entry.file.file)}
|
||||
|
||||
@@ -539,7 +539,6 @@ export function RunCommandMenuBody(props: {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (item.action === "subagent") {
|
||||
props.onSubagent()
|
||||
return
|
||||
@@ -949,6 +948,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
prompts: Accessor<FooterQueuedPrompt[]>
|
||||
onClose: () => void
|
||||
onSelect: (prompt: FooterQueuedPrompt) => void
|
||||
onUndo: (prompt: FooterQueuedPrompt) => void
|
||||
onDelete: (prompt: FooterQueuedPrompt) => void
|
||||
onRows?: (rows: number) => void
|
||||
mono?: boolean
|
||||
@@ -970,10 +970,21 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
onRows: props.onRows,
|
||||
})
|
||||
const shortcuts = Keymap.useShortcuts()
|
||||
const undoShortcut = () => monoShortcut(shortcuts.get("queued_prompt.undo") ?? "", props.mono ?? false)
|
||||
const deleteShortcut = () => monoShortcut(shortcuts.get("queued_prompt.delete") ?? "", props.mono ?? false)
|
||||
Keymap.createLayer(() => ({
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
id: "queued_prompt.undo",
|
||||
title: "Undo",
|
||||
group: "Prompt",
|
||||
run() {
|
||||
const item = controller.items()[controller.menu.selected()]
|
||||
if (!item) return false
|
||||
props.onUndo(item.prompt)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "queued_prompt.delete",
|
||||
title: "Delete pending prompt",
|
||||
@@ -1001,6 +1012,7 @@ export function RunQueuedPromptSelectBody(props: {
|
||||
hint={[
|
||||
controller.items()[controller.menu.selected()]?.prompt.delivery === "steer" ? "enter queue" : "enter steer",
|
||||
deleteShortcut() ? `${deleteShortcut()} delete` : undefined,
|
||||
undoShortcut() ? `${undoShortcut()} undo` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
|
||||
@@ -160,7 +160,8 @@ export type PromptState = {
|
||||
onPaste: (event: PasteEvent) => Promise<void>
|
||||
onContentChange: () => void
|
||||
onSizeChange: () => void
|
||||
replacePrompt: (prompt: RunPrompt) => void
|
||||
current: () => RunPrompt
|
||||
replacePrompt: (prompt: RunPrompt, cursor?: number) => void
|
||||
bind: (area?: TextareaRenderable) => void
|
||||
}
|
||||
|
||||
@@ -1544,6 +1545,10 @@ export function createPromptState(input: PromptInput): PromptState {
|
||||
scheduleRows()
|
||||
},
|
||||
onSizeChange: scheduleRows,
|
||||
current: () => {
|
||||
syncDraft()
|
||||
return promptCopy(draft)
|
||||
},
|
||||
replacePrompt: restore,
|
||||
bind,
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
import { FOOTER_MENU_ROWS, RunFooterMenu } from "./footer.menu"
|
||||
import { RunFooterSubagentBody } from "./footer.subagent"
|
||||
import { RunPromptBody, createPromptState } from "./footer.prompt"
|
||||
import { promptAppend } from "./prompt.shared"
|
||||
import { promptOffsetWidth } from "../prompt/display"
|
||||
import { RunPermissionBody } from "./footer.permission"
|
||||
import { RunFormBody } from "./footer.form"
|
||||
import { createFormBodyState, type FormBodyState } from "./form.shared"
|
||||
@@ -320,7 +322,7 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
}
|
||||
|
||||
const runQueuedAction = createSingleFlight<string>()
|
||||
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string) => {
|
||||
const queuedPromptAction = async (action: QueuedPromptAction, inboxID: string, failureLabel?: string) => {
|
||||
const run = props.onQueuedPromptAction
|
||||
if (!run) return false
|
||||
const result = await runQueuedAction(inboxID, async () => {
|
||||
@@ -329,7 +331,9 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
(error) => error,
|
||||
)
|
||||
if (!error) return true
|
||||
props.onStatus(`failed to ${action === "cancel" ? "delete" : action} pending prompt: ${errorMessage(error)}`)
|
||||
props.onStatus(
|
||||
`failed to ${failureLabel ?? (action === "cancel" ? "delete" : action)} pending prompt: ${errorMessage(error)}`,
|
||||
)
|
||||
return false
|
||||
})
|
||||
return result ?? false
|
||||
@@ -795,6 +799,17 @@ export function RunFooterView(props: RunFooterViewProps) {
|
||||
)
|
||||
closePanel()
|
||||
}}
|
||||
onUndo={async (item) => {
|
||||
const current = composer.current()
|
||||
if (current.mode === "shell" && current.text) {
|
||||
props.onStatus("leave shell mode before undoing a queued prompt")
|
||||
return
|
||||
}
|
||||
if (!(await queuedPromptAction("cancel", item.messageID, "undo"))) return
|
||||
closePanel()
|
||||
const next = promptAppend(composer.current(), item.prompt)
|
||||
composer.replacePrompt(next, promptOffsetWidth(next.text))
|
||||
}}
|
||||
onDelete={(item) => {
|
||||
void queuedPromptAction("cancel", item.messageID)
|
||||
}}
|
||||
|
||||
@@ -93,6 +93,11 @@ function monoCode(renderable: CodeRenderable): void {
|
||||
const onChunks = renderable.onChunks
|
||||
renderable.onChunks = async (chunks, context) => monoChunks((await onChunks?.(chunks, context)) ?? chunks)
|
||||
renderable.treeSitterClient = monoTreeSitter(renderable.treeSitterClient)
|
||||
// Streaming markdown writes the preview buffer here, skipping the setters below.
|
||||
const updateStreamingPreview = renderable.updateStreamingPreview.bind(renderable)
|
||||
renderable.updateStreamingPreview = (content, initialStyledText) => {
|
||||
updateStreamingPreview(content, monoStyledText(initialStyledText))
|
||||
}
|
||||
|
||||
const initialDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "initialStyledText")
|
||||
const contentDescriptor = Object.getOwnPropertyDescriptor(CodeRenderable.prototype, "content")
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
// the current draft is saved and history begins. Arrowing past the end
|
||||
// restores the draft.
|
||||
export { displayCharAt, displaySlice, mentionTriggerIndex, slashTriggerIndex } from "../prompt/display"
|
||||
import { promptOffsetWidth } from "../prompt/display"
|
||||
import { stringWidth } from "../util/string-width"
|
||||
import type { RunPrompt } from "./types"
|
||||
|
||||
@@ -35,6 +36,30 @@ export function promptCopy(prompt: RunPrompt): RunPrompt {
|
||||
}
|
||||
}
|
||||
|
||||
// Part ranges are textarea offsets, so shift by display width rather than string length.
|
||||
export function promptAppend(prompt: RunPrompt, following: RunPrompt): RunPrompt {
|
||||
const text = prompt.text ? `${prompt.text}\n\n` : ""
|
||||
const offset = promptOffsetWidth(text)
|
||||
const shift = <T extends { start: number; end: number }>(range: T) => ({
|
||||
...range,
|
||||
start: range.start + offset,
|
||||
end: range.end + offset,
|
||||
})
|
||||
const command = prompt.text ? prompt.command : following.command
|
||||
return {
|
||||
text: text + following.text,
|
||||
parts: [
|
||||
...structuredClone(prompt.parts),
|
||||
...structuredClone(following.parts).map((part) => {
|
||||
if (!part.source) return part
|
||||
if (part.type === "file") return { ...part, source: { ...part.source, text: shift(part.source.text) } }
|
||||
return { ...part, source: shift(part.source) }
|
||||
}),
|
||||
],
|
||||
...(command ? { command } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
export function promptSame(a: RunPrompt, b: RunPrompt): boolean {
|
||||
return (
|
||||
a.mode === b.mode &&
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { Types } from "effect"
|
||||
import { createSimpleContext } from "../context/helper"
|
||||
import { useTuiPaths } from "../context/runtime"
|
||||
import { appendText, readText, writeText } from "../util/persistence"
|
||||
import { promptOffsetWidth } from "./display"
|
||||
|
||||
export type PastedText = {
|
||||
text: string
|
||||
@@ -28,6 +29,34 @@ export type PromptPartRef = {
|
||||
|
||||
export const emptyPrompt = (): PromptInfo => ({ text: "", files: [], agents: [], skills: [], pasted: [] })
|
||||
|
||||
// Part ranges are textarea offsets, so shift by display width rather than string length.
|
||||
export function appendPrompt(prompt: PromptInfo, following: PromptInfo): PromptInfo {
|
||||
const text = prompt.text ? `${prompt.text}\n\n` : ""
|
||||
const offset = promptOffsetWidth(text)
|
||||
const shift = <T extends { start: number; end: number }>(range: T, by: number) => ({
|
||||
...range,
|
||||
start: range.start + by,
|
||||
end: range.end + by,
|
||||
})
|
||||
const mentions = <T extends { mention?: { start: number; end: number } }>(
|
||||
items: T[] | undefined,
|
||||
next: T[] | undefined,
|
||||
) => [
|
||||
...(items ?? []).map((item) => ({ ...item, mention: item.mention && shift(item.mention, 0) })),
|
||||
...(next ?? []).map((item) => ({ ...item, mention: item.mention && shift(item.mention, offset) })),
|
||||
]
|
||||
return {
|
||||
text: text + following.text,
|
||||
files: mentions(prompt.files, following.files),
|
||||
agents: mentions(prompt.agents, following.agents),
|
||||
skills: mentions(prompt.skills, following.skills),
|
||||
pasted: [
|
||||
...prompt.pasted.map((part) => ({ ...part, source: shift(part.source, 0) })),
|
||||
...following.pasted.map((part) => ({ ...part, source: shift(part.source, offset) })),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const MAX_HISTORY_ENTRIES = 50
|
||||
|
||||
export function parsePromptHistory(text: string) {
|
||||
|
||||
@@ -70,6 +70,7 @@ import { useToast } from "../../ui/toast"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import { usePromptRef } from "../../context/prompt"
|
||||
import { projectedPromptInput } from "../../prompt/codec"
|
||||
import { appendPrompt } from "../../prompt/history"
|
||||
import { deduplicateVisibleImages } from "../../prompt/attachment"
|
||||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
@@ -211,7 +212,9 @@ export function Session(props: {
|
||||
)
|
||||
const pendingDeliveries = createMemo(() => new Map(pendingUsers().map((item) => [item.id, item.delivery])))
|
||||
const queuedPrompts = createMemo(() =>
|
||||
pendingUsers().flatMap((item) => (item.delivery === "queue" ? [{ id: item.id, text: item.payload.text }] : [])),
|
||||
pendingUsers().flatMap((item) =>
|
||||
item.delivery === "queue" ? [{ id: item.id, text: item.payload.text, payload: item.payload }] : [],
|
||||
),
|
||||
)
|
||||
const [composer, setComposer] = createStore({
|
||||
open: false,
|
||||
@@ -608,7 +611,7 @@ export function Session(props: {
|
||||
const dialog = useDialog()
|
||||
const renderer = useRenderer()
|
||||
const runPendingAction = createSingleFlight<string>()
|
||||
const mutatePending = async (action: PendingAction, inboxID: string) => {
|
||||
const mutatePending = async (action: PendingAction, inboxID: string, failureLabel?: string) => {
|
||||
const result = await runPendingAction(inboxID, async () => {
|
||||
const request =
|
||||
action === "steer"
|
||||
@@ -621,7 +624,7 @@ export function Session(props: {
|
||||
(error) => error,
|
||||
)
|
||||
if (!error) return true
|
||||
const label = action === "cancel" ? "delete" : action
|
||||
const label = failureLabel ?? (action === "cancel" ? "delete" : action)
|
||||
toast.show({ title: `Failed to ${label} pending prompt`, message: errorMessage(error), variant: "error" })
|
||||
return false
|
||||
})
|
||||
@@ -652,6 +655,26 @@ export function Session(props: {
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "queued_prompt.undo",
|
||||
title: "undo",
|
||||
onTrigger: (option) => {
|
||||
const target = prompt()
|
||||
const queued = queuedPrompts().find((item) => item.id === option.value)
|
||||
if (!target || !queued) return
|
||||
if (target.mode === "shell" && target.current.text) {
|
||||
toast.show({ message: "Leave shell mode before undoing a queued prompt", variant: "error" })
|
||||
return
|
||||
}
|
||||
void mutatePending("cancel", queued.id, "undo").then((undone) => {
|
||||
if (!undone) return
|
||||
target.setMode("normal")
|
||||
target.set(appendPrompt(target.current, { ...projectedPromptInput(queued.payload), pasted: [] }))
|
||||
dialog.clear()
|
||||
target.focus()
|
||||
})
|
||||
},
|
||||
},
|
||||
]}
|
||||
footerHints={[{ title: "steer", label: "enter" }]}
|
||||
/>
|
||||
|
||||
@@ -4,6 +4,39 @@ export interface PatchHunk {
|
||||
readonly rows?: number
|
||||
}
|
||||
|
||||
export interface AddedPatchChunk {
|
||||
readonly patch: string
|
||||
readonly lines: readonly string[]
|
||||
readonly rows: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a new-file patch into chunks of `size` lines, each a valid patch with its own `@@ -0,0 +start,count @@`
|
||||
* header. Returns undefined for anything else: patches with context or removed lines would need old and new line
|
||||
* numbers recomputed at every cut, so they are not split.
|
||||
*/
|
||||
export function splitAddedPatch(patch: string, size: number): AddedPatchChunk[] | undefined {
|
||||
const header = /^@@ -0,0 \+1,(\d+) @@[^\n]*\n/m.exec(patch)
|
||||
if (!header) return
|
||||
const count = Number(header[1])
|
||||
const lines = patch
|
||||
.slice(header.index + header[0].length)
|
||||
.replace(/\n$/, "")
|
||||
.split("\n")
|
||||
const marker = lines.at(-1)?.startsWith("\\ No newline at end of file") ? lines.pop() : undefined
|
||||
if (lines.length !== count || lines.some((line) => !line.startsWith("+"))) return
|
||||
const prefix = patch.slice(0, header.index)
|
||||
return Array.from({ length: Math.ceil(count / size) }, (_, index) => {
|
||||
const start = index * size
|
||||
const slice = lines.slice(start, start + size)
|
||||
return {
|
||||
patch: `${prefix}@@ -0,0 +${start + 1},${slice.length} @@\n${slice.join("\n")}${marker && start + size >= count ? `\n${marker}` : ""}`,
|
||||
lines: slice,
|
||||
rows: slice.length,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function splitPatchHunks(patch: string): PatchHunk[] {
|
||||
const starts = [...patch.matchAll(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@.*$/gm)].map((match) => match.index)
|
||||
if (starts.length <= 1) return [{ patch }]
|
||||
|
||||
@@ -2058,6 +2058,197 @@ const manyDiffs = Array.from({ length: 40 }, (_, index) => ({
|
||||
file: `file${String(index).padStart(2, "0")}.txt`,
|
||||
}))
|
||||
|
||||
test.each([80, 160])("virtualizes a large added file at %i columns without losing its end", async (width) => {
|
||||
const lines = [
|
||||
"+{",
|
||||
...Array.from(
|
||||
{ length: 7500 },
|
||||
(_, index) =>
|
||||
`+ "row-${String(index).padStart(4, "0")}": "${"value".repeat(index === 777 ? 2000 : index % 7 === 0 ? 24 : 1)}"${index === 7499 ? "" : ","}`,
|
||||
),
|
||||
"+}",
|
||||
]
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "snapshot.json",
|
||||
status: "added",
|
||||
additions: lines.length,
|
||||
deletions: 0,
|
||||
patch: `diff --git a/snapshot.json b/snapshot.json\nnew file mode 100644\n--- /dev/null\n+++ b/snapshot.json\n@@ -0,0 +1,${lines.length} @@\n${lines.join("\n")}`,
|
||||
},
|
||||
],
|
||||
{ width, height: 24 },
|
||||
)
|
||||
try {
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-0000")
|
||||
expect(
|
||||
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
|
||||
).toBeLessThan(2000)
|
||||
viewer.commands.get("diff.last")!.run()
|
||||
await viewer.app.flush()
|
||||
if (!viewer.app.captureCharFrame().includes("row-7499")) {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("row-7499"))
|
||||
}
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-7499")
|
||||
expect(
|
||||
findDiffs(viewer.app.renderer.root).reduce((total, node) => total + node.diff.split("\n").length, 0),
|
||||
).toBeLessThan(2000)
|
||||
viewer.commands.get("diff.first")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-0000")
|
||||
viewer.app.resize(width === 80 ? 160 : 80, 20)
|
||||
await viewer.app.flush()
|
||||
viewer.commands.get("diff.last")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("row-7499")
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("keeps the line-number gutter the same width across virtual chunks", async () => {
|
||||
const additions = Array.from({ length: 10500 }, (_, index) => `+line-${String(index + 1).padStart(5, "0")}`)
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "wide.txt",
|
||||
status: "added",
|
||||
additions: additions.length,
|
||||
deletions: 0,
|
||||
patch: `--- /dev/null\n+++ b/wide.txt\n@@ -0,0 +1,${additions.length} @@\n${additions.join("\n")}`,
|
||||
},
|
||||
],
|
||||
{ width: 120, height: 24 },
|
||||
)
|
||||
const column = (text: string) =>
|
||||
viewer.app
|
||||
.captureCharFrame()
|
||||
.split("\n")
|
||||
.find((line) => line.includes(text))
|
||||
?.indexOf(text)
|
||||
try {
|
||||
await viewer.app.flush()
|
||||
const top = column("line-00001")
|
||||
viewer.commands.get("diff.last")!.run()
|
||||
await viewer.app.flush()
|
||||
if (!viewer.app.captureCharFrame().includes("line-10500")) {
|
||||
await viewer.app.waitForFrame((frame) => frame.includes("line-10500"))
|
||||
}
|
||||
expect(top).toBeDefined()
|
||||
expect(column("line-10500")).toBe(top)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("highlights virtual chunks with whole-file syntax context", async () => {
|
||||
const additions = Array.from({ length: 3200 }, (_, index) => {
|
||||
if (index === 370) return "+/*"
|
||||
if (index === 399) return "+*/"
|
||||
if (index > 370 && index < 399) return `+ comment ${index}`
|
||||
return `+const value${index} = ${index}`
|
||||
})
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "big.ts",
|
||||
status: "added",
|
||||
additions: additions.length,
|
||||
deletions: 0,
|
||||
patch: `--- /dev/null\n+++ b/big.ts\n@@ -0,0 +1,${additions.length} @@\n${additions.join("\n")}`,
|
||||
},
|
||||
],
|
||||
{ width: 120, height: 40 },
|
||||
)
|
||||
const color = (text: string) =>
|
||||
viewer.app
|
||||
.captureSpans()
|
||||
.lines.flatMap((line) => line.spans)
|
||||
.find((span) => span.text.includes(text))?.fg
|
||||
try {
|
||||
findScrollBox(viewer.app.renderer.root)!.scrollTo(360)
|
||||
await viewer.app.flush()
|
||||
// The comment starts in the first chunk and ends in the second; wait until it is highlighted.
|
||||
for (let attempt = 0; attempt < 100 && `${color("comment 375")}` === `${color("value365")}`; attempt++) {
|
||||
await Bun.sleep(20)
|
||||
await viewer.app.flush()
|
||||
}
|
||||
expect(`${color("comment 375")}`).not.toBe(`${color("value365")}`)
|
||||
for (let attempt = 0; attempt < 100 && `${color("comment 390")}` !== `${color("comment 375")}`; attempt++) {
|
||||
await Bun.sleep(20)
|
||||
await viewer.app.flush()
|
||||
}
|
||||
expect(`${color("comment 390")}`).toBe(`${color("comment 375")}`)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("does not virtualize added files at or below the size threshold", async () => {
|
||||
const additions = Array.from({ length: 3000 }, (_, index) => `+small line ${index}`)
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "small.txt",
|
||||
status: "added",
|
||||
additions: additions.length,
|
||||
deletions: 0,
|
||||
patch: `--- /dev/null\n+++ b/small.txt\n@@ -0,0 +1,${additions.length} @@\n${additions.join("\n")}`,
|
||||
},
|
||||
],
|
||||
{ width: 120, height: 24 },
|
||||
)
|
||||
try {
|
||||
expect(findDiffs(viewer.app.renderer.root)).toHaveLength(1)
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("file navigation and review still work after a virtualized patch", async () => {
|
||||
const additions = Array.from({ length: 6600 }, (_, index) => `+added line ${index}`)
|
||||
const viewer = await renderDiffViewer(
|
||||
[
|
||||
{
|
||||
file: "a-large.txt",
|
||||
status: "added",
|
||||
additions: additions.length,
|
||||
deletions: 0,
|
||||
patch: `--- /dev/null\n+++ b/a-large.txt\n@@ -0,0 +1,${additions.length} @@\n${additions.join("\n")}`,
|
||||
},
|
||||
{ ...hunkDiff[0], file: "b-small.txt" },
|
||||
],
|
||||
{ width: 160, height: 24 },
|
||||
)
|
||||
try {
|
||||
const scroll = findScrollBox(viewer.app.renderer.root)!
|
||||
scroll.scrollTo(2700)
|
||||
await viewer.app.flush()
|
||||
viewer.commands.get("diff.previous_hunk")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("added line 0")
|
||||
viewer.commands.get("diff.next_hunk")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
|
||||
viewer.commands.get("diff.next_file")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("b-small.txt")
|
||||
expect(viewer.app.captureCharFrame()).toContain("const first")
|
||||
viewer.commands.get("diff.previous_file")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("a-large.txt")
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).not.toContain("added line 0")
|
||||
viewer.commands.get("diff.mark_reviewed")!.run()
|
||||
await viewer.app.flush()
|
||||
expect(viewer.app.captureCharFrame()).toContain("added line 0")
|
||||
} finally {
|
||||
viewer.app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
function findScrollBox(root: Renderable, patches = true): ScrollBoxRenderable | undefined {
|
||||
const node = root.findDescendantById(patches ? "diff-patches" : "diff-files")
|
||||
return node instanceof ScrollBoxRenderable ? node : undefined
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user