mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-26 18:47:35 +00:00
Compare commits
46
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6cb97672e | ||
|
|
37049a5a13 | ||
|
|
a64bc3616e | ||
|
|
39021dfd67 | ||
|
|
709ddc0d79 | ||
|
|
041885d838 | ||
|
|
29ce49db0f | ||
|
|
00738c5b2d | ||
|
|
6ec8ca920f | ||
|
|
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 | ||
|
|
85b98e7da4 | ||
|
|
8061220b08 | ||
|
|
b02cc35f13 | ||
|
|
e23d89c9a9 | ||
|
|
e8b3e19e85 | ||
|
|
5256f30957 |
@@ -184,7 +184,7 @@ const table = sqliteTable("session", {
|
||||
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
|
||||
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
|
||||
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
|
||||
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep provider-specific native compaction mechanisms in `@opencode/ai` behind `LLMClient.compact`. `SessionCompaction` chooses a summary or native compaction from the model's `compaction` setting and owns route provenance, request shrinking, the retry policy, interruption, usage accounting, and checkpoint persistence.
|
||||
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
|
||||
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
|
||||
- Keep event replay ownership separate from clustered Session execution ownership.
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode.cjs",
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
@@ -135,7 +135,6 @@
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
@@ -177,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -216,7 +215,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -252,7 +251,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -279,7 +278,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -296,7 +295,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -320,7 +319,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -340,7 +339,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -408,7 +407,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "5.0.0",
|
||||
@@ -457,7 +456,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -494,7 +493,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -510,7 +509,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -529,7 +528,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -542,7 +541,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -556,7 +555,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -571,7 +570,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -597,8 +596,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": [
|
||||
@@ -610,7 +609,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -640,7 +639,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -655,7 +654,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -679,7 +678,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -700,7 +699,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -722,7 +721,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -757,7 +756,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -777,7 +776,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -811,7 +810,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -830,7 +829,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -876,7 +875,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -890,7 +889,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -909,7 +908,6 @@
|
||||
"effect": "catalog:",
|
||||
"fuzzysort": "catalog:",
|
||||
"get-east-asian-width": "catalog:",
|
||||
"open": "10.1.2",
|
||||
"opentui-spinner": "catalog:",
|
||||
"remeda": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
@@ -925,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -960,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -982,6 +980,7 @@
|
||||
"mime-types": "3.0.2",
|
||||
"minimatch": "10.2.5",
|
||||
"npm-package-arg": "13.0.2",
|
||||
"open": "11.0.4",
|
||||
"pacote": "21.5.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -997,7 +996,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1038,7 +1037,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -1098,6 +1097,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"open": "11.0.4",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"catalog": {
|
||||
@@ -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=="],
|
||||
|
||||
@@ -4322,7 +4322,7 @@
|
||||
|
||||
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
|
||||
|
||||
"is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
"is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
|
||||
|
||||
"is-document.all": ["is-document.all@1.0.0", "", { "dependencies": { "call-bound": "^1.0.4" } }, "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g=="],
|
||||
|
||||
@@ -4340,6 +4340,8 @@
|
||||
|
||||
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
|
||||
|
||||
"is-in-ssh": ["is-in-ssh@1.0.0", "", {}, "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw=="],
|
||||
|
||||
"is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="],
|
||||
|
||||
"is-map": ["is-map@2.0.3", "", {}, "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw=="],
|
||||
@@ -4386,7 +4388,7 @@
|
||||
|
||||
"is-whitespace": ["is-whitespace@0.3.0", "", {}, "sha512-RydPhl4S6JwAyj0JJjshWJEFG6hNye3pZFBRZaTUfZFwGHxzppNaNOVgQuS/E/SlhrApuMXrpnK1EEIXfdo3Dg=="],
|
||||
|
||||
"is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
|
||||
|
||||
@@ -4850,7 +4852,7 @@
|
||||
|
||||
"oniguruma-to-es": ["oniguruma-to-es@4.3.6", "", { "dependencies": { "oniguruma-parser": "^0.12.2", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA=="],
|
||||
|
||||
"open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="],
|
||||
"open": ["open@11.0.4", "", { "dependencies": { "default-browser": "^5.5.1", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.2.1", "wsl-utils": "^1.0.0" } }, "sha512-++Zlftm0kVLPmzC06t6epuWmcRMDbI4z5P3NNX979WA/k23+NtSOynEGzsVfZwguKw2mi5umVgnBlJQMwRz4Pg=="],
|
||||
|
||||
"openai": ["openai@6.49.0", "", { "peerDependencies": { "@aws-sdk/credential-provider-node": ">=3.972.0 <4", "@smithy/hash-node": ">=4.3.0 <5", "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@aws-sdk/credential-provider-node", "@smithy/hash-node", "@smithy/signature-v4", "ws", "zod"] }, "sha512-aYCc0C6L864eR6WSYIwQGyXriw/nIyZx0ObvhzOEVuk0zoBDpynjSbrionWI7q65B5H8jJX0DXR9snEzM6bfPg=="],
|
||||
|
||||
@@ -4994,6 +4996,8 @@
|
||||
|
||||
"postject": ["postject@1.0.0-alpha.6", "", { "dependencies": { "commander": "^9.4.0" }, "bin": { "postject": "dist/cli.js" } }, "sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A=="],
|
||||
|
||||
"powershell-utils": ["powershell-utils@0.2.1", "", {}, "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A=="],
|
||||
|
||||
"preact": ["preact@11.0.0-beta.0", "", {}, "sha512-IcODoASASYwJ9kxz7+MJeiJhvLriwSb4y4mHIyxdgaRZp6kPUud7xytrk/6GZw8U3y6EFJaRb5wi9SrEK+8+lg=="],
|
||||
|
||||
"preact-render-to-string": ["preact-render-to-string@6.6.5", "", { "peerDependencies": { "preact": ">=10 || >= 11.0.0-0" } }, "sha512-O6MHzYNIKYaiSX3bOw0gGZfEbOmlIDtDfWwN1JJdc/T3ihzRT6tGGSEWE088dWrEDGa1u7101q+6fzQnO9XCPA=="],
|
||||
@@ -5814,7 +5818,7 @@
|
||||
|
||||
"ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="],
|
||||
|
||||
"wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
"wsl-utils": ["wsl-utils@1.0.0", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
@@ -5910,8 +5914,6 @@
|
||||
|
||||
"@astrojs/telemetry/ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="],
|
||||
|
||||
"@astrojs/telemetry/is-docker": ["is-docker@4.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-LHE+wROyG/Y/0ZnbktRCoTix2c1RhgWaZraMZ8o1Q7zCh0VSrICJQO5oqIIISrcSBtrXv0o233w1IYwsWCjTzA=="],
|
||||
|
||||
"@aws-crypto/crc32/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
|
||||
|
||||
"@aws-crypto/crc32c/@aws-sdk/types": ["@aws-sdk/types@3.974.4", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A=="],
|
||||
@@ -6394,8 +6396,6 @@
|
||||
|
||||
"builder-util/js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"chrome-launcher/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"clean-css/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
@@ -6500,6 +6500,10 @@
|
||||
|
||||
"import-in-the-middle/es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="],
|
||||
|
||||
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
@@ -6510,8 +6514,6 @@
|
||||
|
||||
"lighthouse/devtools-protocol": ["devtools-protocol@0.0.1663043", "", {}, "sha512-33aOY3ZnBP1dgZsshgaL+/XlsQleiFZgyUaDtdZkEa1nbZhVY1MoDeWjk+wxg25fU924l1ZJfoGNmjjeA/5s1w=="],
|
||||
|
||||
"lighthouse/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
|
||||
|
||||
"lighthouse/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||
|
||||
"md-to-react-email/marked": ["marked@7.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ=="],
|
||||
@@ -6614,8 +6616,6 @@
|
||||
|
||||
"sst/jose": ["jose@5.2.3", "", {}, "sha512-KUXdbctm1uHVL8BYhnyHkgp3zDX5KW8ZhAKVFEfUbU2P8Alpzjb+48hHvjOdQIyPshoblhzsuqOwEEAbtHVirA=="],
|
||||
|
||||
"storybook/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"storybook-solidjs-vite/semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
|
||||
|
||||
"storybook-solidjs-vite/vite": ["vite@7.1.11", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-uzcxnSDVjAopEUjljkWh8EIrg6tlzrjFUfMcR1EVsRDGwf/ccef0qQPRyOrROwhrTDaApueq+ja+KLPlzR/zdg=="],
|
||||
@@ -6702,6 +6702,10 @@
|
||||
|
||||
"write-file-atomic/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
|
||||
|
||||
"wsl-utils/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"wsl-utils/powershell-utils": ["powershell-utils@0.1.0", "", {}, "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A=="],
|
||||
|
||||
"yaml-language-server/prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="],
|
||||
|
||||
"yaml-language-server/request-light": ["request-light@0.5.8", "", {}, "sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg=="],
|
||||
@@ -7336,8 +7340,6 @@
|
||||
|
||||
"builder-util/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"chrome-launcher/is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
|
||||
"cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
@@ -7398,12 +7400,6 @@
|
||||
|
||||
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
|
||||
|
||||
"lighthouse/open/define-lazy-prop": ["define-lazy-prop@2.0.0", "", {}, "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og=="],
|
||||
|
||||
"lighthouse/open/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
|
||||
|
||||
"lighthouse/open/is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
|
||||
"miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||
@@ -7674,6 +7670,10 @@
|
||||
|
||||
"@astrojs/starlight/@astrojs/mdx/@astrojs/markdown-remark/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="],
|
||||
|
||||
"@astrojs/starlight/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"@astrojs/starlight/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"@astrojs/starlight/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
|
||||
"@astrojs/starlight/astro/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||
@@ -8076,6 +8076,10 @@
|
||||
|
||||
"@opencode/web/@astrojs/cloudflare/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="],
|
||||
|
||||
"@opencode/web/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"@opencode/web/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"@opencode/web/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"@opencode/web/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
@@ -8216,6 +8220,10 @@
|
||||
|
||||
"archiver-utils/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"astro-expressive-code/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"astro-expressive-code/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"astro-expressive-code/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"astro-expressive-code/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
@@ -8318,6 +8326,10 @@
|
||||
|
||||
"temp/rimraf/glob/minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/@astrojs/telemetry/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
|
||||
|
||||
"toolbeam-docs-theme/astro/p-queue/p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="],
|
||||
|
||||
+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-9gJjhes2ueYckAgdeGlPwZcaIDdwB3ZnqK/XHHXhWNs=",
|
||||
"aarch64-linux": "sha256-Sy5YXYM9tKevIITdV++bP35SJNaFCQVKwlNJRbWsD1Q=",
|
||||
"aarch64-darwin": "sha256-wiXHjKXm2VIFvalwITpSiRHaFZEWc8UJIqjQyc/0f0s=",
|
||||
"x86_64-darwin": "sha256-r/mnhdNbnPIJOY3qvtuY6GQ7ed1Nauq65X+8uERhdP8="
|
||||
}
|
||||
}
|
||||
|
||||
+5
-4
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
@@ -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",
|
||||
@@ -162,6 +162,7 @@
|
||||
"@types/node": "catalog:",
|
||||
"bun-types": "1.4.2",
|
||||
"effect": "catalog:",
|
||||
"open": "11.0.4",
|
||||
"solid-js": "catalog:"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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" }))))),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { Portal, render } from "solid-js/web"
|
||||
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
|
||||
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { createSessionBrowser } from "../src/session/browser/model"
|
||||
@@ -27,7 +27,12 @@ export function mountBrowserPane() {
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
covered: false,
|
||||
captures: 0,
|
||||
holdCapture: false,
|
||||
})
|
||||
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
|
||||
const held: (() => void)[] = []
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
title: name,
|
||||
@@ -44,6 +49,17 @@ export function mountBrowserPane() {
|
||||
{
|
||||
setLayout: (layout) => setStore("layouts", tab.title, layout),
|
||||
command: async () => undefined,
|
||||
capture: async () => {
|
||||
setStore("captures", (count) => count + 1)
|
||||
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
|
||||
const canvas = new OffscreenCanvas(4, 4)
|
||||
const paint = canvas.getContext("2d")
|
||||
if (paint) {
|
||||
paint.fillStyle = "#3b82f6"
|
||||
paint.fillRect(0, 0, 4, 4)
|
||||
}
|
||||
return canvas.convertToBlob()
|
||||
},
|
||||
close: () => undefined,
|
||||
},
|
||||
]),
|
||||
@@ -118,12 +134,34 @@ export function mountBrowserPane() {
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
|
||||
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
|
||||
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<p>Captures: {store.captures}</p>
|
||||
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted}>
|
||||
<SessionBrowserPane browser={browser} visible={store.visible} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={store.covered}>
|
||||
{/* Floating content portals into <body> like a menu or hover card over the page. */}
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
data-popper-positioner
|
||||
data-testid="fixture-popover"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "320px",
|
||||
height: "480px",
|
||||
"z-index": "1001",
|
||||
"pointer-events": "none",
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
</Show>
|
||||
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
|
||||
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
|
||||
<For each={tabs}>
|
||||
|
||||
@@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const still = root.locator("#browser-panel img")
|
||||
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
// The native page stays up until its still is ready, so the pane never shows blank.
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(still).toHaveCount(0)
|
||||
|
||||
await root.getByRole("button", { name: "Release capture", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(still).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("covered.png") })
|
||||
|
||||
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(still).toHaveCount(0)
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -25,6 +25,8 @@ export type BrowserPaneEvent =
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
/** Captures the shown page, or resolves null when nothing is on screen. */
|
||||
capture(tabID: Browser.TabID): Promise<Blob | null>
|
||||
close(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -43,6 +43,9 @@ function fixture() {
|
||||
async command(command) {
|
||||
call.commands.push(command)
|
||||
},
|
||||
async capture() {
|
||||
return null
|
||||
},
|
||||
close() {
|
||||
call.closed = true
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
|
||||
@@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
|
||||
navigating: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
// A still of the page shown in the DOM while floating content covers the hidden native view.
|
||||
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
let surface: HTMLDivElement | undefined
|
||||
@@ -37,6 +40,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
let capturing: Browser.TabID | undefined
|
||||
let release: ReturnType<typeof setTimeout> | undefined
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
@@ -69,6 +74,45 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const replaceSnapshot = (next?: { tabID: Browser.TabID; url: string }) => {
|
||||
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
|
||||
setStore("snapshot", next)
|
||||
}
|
||||
// Keep the page on screen as a still under the floating content. The native view
|
||||
// stays visible until the still has decoded, so the pane never flashes blank.
|
||||
const freeze = (tabID: Browser.TabID) => {
|
||||
clearTimeout(release)
|
||||
release = undefined
|
||||
if (store.snapshot?.tabID === tabID || capturing === tabID) return
|
||||
capturing = tabID
|
||||
void (registration()?.capture(tabID) ?? Promise.resolve(null))
|
||||
.catch(() => null)
|
||||
.then(async (blob) => {
|
||||
const url = blob ? URL.createObjectURL(blob) : ""
|
||||
if (url) {
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode().catch(() => undefined)
|
||||
}
|
||||
if (capturing !== tabID) {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
capturing = undefined
|
||||
// A failed capture still hides the page; the pane shows its background as before.
|
||||
replaceSnapshot({ tabID, url })
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
const thaw = () => {
|
||||
capturing = undefined
|
||||
if (!store.snapshot || release !== undefined) return
|
||||
// Keep the still under the native view until the view has painted again.
|
||||
release = setTimeout(() => {
|
||||
release = undefined
|
||||
replaceSnapshot()
|
||||
}, 150)
|
||||
}
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
@@ -84,7 +128,11 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
// The desktop page hides blank and loading documents itself; only hide here
|
||||
// while the pane shows its own empty or failed state over the surface.
|
||||
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
|
||||
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
|
||||
const cover = covered(rect)
|
||||
if (shown && cover) freeze(tab.id)
|
||||
if (!cover) thaw()
|
||||
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
@@ -186,6 +234,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
clearTimeout(release)
|
||||
capturing = undefined
|
||||
replaceSnapshot()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -296,7 +347,17 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<div ref={surface} class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<Show when={store.snapshot?.tabID === state()?.id && !empty() && !failed() && store.snapshot?.url}>
|
||||
{(url) => (
|
||||
<img
|
||||
src={url()}
|
||||
alt=""
|
||||
draggable={false}
|
||||
class="absolute inset-0 size-full pointer-events-none select-none"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={(empty() || failed()) && !props.browser.suspended()}>
|
||||
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
|
||||
<div
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -41,7 +41,6 @@
|
||||
"effect": "catalog:",
|
||||
"immer": "11.1.4",
|
||||
"jsonc-parser": "3.3.1",
|
||||
"open": "10.1.2",
|
||||
"solid-js": "catalog:",
|
||||
"tree-sitter-bash": "0.25.0",
|
||||
"tree-sitter-powershell": "0.25.10",
|
||||
|
||||
@@ -16,8 +16,8 @@ export function requireInteractive(message: string) {
|
||||
}
|
||||
|
||||
export const openUrl = Effect.fn("cli.prompt.open-url")(function* (url: string) {
|
||||
const { default: open } = yield* Effect.promise(() => import("open"))
|
||||
yield* Effect.promise(() => open(url)).pipe(Effect.ignore)
|
||||
const browser = yield* Effect.promise(() => import("@opencode/util/open"))
|
||||
yield* Effect.promise(() => browser.openUrl(url)).pipe(Effect.ignore)
|
||||
})
|
||||
|
||||
export function handlePromptErrors<A, E, R>(effect: Effect.Effect<A, E, R>) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -85,8 +85,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion
|
||||
or binding/default failure.
|
||||
- [ ] Object destructuring from primitives follows ToObject (`const { length } = "abc"`, `const {} = 1`); non-object
|
||||
sources are rejected.
|
||||
- [x] Object destructuring from primitives follows ToObject: `const { length } = "abc"` is `3`, `const { toFixed } = 1`
|
||||
finds the built-in, `const {} = 1` is a no-op, and a rest element copies a string's indexes (`{ 1: "y", 2: "z" }`).
|
||||
Only `null` and `undefined` sources throw (`Cannot destructure null as it is null.`).
|
||||
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
|
||||
`const { slice } = values` find the inherited built-in.
|
||||
- [x] Any assignment target as a `for...in` head, like `for...of`: `for (x.y in obj)`, `for (a[i++] in obj)`, and
|
||||
@@ -130,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`,
|
||||
@@ -154,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`
|
||||
@@ -224,8 +226,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give
|
||||
`"[object Function]"` rather than the source text, `isNaN(fn)` is `true`.
|
||||
- [x] `==` and `!=` follow IsLooselyEqual: objects (including functions and tool references) compare by identity, a
|
||||
nullish operand never coerces the other side, and a data object facing a primitive coerces through its built-in
|
||||
primitive form (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
|
||||
nullish operand never coerces the other side, and a data object facing a primitive converts through its own
|
||||
`valueOf`/`toString` (default hint) (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
|
||||
`switch` matches cases with `===`, so `switch (fn) { case fn: }` selects, and `Object.is` compares any two
|
||||
values. Operators inspect only their direct operands, so `rows == null` on a large array costs the same as
|
||||
`rows === null`, and an object merely holding a function inside (`[fn] + ""`, `-[fn]`) coerces like any other
|
||||
@@ -233,13 +235,26 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [ ] Coercing a function, promise, generator, or tool reference itself: `fn + ""`, `-fn`, `fn++`, and `fn == 1`
|
||||
throw `TypeError: Binary operators require data values.` (or the unary/update form) where JavaScript would use
|
||||
the source text or `NaN`.
|
||||
- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument
|
||||
`Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call
|
||||
the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form
|
||||
(`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already
|
||||
follow ToPrimitive.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
|
||||
string keys.
|
||||
- [x] ToPrimitive on program objects: `+ - * / % **`, the relational and bitwise operators, unary `+ - ~`, `++`/`--`,
|
||||
compound assignment, `${x}`, `Number`/`String`/`isNaN`/`isFinite`, `parseInt`/`parseFloat` (text and radix),
|
||||
`Math.*` arguments, `Error(message)`, and `Array.prototype.join`/`toString` elements call the object's own
|
||||
`valueOf`/`toString` in spec order (both operands left then right, `+` with the default hint) and surface their
|
||||
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).
|
||||
- [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
|
||||
|
||||
@@ -300,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
|
||||
@@ -362,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`.
|
||||
@@ -375,8 +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 `[]`. A program object's own
|
||||
`valueOf`/`toString` is not consulted 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
|
||||
|
||||
@@ -393,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
|
||||
|
||||
@@ -454,15 +471,15 @@ reject }` object.
|
||||
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
|
||||
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
|
||||
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
|
||||
`TimeClip` behavior.
|
||||
`TimeClip` behavior. On an invalid Date every setter but `setTime` and `set(UTC)FullYear` answers `NaN` without
|
||||
writing, so a time set inside an argument's `valueOf` survives.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
|
||||
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [x] Date setters and one-argument construction coerce object arguments through their own `valueOf`/`toString` and
|
||||
surface their throws.
|
||||
- [ ] Multi-argument construction and `Date.UTC` coerce object arguments the same way (see ToPrimitive above).
|
||||
- [x] Date setters, construction, and `Date.UTC` coerce object arguments through their own `valueOf`/`toString` in
|
||||
argument order and surface their throws; only the first seven components are converted.
|
||||
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
|
||||
representation for the string primitive.
|
||||
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
|
||||
@@ -509,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
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type Cursor,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { isOpaque, typeofValue } from "./references.js"
|
||||
|
||||
/** IteratorClose: a consumer failure closes the iterator and wins over any close failure, except that a generator's
|
||||
* return() is a return completion, so a failing close wins over it, as after `break`. */
|
||||
@@ -31,16 +31,15 @@ export const preserveConsumerError = <A, R>(
|
||||
})
|
||||
})
|
||||
|
||||
export type Hint = "number" | "string" | "default"
|
||||
|
||||
/**
|
||||
* ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the
|
||||
* default hint as "string", like their `Symbol.toPrimitive`.
|
||||
* default hint as "string", like their `Symbol.toPrimitive`. Opaque values (functions, promises, generators, tool
|
||||
* references) pass through unchanged so callers reject or describe them in their built-in form.
|
||||
*/
|
||||
export const toPrimitive = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
value: Value,
|
||||
hint: "number" | "string" | "default",
|
||||
): Effect.Effect<Value, unknown, R> => {
|
||||
if (!(value instanceof Obj)) return Effect.succeed(value)
|
||||
export const toPrimitive = <R>(ctx: Interpreter<R>, value: Value, hint: Hint): Effect.Effect<Value, unknown, R> => {
|
||||
if (!(value instanceof Obj) || isOpaque(value)) return Effect.succeed(value)
|
||||
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
|
||||
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
|
||||
return Effect.gen(function* () {
|
||||
@@ -67,6 +66,28 @@ export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
|
||||
|
||||
/**
|
||||
* 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 | Effect.Effect<Value, unknown, R>,
|
||||
): Value | Effect.Effect<Value, unknown, R> => {
|
||||
if (!values.some((value) => value instanceof Obj)) return body(values)
|
||||
return Effect.flatMap(
|
||||
Effect.forEach(values, (value, index) =>
|
||||
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
|
||||
),
|
||||
(primitives) => {
|
||||
const result = body(primitives)
|
||||
return Effect.isEffect(result) ? result : Effect.succeed(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// The single acceptance list for callbacks: collections, sort, string replacers,
|
||||
// Array.from mappers, and promise reactions all admit exactly these callables.
|
||||
// Admission means dispatchable, not necessarily invocable: new-requiring
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { toPrimitiveString } from "./callback.js"
|
||||
import { formatValue } from "../stdlib/console.js"
|
||||
|
||||
export const normalizeError = (error: unknown): Diagnostic => {
|
||||
@@ -139,14 +140,13 @@ const constructAggregateErrorValue = <R>(
|
||||
proto: Obj,
|
||||
): Effect.Effect<ErrorObj, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const message = args[1] === undefined ? "" : yield* toPrimitiveString(ctx, args[1])
|
||||
const cursor = yield* ctx.iterate(args[0])
|
||||
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
|
||||
const errors: Array<Value> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
return createAggregateErrorValue(ctx, errors, args[1] === undefined ? "" : coerceToString(args[1]), proto)
|
||||
}
|
||||
if (step.done) return createAggregateErrorValue(ctx, errors, message, proto)
|
||||
errors.push(step.value)
|
||||
}
|
||||
})
|
||||
@@ -160,7 +160,9 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
const created =
|
||||
type === "AggregateError"
|
||||
? constructAggregateErrorValue(ctx, args, proto)
|
||||
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
|
||||
: Effect.map(args[0] === undefined ? Effect.undefined : toPrimitiveString(ctx, args[0]), (message) =>
|
||||
createErrorValue(proto, message),
|
||||
)
|
||||
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
|
||||
const options = args[type === "AggregateError" ? 2 : 1]
|
||||
if (!(options instanceof Obj) || !has(options, "cause")) return created
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -95,7 +95,7 @@ import {
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { preserveConsumerError } from "./callback.js"
|
||||
import { type Hint, preserveConsumerError, toPrimitive } from "./callback.js"
|
||||
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
|
||||
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { ScopeStack } from "./scope.js"
|
||||
@@ -103,6 +103,30 @@ import { constructRegExp } from "../stdlib/regexp.js"
|
||||
import { enumerableSource } from "../stdlib/object.js"
|
||||
import { compoundOperators } from "../stdlib/value.js"
|
||||
|
||||
/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */
|
||||
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 => {
|
||||
@@ -1116,18 +1140,15 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (!(value instanceof Obj)) {
|
||||
throw typeError(
|
||||
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
|
||||
pattern,
|
||||
)
|
||||
if (value === null || value === undefined) {
|
||||
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
|
||||
}
|
||||
|
||||
const consumed = new Set<PropertyKey>()
|
||||
for (const property of pattern.properties) {
|
||||
if (property.type === "RestElement") {
|
||||
const rest = new Obj(self.ctx.builtins.Object)
|
||||
assign(rest, value, consumed)
|
||||
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
|
||||
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
|
||||
continue
|
||||
}
|
||||
@@ -1136,7 +1157,7 @@ class Frame<R> {
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
yield* self.declarePattern(
|
||||
property.value,
|
||||
self.readProperty(value, key, property),
|
||||
self.destructuredProperty(value, key, property),
|
||||
mutable,
|
||||
property,
|
||||
initialize,
|
||||
@@ -1175,24 +1196,21 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (!(value instanceof Obj)) {
|
||||
throw invalidData(
|
||||
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
|
||||
pattern,
|
||||
)
|
||||
if (value === null || value === undefined) {
|
||||
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
|
||||
}
|
||||
|
||||
const consumed = new Set<PropertyKey>()
|
||||
for (const property of pattern.properties) {
|
||||
if (property.type === "RestElement") {
|
||||
const rest = new Obj(self.ctx.builtins.Object)
|
||||
assign(rest, value, consumed)
|
||||
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
|
||||
yield* self.assignPattern(property.argument, rest, property)
|
||||
continue
|
||||
}
|
||||
const key = yield* self.destructuringPropertyKey(property)
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
|
||||
yield* self.assignPattern(property.value, self.destructuredProperty(value, key, property), property)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1263,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))
|
||||
@@ -1368,85 +1386,105 @@ class Frame<R> {
|
||||
const lhs = yield* self.evaluateExpression(left)
|
||||
const rhs = yield* self.evaluateExpression(node.right)
|
||||
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
|
||||
if (lhs instanceof Obj || rhs instanceof Obj) return yield* self.applyOperator(operator, lhs, rhs, node)
|
||||
return self.applyBinaryOperator(operator, lhs, rhs, node)
|
||||
})
|
||||
}
|
||||
|
||||
/** ToPrimitive for an operand: data objects run their own methods; opaque values stay for the data gates below. */
|
||||
private toPrimitive(value: Value, hint: Hint, node: AstNode) {
|
||||
return this.native(() => toPrimitive(this.ctx, value, hint), node)
|
||||
}
|
||||
|
||||
// Arithmetic, relational, and bitwise operators convert both operands first, left then right, so a `valueOf`
|
||||
// runs (and throws) in spec order; `+` asks for the default hint and the rest for a number.
|
||||
private applyOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
if (!(lhs instanceof Obj || rhs instanceof Obj))
|
||||
return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
|
||||
// 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) ||
|
||||
(equality && other !== null && other !== undefined && typeof other !== "object")
|
||||
if (!converts) return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
|
||||
const hint = operator === "+" || equality ? "default" : "number"
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const l = yield* self.toPrimitive(lhs, hint, node)
|
||||
const r = yield* self.toPrimitive(rhs, hint, node)
|
||||
return self.applyBinaryOperator(operator, l, r, node)
|
||||
})
|
||||
}
|
||||
|
||||
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
|
||||
if (operator === "===") return lhs === rhs
|
||||
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)
|
||||
}
|
||||
// Addition uses the default hint; every other operator asks for a number.
|
||||
const hint = operator === "+" ? "default" : "number"
|
||||
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
|
||||
const l = coerceOperand(lhs)
|
||||
const r = coerceOperand(rhs)
|
||||
// Object operands were already converted by applyOperator; only primitives reach the arithmetic below.
|
||||
switch (operator) {
|
||||
case "+": {
|
||||
const sum = (l as string) + (r as string)
|
||||
const sum = (lhs as string) + (rhs as string)
|
||||
if (typeof sum === "string") checkStringLength(sum.length)
|
||||
return sum
|
||||
}
|
||||
case "-":
|
||||
return (l as number) - (r as number)
|
||||
return (lhs as number) - (rhs as number)
|
||||
case "*":
|
||||
return (l as number) * (r as number)
|
||||
return (lhs as number) * (rhs as number)
|
||||
case "/":
|
||||
return (l as number) / (r as number)
|
||||
return (lhs as number) / (rhs as number)
|
||||
case "%":
|
||||
return (l as number) % (r as number)
|
||||
return (lhs as number) % (rhs as number)
|
||||
case "**":
|
||||
return (l as number) ** (r as number)
|
||||
return (lhs as number) ** (rhs as number)
|
||||
case "<":
|
||||
return (l as string) < (r as string)
|
||||
return (lhs as string) < (rhs as string)
|
||||
case "<=":
|
||||
return (l as string) <= (r as string)
|
||||
return (lhs as string) <= (rhs as string)
|
||||
case ">":
|
||||
return (l as string) > (r as string)
|
||||
return (lhs as string) > (rhs as string)
|
||||
case ">=":
|
||||
return (l as string) >= (r as string)
|
||||
return (lhs as string) >= (rhs as string)
|
||||
case "&":
|
||||
return (l as number) & (r as number)
|
||||
return (lhs as number) & (rhs as number)
|
||||
case "|":
|
||||
return (l as number) | (r as number)
|
||||
return (lhs as number) | (rhs as number)
|
||||
case "^":
|
||||
return (l as number) ^ (r as number)
|
||||
return (lhs as number) ^ (rhs as number)
|
||||
case "<<":
|
||||
return (l as number) << (r as number)
|
||||
return (lhs as number) << (rhs as number)
|
||||
case ">>":
|
||||
return (l as number) >> (r as number)
|
||||
return (lhs as number) >> (rhs as number)
|
||||
case ">>>":
|
||||
return (l as number) >>> (r as number)
|
||||
return (lhs as number) >>> (rhs as number)
|
||||
case "in":
|
||||
if (!(rhs instanceof Obj)) {
|
||||
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
return has(rhs, coerceOperand(lhs) as PropertyKey)
|
||||
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
default:
|
||||
throw typeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and only a
|
||||
// data object facing a non-nullish primitive needs to coerce, so an opaque value is rejected only there.
|
||||
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and a nullish
|
||||
// primitive never equals an object.
|
||||
private looselyEqual(lhs: Value, rhs: Value, node: AstNode): boolean {
|
||||
const lhsObject = lhs !== null && typeof lhs === "object"
|
||||
const rhsObject = rhs !== null && typeof rhs === "object"
|
||||
if (lhsObject === rhsObject) return lhsObject ? lhs === rhs : lhs == rhs
|
||||
const object = lhsObject ? lhs : rhs
|
||||
const primitive = lhsObject ? rhs : lhs
|
||||
if (primitive === null || primitive === undefined) return false
|
||||
if (!(object instanceof Obj) || isOpaque(object)) {
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
return object.toPrimitive("default") == primitive
|
||||
// Data objects were converted by applyOperator, so only an opaque reference facing a primitive gets here.
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
|
||||
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
|
||||
@@ -1468,14 +1506,16 @@ class Frame<R> {
|
||||
if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(argument.name)) {
|
||||
return Effect.succeed("undefined")
|
||||
}
|
||||
return Effect.map(this.evaluateExpression(argument), (value) => {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* self.evaluateExpression(argument)
|
||||
if (operator === "typeof") return typeofValue(value)
|
||||
if (operator === "!") return !value
|
||||
if (operator === "void") return undefined
|
||||
if (isOpaque(value)) {
|
||||
const operand = yield* self.toPrimitive(value, "number", node)
|
||||
if (isOpaque(operand)) {
|
||||
throw invalidData("Unary operators require data values.", node)
|
||||
}
|
||||
const operand = value instanceof Obj ? value.toPrimitive("number") : value
|
||||
let result: Value
|
||||
switch (operator) {
|
||||
case "+":
|
||||
@@ -1497,11 +1537,16 @@ class Frame<R> {
|
||||
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
|
||||
const left = node.left
|
||||
const operator = node.operator
|
||||
// The binary operator a compound assignment applies: `+=` is `+`.
|
||||
const binary = operator.slice(0, -1)
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (operator === "??=" || operator === "||=" || operator === "&&=") {
|
||||
return yield* self.evaluateLogicalAssignment(node, left, operator)
|
||||
}
|
||||
if (operator !== "=" && !compoundOperators.has(operator)) {
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
if (operator === "=" && (left.type === "ObjectPattern" || left.type === "ArrayPattern")) {
|
||||
const rightValue = yield* self.evaluateExpression(node.right)
|
||||
yield* self.assignPattern(left, rightValue, node)
|
||||
@@ -1512,17 +1557,24 @@ class Frame<R> {
|
||||
if (operator !== "=") {
|
||||
const current = self.scopes.get(name, left)
|
||||
const rightValue = yield* self.evaluateExpression(node.right)
|
||||
return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left)
|
||||
const next =
|
||||
current instanceof Obj || rightValue instanceof Obj
|
||||
? yield* self.applyOperator(binary, current, rightValue, node)
|
||||
: self.applyBinaryOperator(binary, current, rightValue, node)
|
||||
return self.scopes.set(name, next, left)
|
||||
}
|
||||
const rightValue = yield* self.evaluateNamed(node.right, name)
|
||||
return self.scopes.set(name, rightValue, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
return yield* self.modifyMember(left, (current) =>
|
||||
Effect.map(self.evaluateExpression(node.right), (rightValue) => {
|
||||
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
|
||||
const next = self.applyCompoundAssignment(operator, current, rightValue, node)
|
||||
return { write: true, next, result: next }
|
||||
Effect.flatMap(self.evaluateExpression(node.right), (rightValue) => {
|
||||
if (operator === "=") return Effect.succeed({ write: true, next: rightValue, result: rightValue })
|
||||
return Effect.map(self.applyOperator(binary, current, rightValue, node), (next) => ({
|
||||
write: true,
|
||||
next,
|
||||
result: next,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1572,8 +1624,7 @@ class Frame<R> {
|
||||
throw typeError(`Unsupported update operator '${operator}'.`, node)
|
||||
}
|
||||
|
||||
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
|
||||
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
|
||||
// CodeMode numeric coercion, not host Number(), so opaque runtime references reject clearly.
|
||||
const operand = (current: Value): number => {
|
||||
if (isOpaque(current)) {
|
||||
throw invalidData(`'${operator}' requires a data value.`, argument)
|
||||
@@ -1582,21 +1633,26 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = argument.name
|
||||
const current = operand(this.scopes.get(name, argument))
|
||||
const next = current + increment
|
||||
const name = argument.name
|
||||
const current = this.scopes.get(name, argument)
|
||||
const update = (value: Value) => {
|
||||
const before = operand(value)
|
||||
const next = before + increment
|
||||
this.scopes.set(name, next, argument)
|
||||
return prefix ? next : current
|
||||
})
|
||||
return prefix ? next : before
|
||||
}
|
||||
if (!(current instanceof Obj)) return Effect.sync(() => update(current))
|
||||
return Effect.map(this.toPrimitive(current, "number", argument), update)
|
||||
}
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = operand(current)
|
||||
const next = value + increment
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
return this.modifyMember(argument, (current) =>
|
||||
Effect.map(this.toPrimitive(current, "number", argument), (primitive) => {
|
||||
const value = operand(primitive)
|
||||
const next = value + increment
|
||||
return { write: true, next, result: prefix ? next : value }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
@@ -1996,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)
|
||||
}
|
||||
@@ -2065,7 +2121,7 @@ class Frame<R> {
|
||||
|
||||
if (index < expressions.length) {
|
||||
const raw = yield* self.evaluateExpression(expressions[index])
|
||||
output += coerceToString(raw)
|
||||
output += coerceToString(yield* self.toPrimitive(raw, "string", expressions[index]))
|
||||
checkStringLength(output.length)
|
||||
}
|
||||
}
|
||||
@@ -2115,13 +2171,6 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
|
||||
if (!compoundOperators.has(operator)) {
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
|
||||
}
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
|
||||
@@ -2135,37 +2184,58 @@ 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))
|
||||
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string") {
|
||||
throw typeError("Tool paths must use string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return { value: objectValue.length }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
if (index !== undefined) return { value: objectValue[index] }
|
||||
}
|
||||
const proto = primitivePrototype(self.ctx.builtins, objectValue)
|
||||
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
|
||||
|
||||
: 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 '${String(key)}').`, objectNode)
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${coerceToString(keyValue)}').`, objectNode)
|
||||
}
|
||||
throw typeError("Cannot access a property on a non-object value.", objectNode)
|
||||
const key = yield* self.toPropertyKey(keyValue, propertyNode)
|
||||
return self.resolveProperty(objectValue, key, objectNode, propertyNode)
|
||||
})
|
||||
}
|
||||
|
||||
private resolveProperty(
|
||||
objectValue: Value,
|
||||
key: PropertyKey,
|
||||
objectNode: AstNode,
|
||||
propertyNode: AstNode,
|
||||
): MemberReference | ToolReference | { value: Value } {
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string") {
|
||||
throw typeError("Tool paths must use string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return { value: objectValue.length }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
if (index !== undefined) return { value: objectValue[index] }
|
||||
}
|
||||
const proto = primitivePrototype(this.ctx.builtins, objectValue)
|
||||
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
|
||||
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
|
||||
}
|
||||
throw typeError("Cannot access a property on a non-object value.", objectNode)
|
||||
}
|
||||
|
||||
// One destructured property, read the way a member expression would read it (primitives use their prototype).
|
||||
private destructuredProperty(source: Value, key: PropertyKey, node: AstNode): Value {
|
||||
const reference = this.resolveProperty(source, key, node, node)
|
||||
if (reference instanceof ToolReference) return reference
|
||||
if ("value" in reference) return reference.value
|
||||
return this.readProperty(reference.target, reference.key, node, reference.receiver)
|
||||
}
|
||||
|
||||
private readReference(reference: MemberReference, node: MemberExpression): Value {
|
||||
// Reject unknown promise properties so a missing await cannot hide.
|
||||
if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) {
|
||||
@@ -2255,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(
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -146,20 +148,30 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"join",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const joined = self(thisValue, "join")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(args[0] === undefined ? "," : coerceToString(args[0]))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
// .map would keep holes, which Effect.forEach would then hand to the body as undefined.
|
||||
const parts = Array.from(self(thisValue, "join").items, (item) => item ?? "")
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
[args[0] === undefined ? "," : args[0], ...parts],
|
||||
([separator, ...items]) => {
|
||||
const joined = items.map(coerceToString).join(coerceToString(separator))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
[
|
||||
"toString",
|
||||
0,
|
||||
(thisValue) =>
|
||||
self(thisValue, "toString")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(","),
|
||||
withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
Array.from(self(thisValue, "toString").items, (item) => item ?? ""),
|
||||
(items) => items.map(coerceToString).join(","),
|
||||
),
|
||||
],
|
||||
[
|
||||
"includes",
|
||||
@@ -362,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)
|
||||
}
|
||||
@@ -401,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
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ const constructDate = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) =
|
||||
: new DateObj(proto, new Date(coerceToNumber(value)).getTime()),
|
||||
)
|
||||
}
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return Effect.succeed(new DateObj(proto, new Date(...(parts as [number, number])).getTime()))
|
||||
// The spec converts at most seven components, in order, so extra arguments never run program code.
|
||||
return Effect.map(
|
||||
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
|
||||
(parts) => new DateObj(proto, new Date(...(parts as [number, number])).getTime()),
|
||||
)
|
||||
}
|
||||
|
||||
type Getter = keyof {
|
||||
@@ -79,7 +82,15 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
methods(builtins, date, [
|
||||
["now", 0, () => Date.now()],
|
||||
["parse", 1, (_, args) => Date.parse(coerceToString(args[0]))],
|
||||
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
|
||||
[
|
||||
"UTC",
|
||||
7,
|
||||
(_, args) =>
|
||||
Effect.map(
|
||||
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
|
||||
(parts) => Date.UTC(...(parts as Parameters<typeof Date.UTC>)),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
|
||||
@@ -125,6 +136,8 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
concurrency: 1,
|
||||
}),
|
||||
(values) => {
|
||||
// Every setter but setTime and setFullYear leaves an invalid Date untouched and answers NaN.
|
||||
if (Number.isNaN(hosted.getTime()) && name !== "setTime" && !name.endsWith("FullYear")) return NaN
|
||||
target.time = hosted[name](...(values as [number, number, number, number]))
|
||||
return target.time
|
||||
},
|
||||
|
||||
@@ -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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { constants, type Method, methods } from "../interpreter/native.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { Obj, coerceToNumber } from "../interpreter/objects.js"
|
||||
import { preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
|
||||
@@ -12,25 +12,27 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate only the arguments a method consumes; like JS, extras are ignored
|
||||
// (so built-ins work as callbacks receiving (element, index, array)).
|
||||
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(coerceToNumber(args[0]))]
|
||||
|
||||
const binary = (name: string, op: (a: number, b: number) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => op(coerceToNumber(args[0]), coerceToNumber(args[1])),
|
||||
]
|
||||
|
||||
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => op(...args.map(coerceToNumber)),
|
||||
]
|
||||
|
||||
export const mathGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const math = new Obj(builtins.Object)
|
||||
// Convert only the arguments a method consumes; like JS, extras are ignored
|
||||
// (so built-ins work as callbacks receiving (element, index, array)).
|
||||
const unary = (name: string, op: (a: number) => number): Method => [
|
||||
name,
|
||||
1,
|
||||
(_, args) => withPrimitives(ctx, "number", [args[0]], ([a]) => op(coerceToNumber(a))),
|
||||
]
|
||||
const binary = (name: string, op: (a: number, b: number) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) =>
|
||||
withPrimitives(ctx, "number", [args[0], args[1]], ([a, b]) => op(coerceToNumber(a), coerceToNumber(b))),
|
||||
]
|
||||
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => withPrimitives(ctx, "number", args, (values) => op(...values.map(coerceToNumber))),
|
||||
]
|
||||
constants(math, {
|
||||
PI: Math.PI,
|
||||
E: Math.E,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { constructor, constants, methods } from "../interpreter/native.js"
|
||||
import { coerceToNumber, coerceToString, type Value } from "../interpreter/objects.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 { coercion } from "./value.js"
|
||||
import { withPrimitives } from "../interpreter/callback.js"
|
||||
import { coerce, coercion } from "./value.js"
|
||||
|
||||
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
@@ -26,46 +27,36 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["isFinite", 1, (_, args) => Number.isFinite(args[0])],
|
||||
["isNaN", 1, (_, args) => Number.isNaN(args[0])],
|
||||
["isSafeInteger", 1, (_, args) => Number.isSafeInteger(args[0])],
|
||||
[
|
||||
"parseInt",
|
||||
2,
|
||||
(_, args) => {
|
||||
return parseInt(coerceToString(args[0]), coerceToNumber(args[1]))
|
||||
},
|
||||
],
|
||||
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
|
||||
["parseInt", 2, (_, args) => coerce(ctx, "parseInt", args)],
|
||||
["parseFloat", 1, (_, args) => coerce(ctx, "parseFloat", args)],
|
||||
])
|
||||
|
||||
const self = (thisValue: Value, name: string): number => {
|
||||
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
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { fn } from "../interpreter/native.js"
|
||||
import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { withPrimitives } from "../interpreter/callback.js"
|
||||
|
||||
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
|
||||
|
||||
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
|
||||
|
||||
const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Value => {
|
||||
export const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>) => {
|
||||
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
|
||||
// other coercers match native through the undefined-argument path below.
|
||||
if (args.length === 0) {
|
||||
@@ -14,15 +15,19 @@ const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Val
|
||||
if (name === "String") return ""
|
||||
}
|
||||
const raw = args[0]
|
||||
if (name === "Number") return coerceToNumber(raw)
|
||||
if (name === "Boolean") return Boolean(raw)
|
||||
if (name === "isFinite") return Number.isFinite(coerceToNumber(raw))
|
||||
if (name === "isNaN") return Number.isNaN(coerceToNumber(raw))
|
||||
if (name === "parseInt") {
|
||||
return parseInt(coerceToString(raw), coerceToNumber(args[1]))
|
||||
return withPrimitives(ctx, ["string", "number"], [raw, args[1]], ([text, radix]) =>
|
||||
parseInt(coerceToString(text), coerceToNumber(radix)),
|
||||
)
|
||||
}
|
||||
if (name === "parseFloat") return parseFloat(coerceToString(raw))
|
||||
return coerceToString(raw)
|
||||
return withPrimitives(ctx, name === "String" || name === "parseFloat" ? "string" : "number", [raw], ([value]) => {
|
||||
if (name === "Number") return coerceToNumber(value)
|
||||
if (name === "isFinite") return Number.isFinite(coerceToNumber(value))
|
||||
if (name === "isNaN") return Number.isNaN(coerceToNumber(value))
|
||||
if (name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
return coerceToString(value)
|
||||
})
|
||||
}
|
||||
|
||||
/** A global coercion function such as `Number` or `parseInt`. */
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -1571,3 +1571,416 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ToPrimitive: operators and conversions honor program valueOf and toString", () => {
|
||||
test("program-installed valueOf and toString on opaque values are ignored at every site", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const f = () => 1
|
||||
f.toString = () => "custom"
|
||||
f.valueOf = () => 5
|
||||
return [String(f), \`\${f}\`, [f].join(), new Error(f).message, isNaN(Number(f)), isNaN(Math.abs(f))]
|
||||
`),
|
||||
).toEqual(["[object Function]", "[object Function]", "[object Function]", "[object Function]", true, true])
|
||||
})
|
||||
|
||||
test("== converts an object facing a non-nullish primitive through its own valueOf", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const one = { valueOf() { return 1 } }
|
||||
return [one == 1, 1 == one, one == true, one == "1", one == null, one == one, one == { valueOf() { return 1 } }, [1] == 1]
|
||||
`),
|
||||
).toEqual([true, true, true, true, false, true, false, true])
|
||||
expect((await error(`(() => 1) == 1`)).message).toContain("Binary operators require data values")
|
||||
})
|
||||
|
||||
test("operators, unary, template literals, and conversion functions use the object's own methods", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const money = { valueOf() { return 7 } }
|
||||
return [money * 2, money + 1, money + "", -money, +money, ~money, money < 8, money ** 2, money | 8,
|
||||
Number(money), Math.max(money, 1), \`\${money}\`, String(money), isNaN(money), isFinite(money),
|
||||
parseInt({ toString() { return "42px" } }), parseInt("ff", { valueOf() { return 16 } }),
|
||||
Number.parseFloat({ toString() { return "1.5" } })]
|
||||
`),
|
||||
).toEqual([
|
||||
14,
|
||||
8,
|
||||
"7",
|
||||
-7,
|
||||
7,
|
||||
-8,
|
||||
true,
|
||||
49,
|
||||
15,
|
||||
7,
|
||||
7,
|
||||
"[object Object]",
|
||||
"[object Object]",
|
||||
false,
|
||||
true,
|
||||
42,
|
||||
255,
|
||||
1.5,
|
||||
])
|
||||
})
|
||||
|
||||
test("the hint picks the method: + and Number prefer valueOf, template literals and String prefer toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const both = { valueOf() { return 1 }, toString() { return "s" } }
|
||||
return [both + "", \`\${both}\`, String(both), both * 2, new Error(both).message, [both].join(), [both, 2] + ""]
|
||||
`),
|
||||
).toEqual(["1", "s", "s", 2, "s", "s", "s,2"])
|
||||
})
|
||||
|
||||
test("operands convert left then right, and a throwing valueOf surfaces as the program error", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const a = { valueOf() { order.push("a"); return 1 } }, b = { valueOf() { order.push("b"); return 2 } }
|
||||
a + b; a < b; a - b
|
||||
return order
|
||||
`),
|
||||
).toEqual(["a", "b", "a", "b", "a", "b"])
|
||||
expect(
|
||||
await value(`
|
||||
const bad = { valueOf() { throw new RangeError("nope") } }
|
||||
const names = []
|
||||
try { bad + 1 } catch (e) { names.push(e.name) }
|
||||
try { Number(bad) } catch (e) { names.push(e.name) }
|
||||
try { Math.abs(bad) } catch (e) { names.push(e.name) }
|
||||
return names
|
||||
`),
|
||||
).toEqual(["RangeError", "RangeError", "RangeError"])
|
||||
})
|
||||
|
||||
test("arrays keep their built-in join form unless the program replaces toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const arr = [1, 2]
|
||||
const before = [arr + "", [] + [], [1, , 3].join("-"), [1, { toString() { return "q" } }].join("-")]
|
||||
arr.toString = () => "x"
|
||||
return [...before, arr + "", \`\${arr}\`, String(arr)]
|
||||
`),
|
||||
).toEqual(["1,2", "", "1--3", "1-q", "x", "x", "x"])
|
||||
})
|
||||
|
||||
test("update and compound assignment convert the current value", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let x = { valueOf() { return 5 } }
|
||||
const o = { n: { valueOf() { return 4 } } }
|
||||
const after = x++
|
||||
o.n += 1
|
||||
o.n++
|
||||
let s = { valueOf() { return 2 } }
|
||||
s *= 3
|
||||
return [after, x, o.n, s]
|
||||
`),
|
||||
).toEqual([5, 6, 6, 6])
|
||||
})
|
||||
|
||||
test("functions and other opaque values still reject arithmetic, and an object without a primitive form throws", async () => {
|
||||
expect((await error(`const f = () => 1; return f + 1`)).message).toContain("Binary operators require data values")
|
||||
expect((await error(`return -(() => 1)`)).message).toContain("Unary operators require data values")
|
||||
const failure = await error(`return { valueOf() { return {} }, toString() { return [] } } + 1`)
|
||||
expect(failure.message).toContain("Cannot convert object to primitive value")
|
||||
})
|
||||
})
|
||||
|
||||
describe("object destructuring from primitives", () => {
|
||||
test("reads through the primitive's prototype like member access", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const { length, 0: first, toUpperCase } = "abc"
|
||||
const { toFixed } = 1.5
|
||||
const {} = true
|
||||
const { 0: a, ...rest } = "xyz"
|
||||
const { ...none } = 42
|
||||
let n
|
||||
;({ length: n } = "hello")
|
||||
return [length, first, toUpperCase.call("q"), toFixed.call(2.345, 1), a, rest, none, n]
|
||||
`),
|
||||
).toEqual([3, "a", "Q", "2.3", "x", { 1: "y", 2: "z" }, {}, 5])
|
||||
})
|
||||
|
||||
test("only null and undefined sources throw", async () => {
|
||||
expect((await error(`const { a } = null`)).message).toContain("Cannot destructure null as it is null")
|
||||
expect((await error(`const {} = undefined`)).message).toContain("Cannot destructure undefined")
|
||||
expect((await error(`let a; ({ a } = undefined)`)).message).toContain("Cannot destructure undefined")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Date components convert through ToPrimitive", () => {
|
||||
test("construction and Date.UTC ask each of the first seven arguments in order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const seen = []
|
||||
const part = (n) => ({ valueOf() { seen.push(n); return n } })
|
||||
const time = new Date(part(2024), part(1), part(2), part(3), part(4), part(5), part(6), part(99)).getTime()
|
||||
const utc = Date.UTC(2024, { valueOf() { return 0 } }, 15)
|
||||
return [seen, time === new Date(2024, 1, 2, 3, 4, 5, 6).getTime(), utc === Date.UTC(2024, 0, 15)]
|
||||
`),
|
||||
).toEqual([[2024, 1, 2, 3, 4, 5, 6], true, true])
|
||||
expect((await error(`new Date(2024, { valueOf() { throw new RangeError("boom") } })`)).message).toContain("boom")
|
||||
})
|
||||
|
||||
test("setters on an invalid Date answer NaN without overwriting a time set during coercion", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const d = new Date(NaN)
|
||||
const result = d.setDate({ valueOf() { d.setTime(0); return 1 } })
|
||||
const y = new Date(NaN)
|
||||
return [Number.isNaN(result), d.getTime(), y.setFullYear(2020) === Date.UTC(2020, 0, 1) - y.getTimezoneOffset() * 60000]
|
||||
`),
|
||||
).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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
|
||||
|
||||
## Layout
|
||||
|
||||
- `manifest.json` — the pinned upstream revision, which upstream directories are copied, and what is left out.
|
||||
- `manifest.json` — the pinned upstream revision, which upstream directories are copied (every `built-ins` and
|
||||
`language` directory, about 14,900 files after filtering), and what is left out.
|
||||
- `built-ins/`, `language/` — the copied files, mirroring upstream `test/`; gitignored.
|
||||
- `skipped.txt` — vendored files that fail on a known interpreter gap, one `path # reason` per line. They are
|
||||
skipped, and each gap is listed as unchecked in `interpreter-support.md`.
|
||||
@@ -26,7 +27,8 @@ 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, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
|
||||
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.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
{
|
||||
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
|
||||
"directories": [
|
||||
"built-ins/Array/prototype",
|
||||
"built-ins/Function/prototype/apply",
|
||||
"built-ins/Function/prototype/bind",
|
||||
"built-ins/Function/prototype/call",
|
||||
"built-ins/Iterator",
|
||||
"built-ins/Object/freeze",
|
||||
"built-ins/Object/getPrototypeOf",
|
||||
"built-ins/Object/is",
|
||||
"built-ins/Object/isExtensible",
|
||||
"built-ins/Object/isFrozen",
|
||||
"built-ins/Object/isSealed",
|
||||
"built-ins/Object/preventExtensions",
|
||||
"built-ins/String/raw",
|
||||
"language/arguments-object",
|
||||
"language/expressions/does-not-equals",
|
||||
"language/expressions/equals",
|
||||
"language/expressions/tagged-template",
|
||||
"language/expressions/this",
|
||||
"language/statements"
|
||||
],
|
||||
"directories": ["built-ins", "language"],
|
||||
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
|
||||
"flags": ["module", "raw", "noStrict"],
|
||||
"boundaries": {
|
||||
"class": "\\bclass\\s*[A-Za-z_${]",
|
||||
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
|
||||
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
|
||||
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
|
||||
"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(WeakRef|FinalizationRegistry)\\b",
|
||||
"Reflect and Proxy": "\\b(Reflect|Proxy)\\b",
|
||||
"sloppy mode": "\\bwith\\s*\\(",
|
||||
"eval": "\\b(eval|Function)\\b",
|
||||
"new.target": "\\bnew\\.target\\b",
|
||||
@@ -91,8 +74,6 @@
|
||||
"Uint16Array",
|
||||
"Uint32Array",
|
||||
"uint8array-base64",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"WeakRef",
|
||||
"FinalizationRegistry",
|
||||
"Intl-enumeration",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -354,3 +354,17 @@ describe("tools.search alias", () => {
|
||||
expect(await value(runtime, `return await tools.search({})`)).toBe("custom")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool references under ==", () => {
|
||||
test("compare by identity against data objects without converting them", async () => {
|
||||
const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok") } })
|
||||
expect(
|
||||
await value(
|
||||
runtime,
|
||||
`let calls = 0
|
||||
const o = { valueOf() { calls++; return 1 } }
|
||||
return [o == tools.probe, tools == { a: 1 }, tools.probe == null, calls]`,
|
||||
),
|
||||
).toEqual([false, false, false, 0])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.16",
|
||||
"version": "2.0.18",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user