mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-22 08:37:36 +00:00
Compare commits
64
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e444c4b1b8 | ||
|
|
19e1357a06 | ||
|
|
4b381ac6a1 | ||
|
|
07d48e1ffb | ||
|
|
9fdcb8da41 | ||
|
|
ba61ac6730 | ||
|
|
94b9133910 | ||
|
|
6f8c5ae0aa | ||
|
|
60673aaef3 | ||
|
|
651529d64e | ||
|
|
532f25d0d4 | ||
|
|
643c4c3500 | ||
|
|
9d531435b4 | ||
|
|
5b9dc35eec | ||
|
|
1814dd9799 | ||
|
|
1e1cd042ea | ||
|
|
02566f6219 | ||
|
|
f488aa3f79 | ||
|
|
97457ec7a3 | ||
|
|
d62049aab4 | ||
|
|
096ac95773 | ||
|
|
4cc9b90f27 | ||
|
|
990463aa9f | ||
|
|
cd39063622 | ||
|
|
4c944a86d7 | ||
|
|
97e833a297 | ||
|
|
b8aa08f260 | ||
|
|
3f0118022b | ||
|
|
6f76c31ca7 | ||
|
|
f90beeb9b8 | ||
|
|
4b9a3d80fc | ||
|
|
1316576720 | ||
|
|
2f0c861af0 | ||
|
|
4d94777d4d | ||
|
|
46ebde65e9 | ||
|
|
932c12ad1d | ||
|
|
6f655dcbab | ||
|
|
ab60f08c69 | ||
|
|
60ed84ecd1 | ||
|
|
8aebed170a | ||
|
|
14148a0ea4 | ||
|
|
7f51fbd878 | ||
|
|
cbdd1f66da | ||
|
|
788eb0fa29 | ||
|
|
58fcad77a8 | ||
|
|
c555559ac1 | ||
|
|
e3a3fa7108 | ||
|
|
6238af397e | ||
|
|
7ffd75faf6 | ||
|
|
1f8ab95695 | ||
|
|
62dc1f7696 | ||
|
|
66f10ab7bf | ||
|
|
1ca8f63a79 | ||
|
|
03bcdd580d | ||
|
|
cc502f7e5f | ||
|
|
64ce8771c0 | ||
|
|
702a73d91a | ||
|
|
991b727eb8 | ||
|
|
ba342ce227 | ||
|
|
1464545665 | ||
|
|
fecacc9e68 | ||
|
|
1f73b4806b | ||
|
|
da2ce02596 | ||
|
|
25f35dcfb8 |
@@ -112,11 +112,12 @@ jobs:
|
||||
- name: Run unit tests
|
||||
timeout-minutes: 20
|
||||
run: |
|
||||
# The runners have four vCPUs, and each Bun test process performs its own concurrent work.
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
GITHUB_ACTIONS=false bun turbo test
|
||||
GITHUB_ACTIONS=false bun turbo test --concurrency=3
|
||||
exit 0
|
||||
fi
|
||||
GITHUB_ACTIONS=false bun turbo test --affected
|
||||
GITHUB_ACTIONS=false bun turbo test --affected --concurrency=3
|
||||
env:
|
||||
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: ${{ runner.os == 'Windows' && 'true' || 'false' }}
|
||||
TURBO_SCM_BASE: ${{ github.event_name == 'pull_request' && format('{0}^1', github.sha) || github.event.before }}
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -111,7 +111,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -175,7 +175,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -201,7 +201,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -214,7 +214,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -250,7 +250,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -277,7 +277,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -294,7 +294,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -318,7 +318,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -338,7 +338,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -406,10 +406,10 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
"electron-context-menu": "5.0.0",
|
||||
"electron-log": "^5",
|
||||
"electron-updater": "6.8.9",
|
||||
"lighthouse": "13.4.1",
|
||||
@@ -436,7 +436,7 @@
|
||||
"drizzle-kit": "catalog:",
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"electron": "42.10.1",
|
||||
"electron": "44.4.3",
|
||||
"electron-builder": "26.15.7",
|
||||
"electron-vite": "6.0.0-beta.1",
|
||||
"puppeteer-core": "25.9.0",
|
||||
@@ -455,7 +455,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -492,7 +492,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -508,7 +508,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -527,7 +527,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -540,7 +540,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -554,7 +554,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -569,7 +569,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -608,7 +608,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -638,7 +638,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -653,7 +653,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -677,7 +677,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -698,7 +698,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -720,7 +720,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -755,7 +755,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -775,7 +775,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -809,7 +809,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -828,7 +828,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -874,7 +874,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -888,7 +888,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -923,7 +923,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -958,7 +958,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -991,7 +991,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1032,7 +1032,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
@@ -3880,13 +3880,13 @@
|
||||
|
||||
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
|
||||
|
||||
"electron": ["electron@42.10.1", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-ITc1HPeoDzsxCCaH6MFsN67Nq2nUiJf5N9pBWxzhUpFfKJF0IwiAkKT3emg1lPbvC4OfFE+Cdwe4vhgdOZ1YKg=="],
|
||||
"electron": ["electron@44.4.3", "", { "dependencies": { "@electron-internal/extract-zip": "^1.0.1", "@electron/get": "^5.0.0", "@types/node": "^24.9.0" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-LTpSFTB40qVCXIX5xMo+cgHI/Jjkbjw7VpB26PccEbroqOn72LBukeaDwPVo1fBYzSzs0c9iPuAucFCO7Tw81Q=="],
|
||||
|
||||
"electron-builder": ["electron-builder@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "builder-util-runtime": "9.7.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.15.7", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "./cli.js", "install-app-deps": "./install-app-deps.js" } }, "sha512-DBpaNzxsPs1BvEblzFoNriSbzsBqDCy/gseIngeEhYzQG1IxfB7Hvc2tBBVmpWE2BTQGP9J1RrAvDT+Vc/uAxg=="],
|
||||
|
||||
"electron-builder-squirrel-windows": ["electron-builder-squirrel-windows@26.15.7", "", { "dependencies": { "app-builder-lib": "26.15.7", "builder-util": "26.15.3", "electron-winstaller": "5.4.0" } }, "sha512-B4uvn2NzFSuf084udWqugludFull6CRJiWe2dLzMnZLl6G5hdAGk0fsBMGlBSpKjvQCJn8IPc+S7OnJ+GXqwLA=="],
|
||||
|
||||
"electron-context-menu": ["electron-context-menu@4.1.2", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-9xYTUV0oRqKL50N9W71IrXNdVRB0LuBp3R1zkUdUc2wfIa2/QZwYYj5RLuO7Tn7ZSLVIaO3X6u+EIBK+cBvzrQ=="],
|
||||
"electron-context-menu": ["electron-context-menu@5.0.0", "", { "dependencies": { "cli-truncate": "^4.0.0", "electron-dl": "^4.0.0", "electron-is-dev": "^3.0.1" } }, "sha512-rgFpRtwY0/rhsRCoz9rE6VM4WueEsLbIpca7ucOhERVrbGB2dQrxa9xwBNLplU54jRgPuv6nTEbxoplE5bzy2A=="],
|
||||
|
||||
"electron-dl": ["electron-dl@4.0.0", "", { "dependencies": { "ext-name": "^5.0.0", "pupa": "^3.1.0", "unused-filename": "^4.0.1" } }, "sha512-USiB9816d2JzKv0LiSbreRfTg5lDk3lWh0vlx/gugCO92ZIJkHVH0UM18EHvKeadErP6Xn4yiTphWzYfbA2Ong=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-8mOzCscBAuogG4tm8CroqjTX5E5yzCvTobbhKvxQP0U=",
|
||||
"aarch64-linux": "sha256-drrRSpzxC8bfaTXBpOyaN0QAyBVV7WJzm0NstJ+8sAE=",
|
||||
"aarch64-darwin": "sha256-YY5A/zxLPONvgnI+DZlzcD2K5Q9PIw4F2YbDxKbT4UU=",
|
||||
"x86_64-darwin": "sha256-/c/Ew4onA+9+l6GRKXz3zWq3QFESM7j5RyGLCWRPQFw="
|
||||
"x86_64-linux": "sha256-/jah4P2a0aGJNJ0aMdlFEbGHAXx33lqxHbc7UmpLFDg=",
|
||||
"aarch64-linux": "sha256-L3SoZ24qNXicsE2FK6LATQjOmjPxY679RjugrUyO/1Y=",
|
||||
"aarch64-darwin": "sha256-pI9NT8KWUPi+JCk6DYMqIAYmBqTbi13uL4VdNV3WS6Y=",
|
||||
"x86_64-darwin": "sha256-rMAGhTTz46KA5Ya7E5J0af7Bn1QzTDhTaNfNJm8qfsw="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -29,6 +29,47 @@ await Effect.runPromise(program.pipe(Effect.provide(llmLayer)))
|
||||
|
||||
Run `LLMClient.stream(request)` instead of `generate` when you want incremental `LLMEvent`s. The event stream is provider-neutral — same shape across OpenAI Chat, OpenAI Responses, Anthropic Messages, Gemini, Bedrock Converse, and any OpenAI-compatible deployment.
|
||||
|
||||
## Experimental evaluation
|
||||
|
||||
Evaluation models compare shared state with typed choice, score, and boolean questions. The API is
|
||||
isolated under an experimental entrypoint and provider namespace while the contract evolves:
|
||||
|
||||
```ts
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
|
||||
import { TypeSafeAI } from "@opencode/ai/providers"
|
||||
|
||||
const model = TypeSafeAI.configure().experimental.evaluation("jev-latest")
|
||||
|
||||
const program = Evaluation.run({
|
||||
model,
|
||||
state: "I was charged twice. Please refund the duplicate payment.",
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this?",
|
||||
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this?",
|
||||
criteria: ["Can wait", "Needs prompt attention", "Blocking revenue"],
|
||||
},
|
||||
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
|
||||
},
|
||||
})
|
||||
|
||||
const response = await Effect.runPromise(program.pipe(Effect.provide(EvaluationClient.fetchLayer)))
|
||||
|
||||
console.log(response.answers.department.choice)
|
||||
console.log(response.answers.refund.probability)
|
||||
```
|
||||
|
||||
`TypeSafeAI` reads `TYPESAFE_API_KEY`. `OpenCodeZen` exposes the same selector and reads
|
||||
`OPENCODE_API_KEY`. The common API uses `boolean`; System One routes lower it to native `noul`.
|
||||
Choice and score confidence plus score legends remain available in provider metadata, and the
|
||||
provider's rounded probabilities are returned unchanged.
|
||||
|
||||
## Alibaba Cloud Model Studio
|
||||
|
||||
`Alibaba` provides standard Model Studio inference. Configure a region explicitly, then select
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
export { EvaluationClient } from "./experimental/evaluation-client.js"
|
||||
export {
|
||||
BooleanAnswer,
|
||||
BooleanQuestion,
|
||||
ChoiceAnswer,
|
||||
ChoiceQuestion,
|
||||
Evaluation,
|
||||
EvaluationAnswer,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationModelSchema,
|
||||
EvaluationQuestion,
|
||||
EvaluationRequest,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreAnswer,
|
||||
ScoreQuestion,
|
||||
} from "./experimental/evaluation.js"
|
||||
export type {
|
||||
AnswerFor,
|
||||
AnswersFor,
|
||||
EvaluationModelOptions,
|
||||
EvaluationOptions,
|
||||
EvaluationQuestions,
|
||||
EvaluationRequestFor,
|
||||
EvaluationRequestInput,
|
||||
EvaluationResponseFor,
|
||||
EvaluationRoute,
|
||||
} from "./experimental/evaluation.js"
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import { RequestExecutor } from "../route/executor.js"
|
||||
import { mergeHttpOptions, type AIError } from "../schema/index.js"
|
||||
import { sanitizeSurrogates } from "../utils/sanitize.js"
|
||||
import {
|
||||
type EvaluationOptions,
|
||||
type EvaluationQuestions,
|
||||
type EvaluationRequestFor,
|
||||
type EvaluationResponseFor,
|
||||
} from "./evaluation.js"
|
||||
|
||||
export type Execute = RequestExecutor.Interface["execute"]
|
||||
|
||||
export interface Interface {
|
||||
readonly evaluate: <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AI/Experimental/EvaluationClient") {}
|
||||
|
||||
export const evaluate = <Options extends EvaluationOptions, const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service> =>
|
||||
Effect.flatMap(Service, (client) => client.evaluate(request))
|
||||
|
||||
export const layer: Layer.Layer<Service, never, RequestExecutor.Service> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
return Service.of({
|
||||
evaluate: (request) =>
|
||||
request.model.route.evaluate(
|
||||
{
|
||||
...sanitizeSurrogates({
|
||||
...request,
|
||||
model: undefined,
|
||||
http: mergeHttpOptions(request.model.http, request.http),
|
||||
}),
|
||||
model: request.model,
|
||||
},
|
||||
executor.execute,
|
||||
),
|
||||
})
|
||||
}),
|
||||
)
|
||||
export const fetchLayer = layer.pipe(Layer.provide(RequestExecutor.fetchLayer))
|
||||
|
||||
export const EvaluationClient = {
|
||||
Service,
|
||||
layer,
|
||||
fetchLayer,
|
||||
evaluate,
|
||||
} as const
|
||||
@@ -0,0 +1,245 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import {
|
||||
AIError,
|
||||
HttpOptions,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
ProviderID,
|
||||
ProviderMetadata,
|
||||
Usage,
|
||||
} from "../schema/index.js"
|
||||
import { EvaluationClient, Service, type Execute } from "./evaluation-client.js"
|
||||
|
||||
export const EvaluationInput = Schema.Union([Schema.String, Schema.JsonObject, Schema.Array(Schema.Json)])
|
||||
export type EvaluationInput = Schema.Schema.Type<typeof EvaluationInput>
|
||||
|
||||
const EvaluationCriterion = Schema.NullOr(EvaluationInput)
|
||||
const ChoiceCriteria = Schema.Record(Schema.String, EvaluationCriterion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Choice criteria must be a nonempty option map",
|
||||
}),
|
||||
)
|
||||
|
||||
export const ChoiceQuestion = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: ChoiceCriteria,
|
||||
})
|
||||
export type ChoiceQuestion = Schema.Schema.Type<typeof ChoiceQuestion>
|
||||
|
||||
export const ScoreQuestion = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.Array(EvaluationCriterion).check(Schema.isMinLength(2)),
|
||||
})
|
||||
export type ScoreQuestion = Schema.Schema.Type<typeof ScoreQuestion>
|
||||
|
||||
export const BooleanQuestion = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(EvaluationCriterion),
|
||||
false: Schema.optional(EvaluationCriterion),
|
||||
}),
|
||||
),
|
||||
})
|
||||
export type BooleanQuestion = Schema.Schema.Type<typeof BooleanQuestion>
|
||||
|
||||
export const EvaluationQuestion = Schema.Union([ChoiceQuestion, ScoreQuestion, BooleanQuestion]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationQuestion = Schema.Schema.Type<typeof EvaluationQuestion>
|
||||
export type EvaluationQuestions = Readonly<Record<string, EvaluationQuestion>>
|
||||
const EvaluationQuestions = Schema.Record(Schema.String, EvaluationQuestion).pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x).length > 0, {
|
||||
message: "Evaluation questions must be a nonempty map",
|
||||
}),
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
|
||||
export const ChoiceAnswer = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
})
|
||||
export type ChoiceAnswer = Schema.Schema.Type<typeof ChoiceAnswer>
|
||||
|
||||
export const ScoreAnswer = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.optional(Schema.Record(Schema.String, Probability)),
|
||||
})
|
||||
export type ScoreAnswer = Schema.Schema.Type<typeof ScoreAnswer>
|
||||
|
||||
export const BooleanAnswer = Schema.Struct({
|
||||
type: Schema.Literal("boolean"),
|
||||
probability: Probability,
|
||||
})
|
||||
export type BooleanAnswer = Schema.Schema.Type<typeof BooleanAnswer>
|
||||
|
||||
export const EvaluationAnswer = Schema.Union([ChoiceAnswer, ScoreAnswer, BooleanAnswer]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type EvaluationAnswer = Schema.Schema.Type<typeof EvaluationAnswer>
|
||||
|
||||
export type AnswerFor<Question extends EvaluationQuestion> = Question extends {
|
||||
readonly type: "choice"
|
||||
readonly criteria: infer Criteria
|
||||
}
|
||||
? {
|
||||
readonly type: "choice"
|
||||
readonly choice: Extract<keyof Criteria, string>
|
||||
readonly probabilities?: Readonly<Record<Extract<keyof Criteria, string>, number>>
|
||||
}
|
||||
: Question extends { readonly type: "score" }
|
||||
? ScoreAnswer
|
||||
: BooleanAnswer
|
||||
|
||||
export type AnswersFor<Questions extends EvaluationQuestions> = {
|
||||
readonly [ID in keyof Questions]: AnswerFor<Questions[ID]>
|
||||
}
|
||||
|
||||
export type EvaluationOptions = Record<string, unknown>
|
||||
|
||||
export interface EvaluationRoute<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: string
|
||||
readonly evaluate: <const Questions extends EvaluationQuestions>(
|
||||
request: EvaluationRequestFor<Options, Questions>,
|
||||
execute: Execute,
|
||||
) => Effect.Effect<EvaluationResponseFor<Questions>, AIError>
|
||||
}
|
||||
|
||||
export class EvaluationModel<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
declare protected readonly _Options: (options: Options) => Options
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
|
||||
constructor(input: EvaluationModel.Input<Options>) {
|
||||
this.id = input.id
|
||||
this.provider = input.provider
|
||||
this.route = input.route
|
||||
this.http = input.http
|
||||
}
|
||||
|
||||
static make<Options extends EvaluationOptions = EvaluationOptions>(input: EvaluationModel.MakeInput<Options>) {
|
||||
return new EvaluationModel<Options>({
|
||||
id: ModelID.make(input.id),
|
||||
provider: ProviderID.make(input.provider),
|
||||
route: input.route,
|
||||
http: input.http,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export namespace EvaluationModel {
|
||||
export interface Input<Options extends EvaluationOptions = EvaluationOptions> {
|
||||
readonly id: ModelID
|
||||
readonly provider: ProviderID
|
||||
readonly route: EvaluationRoute<Options>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export interface MakeInput<Options extends EvaluationOptions = EvaluationOptions>
|
||||
extends Omit<Input<Options>, "id" | "provider"> {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string | ProviderID
|
||||
}
|
||||
}
|
||||
|
||||
export const EvaluationModelSchema = Schema.declare(
|
||||
(value): value is EvaluationModel => value instanceof EvaluationModel,
|
||||
{
|
||||
expected: "Evaluation.Model",
|
||||
},
|
||||
)
|
||||
|
||||
export class EvaluationRequest extends Schema.Class<EvaluationRequest>("Evaluation.Request")({
|
||||
model: EvaluationModelSchema,
|
||||
state: EvaluationInput,
|
||||
questions: EvaluationQuestions,
|
||||
options: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
|
||||
http: Schema.optional(HttpOptions),
|
||||
}) {
|
||||
declare protected readonly _EvaluationRequest: void
|
||||
}
|
||||
|
||||
export type EvaluationModelOptions<Model> = Model extends EvaluationModel<infer Options> ? Options : never
|
||||
|
||||
export type EvaluationRequestFor<
|
||||
Options extends EvaluationOptions = EvaluationOptions,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<EvaluationRequest, "model" | "questions" | "options"> & {
|
||||
readonly model: EvaluationModel<Options>
|
||||
readonly questions: Questions
|
||||
readonly options?: Options
|
||||
}
|
||||
|
||||
export type EvaluationRequestInput<
|
||||
Model extends object = EvaluationModel,
|
||||
Questions extends EvaluationQuestions = EvaluationQuestions,
|
||||
> = Omit<ConstructorParameters<typeof EvaluationRequest>[0], "model" | "questions" | "options" | "http"> & {
|
||||
readonly model: Model
|
||||
readonly questions: Questions
|
||||
readonly options?: NoInfer<EvaluationModelOptions<Model>>
|
||||
readonly http?: HttpOptions.Input
|
||||
} & (Model extends EvaluationModel<EvaluationModelOptions<Model>> ? unknown : never)
|
||||
|
||||
export class EvaluationRounding extends Schema.Class<EvaluationRounding>("Evaluation.Rounding")({
|
||||
probabilityDecimals: Schema.optional(Schema.Int),
|
||||
scoreDecimals: Schema.optional(Schema.Int),
|
||||
}) {}
|
||||
|
||||
export class EvaluationResponse extends Schema.Class<EvaluationResponse>("Evaluation.Response")({
|
||||
model: ModelID,
|
||||
answers: Schema.Record(Schema.String, EvaluationAnswer),
|
||||
usage: Schema.optional(Usage),
|
||||
rounding: Schema.optional(EvaluationRounding),
|
||||
providerMetadata: Schema.optional(ProviderMetadata),
|
||||
}) {}
|
||||
|
||||
export type EvaluationResponseFor<Questions extends EvaluationQuestions> = Omit<EvaluationResponse, "answers"> & {
|
||||
readonly answers: AnswersFor<Questions>
|
||||
}
|
||||
|
||||
export function request<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): EvaluationRequestFor<EvaluationModelOptions<Model>, Questions>
|
||||
export function request(input: EvaluationRequest): EvaluationRequest
|
||||
export function request(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
if (input instanceof EvaluationRequest) return input
|
||||
return new EvaluationRequest({
|
||||
...input,
|
||||
model: input.model as unknown as EvaluationModel,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
}
|
||||
|
||||
export function run<const Model extends object, const Questions extends EvaluationQuestions>(
|
||||
input: EvaluationRequestInput<Model, Questions>,
|
||||
): Effect.Effect<EvaluationResponseFor<Questions>, AIError, Service>
|
||||
export function run(input: EvaluationRequest): Effect.Effect<EvaluationResponse, AIError, Service>
|
||||
export function run(input: EvaluationRequest | EvaluationRequestInput) {
|
||||
return Effect.try({
|
||||
try: () => (input instanceof EvaluationRequest ? input : request(input)),
|
||||
catch: (cause) =>
|
||||
new AIError({
|
||||
reason: new InvalidRequestError({
|
||||
message: cause instanceof Error ? cause.message : String(cause),
|
||||
cause,
|
||||
}),
|
||||
}),
|
||||
}).pipe(
|
||||
Effect.flatMap((request) =>
|
||||
EvaluationClient.evaluate(request as EvaluationRequestFor<EvaluationOptions, EvaluationQuestions>),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
export const Evaluation = {
|
||||
request,
|
||||
run,
|
||||
} as const
|
||||
@@ -0,0 +1,222 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Headers, HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
BooleanAnswer,
|
||||
ChoiceAnswer,
|
||||
ChoiceQuestion,
|
||||
EvaluationInput,
|
||||
EvaluationModel,
|
||||
EvaluationResponse,
|
||||
EvaluationRounding,
|
||||
ScoreAnswer,
|
||||
ScoreQuestion,
|
||||
type EvaluationResponseFor,
|
||||
type EvaluationRoute,
|
||||
} from "./evaluation.js"
|
||||
import { Auth, type Definition as AuthDefinition } from "../route/auth.js"
|
||||
import {
|
||||
AIError,
|
||||
HttpContext,
|
||||
HttpOptions,
|
||||
InvalidProviderOutputError,
|
||||
InvalidRequestError,
|
||||
ModelID,
|
||||
Usage,
|
||||
mergeJsonRecords,
|
||||
} from "../schema/index.js"
|
||||
|
||||
const Noul = Schema.Struct({
|
||||
type: Schema.Literal("noul"),
|
||||
instructions: EvaluationInput,
|
||||
criteria: Schema.optional(
|
||||
Schema.Struct({
|
||||
true: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
false: Schema.optional(Schema.NullOr(EvaluationInput)),
|
||||
}),
|
||||
),
|
||||
})
|
||||
const Question = Schema.Union([
|
||||
ChoiceQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => Object.keys(x.criteria).length <= 255, {
|
||||
message: "System One Choice questions support at most 255 options",
|
||||
}),
|
||||
),
|
||||
ScoreQuestion.pipe(
|
||||
Schema.refine((x): x is typeof x => x.criteria.length <= 10, {
|
||||
message: "System One Score questions support at most 10 levels",
|
||||
}),
|
||||
),
|
||||
Noul,
|
||||
])
|
||||
const Request = Schema.StructWithRest(
|
||||
Schema.Struct({
|
||||
model: Schema.String,
|
||||
state: EvaluationInput,
|
||||
questions: Schema.Record(Schema.String, Question),
|
||||
}),
|
||||
[Schema.Record(Schema.String, Schema.Any)],
|
||||
)
|
||||
|
||||
const Probability = Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))
|
||||
const NoulAnswer = Schema.Struct({ type: Schema.Literal("noul"), noul: Probability })
|
||||
const Choice = Schema.Struct({
|
||||
type: Schema.Literal("choice"),
|
||||
choice: Schema.String,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const Score = Schema.Struct({
|
||||
type: Schema.Literal("score"),
|
||||
score: Schema.Number,
|
||||
probabilities: Schema.Record(Schema.String, Probability),
|
||||
legend: Schema.optional(Schema.Record(Schema.String, Schema.Json)),
|
||||
confidence: Schema.optional(Probability),
|
||||
})
|
||||
const NativeUsage = Schema.Struct({
|
||||
input_tokens: Schema.optional(Schema.Number),
|
||||
output_tokens: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const encode = Schema.encodeUnknownEffect(Schema.fromJsonString(Request))
|
||||
const exact = (x: Readonly<Record<string, unknown>>, keys: ReadonlyArray<string>) =>
|
||||
Object.keys(x).length === keys.length && keys.every((key) => Object.hasOwn(x, key))
|
||||
|
||||
export interface ModelInput {
|
||||
readonly id: string | ModelID
|
||||
readonly provider: string
|
||||
readonly providerMetadataKey: string
|
||||
readonly auth: AuthDefinition
|
||||
readonly baseURL: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions
|
||||
}
|
||||
|
||||
export const model = (cfg: ModelInput) => {
|
||||
const route: EvaluationRoute = {
|
||||
id: "system-one",
|
||||
evaluate: (req, send) =>
|
||||
Effect.gen(function* () {
|
||||
const url = new URL(`${cfg.baseURL.replace(/\/$/, "")}/systemone`)
|
||||
Object.entries(req.http?.query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value))
|
||||
const body = yield* encode({
|
||||
...mergeJsonRecords(req.options, req.http?.body),
|
||||
model: req.model.id,
|
||||
state: req.state,
|
||||
questions: Object.fromEntries(
|
||||
Object.entries(req.questions).map(([id, x]) => [id, x.type === "boolean" ? { ...x, type: "noul" } : x]),
|
||||
),
|
||||
}).pipe(
|
||||
Effect.mapError(
|
||||
(cause) => new AIError({ reason: new InvalidRequestError({ message: cause.message, cause }) }),
|
||||
),
|
||||
)
|
||||
const headers = yield* Auth.toEffect(cfg.auth)({
|
||||
request: req,
|
||||
method: "POST",
|
||||
url: url.toString(),
|
||||
body,
|
||||
headers: Headers.fromInput({ ...cfg.headers, ...req.http?.headers }),
|
||||
})
|
||||
const res = yield* send(
|
||||
HttpClientRequest.post(url).pipe(
|
||||
HttpClientRequest.setHeaders(headers),
|
||||
HttpClientRequest.bodyText(body, "application/json"),
|
||||
),
|
||||
)
|
||||
const http = new HttpContext({ url: res.request.url, status: res.status, headers: res.headers })
|
||||
const fail = (message: string, cause: unknown, body?: string) =>
|
||||
new AIError({ reason: new InvalidProviderOutputError({ route: route.id, message, body, http, cause }) })
|
||||
const text = yield* res.text.pipe(
|
||||
Effect.mapError((cause) => fail("Failed to read the System One response", cause)),
|
||||
)
|
||||
const entries = Object.entries(req.questions)
|
||||
const output = Schema.Struct({
|
||||
model: Schema.String,
|
||||
answers: Schema.Struct(
|
||||
Object.fromEntries(
|
||||
entries.map(([id, question]) => {
|
||||
if (question.type === "boolean") return [id, NoulAnswer]
|
||||
if (question.type === "choice") {
|
||||
const keys = Object.keys(question.criteria)
|
||||
return [
|
||||
id,
|
||||
Choice.pipe(
|
||||
Schema.refine(
|
||||
(x): x is typeof x =>
|
||||
Object.hasOwn(question.criteria, x.choice) && exact(x.probabilities, keys),
|
||||
{ message: `Question "${id}" returned an invalid choice answer` },
|
||||
),
|
||||
),
|
||||
]
|
||||
}
|
||||
const keys = question.criteria.map((_, index) => String(index))
|
||||
return [
|
||||
id,
|
||||
Score.pipe(
|
||||
Schema.refine(
|
||||
(x): x is typeof x =>
|
||||
x.score >= 0 && x.score <= question.criteria.length - 1 && exact(x.probabilities, keys),
|
||||
{ message: `Question "${id}" returned an invalid score answer` },
|
||||
),
|
||||
),
|
||||
]
|
||||
}),
|
||||
) as Record<string, Schema.Codec<unknown>>,
|
||||
),
|
||||
usage: Schema.optional(NativeUsage),
|
||||
})
|
||||
const data = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(output))(text).pipe(
|
||||
Effect.mapError((cause) => fail("System One returned an invalid response", cause, text)),
|
||||
)
|
||||
|
||||
const confidence: Record<string, number> = {}
|
||||
const legend: Record<string, Record<string, Schema.Json>> = {}
|
||||
const answers = Object.fromEntries(
|
||||
entries.map(([id, question]) => {
|
||||
const answer = data.answers[id]
|
||||
if (question.type === "boolean")
|
||||
return [
|
||||
id,
|
||||
{ type: "boolean", probability: (answer as typeof NoulAnswer.Type).noul } satisfies BooleanAnswer,
|
||||
]
|
||||
if (question.type === "choice") {
|
||||
const value = answer as typeof Choice.Type
|
||||
if (value.confidence !== undefined) confidence[id] = value.confidence
|
||||
return [
|
||||
id,
|
||||
{ type: "choice", choice: value.choice, probabilities: value.probabilities } satisfies ChoiceAnswer,
|
||||
]
|
||||
}
|
||||
const value = answer as typeof Score.Type
|
||||
if (value.confidence !== undefined) confidence[id] = value.confidence
|
||||
if (value.legend !== undefined) legend[id] = value.legend
|
||||
return [id, { type: "score", score: value.score, probabilities: value.probabilities } satisfies ScoreAnswer]
|
||||
}),
|
||||
)
|
||||
const meta = {
|
||||
...(Object.keys(confidence).length === 0 ? {} : { confidence }),
|
||||
...(Object.keys(legend).length === 0 ? {} : { legend }),
|
||||
}
|
||||
return new EvaluationResponse({
|
||||
model: ModelID.make(data.model),
|
||||
answers,
|
||||
usage: data.usage
|
||||
? new Usage({
|
||||
inputTokens: data.usage.input_tokens,
|
||||
outputTokens: data.usage.output_tokens,
|
||||
totalTokens:
|
||||
data.usage.input_tokens === undefined && data.usage.output_tokens === undefined
|
||||
? undefined
|
||||
: (data.usage.input_tokens ?? 0) + (data.usage.output_tokens ?? 0),
|
||||
providerMetadata: { [cfg.providerMetadataKey]: data.usage },
|
||||
})
|
||||
: undefined,
|
||||
rounding: new EvaluationRounding({ probabilityDecimals: 2, scoreDecimals: 2 }),
|
||||
providerMetadata: Object.keys(meta).length === 0 ? undefined : { [cfg.providerMetadataKey]: meta },
|
||||
}) as EvaluationResponseFor<typeof req.questions>
|
||||
}),
|
||||
}
|
||||
return EvaluationModel.make({ id: cfg.id, provider: cfg.provider, route, http: cfg.http })
|
||||
}
|
||||
|
||||
export const SystemOne = { model } as const
|
||||
@@ -38,23 +38,13 @@ export type Settings = ProviderPackage.Settings &
|
||||
|
||||
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
|
||||
const { serviceTier: _, ...body } = yield* Gemini.protocol.body.from(request)
|
||||
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
|
||||
// unlike AI Studio, so history minted there cannot be lowered verbatim.
|
||||
const contents = body.contents.map((content) => ({
|
||||
...content,
|
||||
parts: (content.parts ?? []).map((part) => {
|
||||
if ("functionCall" in part) return { ...part, functionCall: { ...part.functionCall, id: undefined } }
|
||||
if ("functionResponse" in part) return { ...part, functionResponse: { ...part.functionResponse, id: undefined } }
|
||||
return part
|
||||
}),
|
||||
}))
|
||||
const value = request.providerOptions?.labels
|
||||
const labels = ProviderShared.isRecord(value)
|
||||
? Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"),
|
||||
)
|
||||
: undefined
|
||||
return { ...body, contents, labels }
|
||||
return { ...body, labels }
|
||||
})
|
||||
|
||||
const protocol = {
|
||||
|
||||
@@ -24,8 +24,10 @@ export * as Moonshot from "./moonshot.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
export * as OpenCodeZen from "./opencode-zen.js"
|
||||
export * as OpenRouter from "./openrouter.js"
|
||||
export * as TogetherAI from "./togetherai.js"
|
||||
export * as TypeSafeAI from "./typesafe-ai.js"
|
||||
export * as XAI from "./xai.js"
|
||||
export * as ZAI from "./zai.js"
|
||||
export * as ZAICodingPlan from "./zai-coding-plan.js"
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
|
||||
export const id = ProviderID.make("opencode")
|
||||
const baseURL = "https://opencode.ai/zen/v1"
|
||||
|
||||
export type Options = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export const configure = (input: Options = {}) => {
|
||||
const evaluation = (modelID: string | ModelID) =>
|
||||
SystemOne.model({
|
||||
id: modelID,
|
||||
provider: id,
|
||||
providerMetadataKey: "opencode",
|
||||
auth: AuthOptions.bearer(input, "OPENCODE_API_KEY"),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
return { id, experimental: { evaluation }, configure }
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const experimental = provider.experimental
|
||||
|
||||
export * as OpenCodeZen from "./opencode-zen.js"
|
||||
@@ -0,0 +1,31 @@
|
||||
import { HttpOptions, ProviderID, type ModelID } from "../schema/index.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { SystemOne } from "../experimental/system-one.js"
|
||||
|
||||
export const id = ProviderID.make("typesafe-ai")
|
||||
const baseURL = "https://api.typesafe.ai/v1"
|
||||
|
||||
export type Options = ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly headers?: Record<string, string>
|
||||
readonly http?: HttpOptions.Input
|
||||
}
|
||||
|
||||
export const configure = (input: Options = {}) => {
|
||||
const evaluation = (modelID: string | ModelID) =>
|
||||
SystemOne.model({
|
||||
id: modelID,
|
||||
provider: id,
|
||||
providerMetadataKey: "typesafe",
|
||||
auth: AuthOptions.bearer(input, "TYPESAFE_API_KEY"),
|
||||
baseURL: input.baseURL ?? baseURL,
|
||||
headers: input.headers,
|
||||
http: input.http === undefined ? undefined : HttpOptions.make(input.http),
|
||||
})
|
||||
return { id, experimental: { evaluation }, configure }
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
export const experimental = provider.experimental
|
||||
|
||||
export * as TypeSafeAI from "./typesafe-ai.js"
|
||||
@@ -0,0 +1,173 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Evaluation, EvaluationClient } from "../src/experimental.js"
|
||||
import { OpenCodeZen, TypeSafeAI } from "../src/providers.js"
|
||||
import { it } from "./lib/effect.js"
|
||||
import { dynamicResponse } from "./lib/http.js"
|
||||
|
||||
describe("experimental Evaluation", () => {
|
||||
it.effect("evaluates typed questions through System One", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Evaluation.run({
|
||||
model: TypeSafeAI.configure({
|
||||
apiKey: "test",
|
||||
baseURL: "https://typesafe.test/v1/",
|
||||
headers: { "x-default": "yes" },
|
||||
http: { body: { deployment: "test" }, query: { api: "v1" } },
|
||||
}).experimental.evaluation("jev-latest"),
|
||||
state: { ticket: "Please refund the duplicate charge." },
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this?",
|
||||
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this?",
|
||||
criteria: ["Can wait", "Needs attention", "Blocking"],
|
||||
},
|
||||
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
|
||||
},
|
||||
options: { trace: { enabled: true } },
|
||||
http: { body: { request_metadata: "value" }, headers: { "x-request": "yes" }, query: { trace: "1" } },
|
||||
})
|
||||
|
||||
expect(response.model).toBe("jev-1.13.0")
|
||||
expect(response.answers.department).toEqual({
|
||||
type: "choice",
|
||||
choice: "billing",
|
||||
probabilities: { billing: 0.9, technical: 0.1 },
|
||||
})
|
||||
expect(response.answers.urgency).toEqual({
|
||||
type: "score",
|
||||
score: 1.2,
|
||||
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
|
||||
})
|
||||
expect(response.answers.refund).toEqual({ type: "boolean", probability: 0.97 })
|
||||
expect(response.usage?.totalTokens).toBe(36)
|
||||
expect(response.providerMetadata).toEqual({
|
||||
typesafe: {
|
||||
confidence: { department: 0.8, urgency: 0.6 },
|
||||
legend: { urgency: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" } },
|
||||
},
|
||||
})
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
EvaluationClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
expect(request.url).toBe("https://typesafe.test/v1/systemone?api=v1&trace=1")
|
||||
expect(request.headers.get("authorization")).toBe("Bearer test")
|
||||
expect(request.headers.get("x-default")).toBe("yes")
|
||||
expect(request.headers.get("x-request")).toBe("yes")
|
||||
expect(JSON.parse(input.text)).toEqual({
|
||||
deployment: "test",
|
||||
request_metadata: "value",
|
||||
trace: { enabled: true },
|
||||
model: "jev-latest",
|
||||
state: { ticket: "Please refund the duplicate charge." },
|
||||
questions: {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this?",
|
||||
criteria: { billing: "Payments and refunds", technical: "Bugs and outages" },
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this?",
|
||||
criteria: ["Can wait", "Needs attention", "Blocking"],
|
||||
},
|
||||
refund: { type: "noul", instructions: "Is the customer asking for a refund?" },
|
||||
},
|
||||
})
|
||||
return input.respond(
|
||||
JSON.stringify({
|
||||
model: "jev-1.13.0",
|
||||
answers: {
|
||||
department: {
|
||||
type: "choice",
|
||||
choice: "billing",
|
||||
probabilities: { billing: 0.9, technical: 0.1 },
|
||||
confidence: 0.8,
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
score: 1.2,
|
||||
probabilities: { "0": 0, "1": 0.8, "2": 0.2 },
|
||||
legend: { "0": "Can wait", "1": "Needs attention", "2": "Blocking" },
|
||||
confidence: 0.6,
|
||||
},
|
||||
refund: { type: "noul", noul: 0.97 },
|
||||
},
|
||||
usage: { input_tokens: 30, output_tokens: 6 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("configures the OpenCode Zen System One endpoint", () =>
|
||||
Evaluation.run({
|
||||
model: OpenCodeZen.configure({ apiKey: "zen-key", baseURL: "https://zen.test/v1" }).experimental.evaluation(
|
||||
"jev-1.13",
|
||||
),
|
||||
state: "hello",
|
||||
questions: { greeting: { type: "boolean", instructions: "Is this a greeting?" } },
|
||||
}).pipe(
|
||||
Effect.tap((response) =>
|
||||
Effect.sync(() => {
|
||||
expect(response.answers.greeting.probability).toBe(0.99)
|
||||
expect(response.usage?.providerMetadata).toEqual({
|
||||
opencode: { input_tokens: 10, output_tokens: 2 },
|
||||
})
|
||||
}),
|
||||
),
|
||||
Effect.provide(
|
||||
EvaluationClient.layer.pipe(
|
||||
Layer.provide(
|
||||
dynamicResponse((input) => {
|
||||
expect(input.request.url).toBe("https://zen.test/v1/systemone")
|
||||
expect(input.request.headers.authorization).toBe("Bearer zen-key")
|
||||
return Effect.succeed(
|
||||
input.respond(
|
||||
JSON.stringify({
|
||||
model: "jev-1.13.0",
|
||||
answers: { greeting: { type: "noul", noul: 0.99 } },
|
||||
usage: { input_tokens: 10, output_tokens: 2 },
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed questions before network I/O", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Evaluation.run({
|
||||
model: TypeSafeAI.experimental.evaluation("jev-latest"),
|
||||
state: "hello",
|
||||
questions: { score: { type: "score", instructions: "How much?", criteria: ["only"] } },
|
||||
}).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
EvaluationClient.layer.pipe(
|
||||
Layer.provide(dynamicResponse(() => Effect.die("invalid evaluation reached the network"))),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation, EvaluationClient, EvaluationModel, type EvaluationRoute } from "../src/experimental.js"
|
||||
import type { Service } from "../src/experimental/evaluation-client.js"
|
||||
import { OpenCodeZen, TypeSafeAI } from "../src/providers.js"
|
||||
|
||||
type Requirements<T> = T extends Effect.Effect<infer _A, infer _E, infer R> ? R : never
|
||||
type Success<T> = T extends Effect.Effect<infer A, infer _E, infer _R> ? A : never
|
||||
type Equal<A, B> = [A, B] extends [B, A] ? true : false
|
||||
type Assert<T extends true> = T
|
||||
|
||||
const model = TypeSafeAI.configure({ apiKey: "test" }).experimental.evaluation("jev-latest")
|
||||
const request = Evaluation.request({
|
||||
model,
|
||||
state: { ticket: "refund" },
|
||||
questions: {
|
||||
topic: {
|
||||
type: "choice",
|
||||
instructions: "Which team?",
|
||||
criteria: { billing: null, support: { includes: ["help"] } },
|
||||
},
|
||||
severity: { type: "score", instructions: "How severe?", criteria: ["Low", "High"] },
|
||||
refund: { type: "boolean", instructions: "Refund?" },
|
||||
},
|
||||
})
|
||||
|
||||
const result = EvaluationClient.evaluate(request)
|
||||
type Result = Success<typeof result>
|
||||
type Choice = Assert<Equal<Result["answers"]["topic"]["choice"], "billing" | "support">>
|
||||
type ClientRequirements = Assert<Equal<Requirements<typeof result>, Service>>
|
||||
void (true satisfies Choice)
|
||||
void (true satisfies ClientRequirements)
|
||||
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Evaluation.run({
|
||||
model: OpenCodeZen.experimental.evaluation("jev-1.13"),
|
||||
state: ["hello"],
|
||||
questions: { greeting: { type: "boolean", instructions: "Greeting?" } },
|
||||
})
|
||||
response.answers.greeting.probability satisfies number
|
||||
// @ts-expect-error Boolean answers do not contain a selected choice.
|
||||
response.answers.greeting.choice
|
||||
// @ts-expect-error Unknown question IDs are not exposed.
|
||||
response.answers.missing
|
||||
})
|
||||
|
||||
declare const route: EvaluationRoute<{ readonly temperature?: number }>
|
||||
const custom = EvaluationModel.make({ id: "custom", provider: "custom", route })
|
||||
Evaluation.run({
|
||||
model: custom,
|
||||
state: "hello",
|
||||
questions: { ok: { type: "boolean", instructions: "OK?" } },
|
||||
options: { temperature: 0.5 },
|
||||
})
|
||||
// @ts-expect-error Selected evaluation models retain their request option types.
|
||||
Evaluation.run({
|
||||
model: custom,
|
||||
state: "hello",
|
||||
questions: { ok: { type: "boolean", instructions: "OK?" } },
|
||||
options: { temperature: "high" },
|
||||
})
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
CloudflareWorkersAI,
|
||||
DeepSeek,
|
||||
Fireworks,
|
||||
OpenCodeZen,
|
||||
OpenAI,
|
||||
OpenAICompatible,
|
||||
OpenRouter,
|
||||
TypeSafeAI,
|
||||
XAI,
|
||||
} from "@opencode/ai/providers"
|
||||
import {
|
||||
@@ -23,6 +25,7 @@ import {
|
||||
} from "@opencode/ai/protocols"
|
||||
import * as AnthropicMessages from "@opencode/ai/protocols/anthropic-messages"
|
||||
import { TestLLM } from "@opencode/ai/testing"
|
||||
import { Evaluation, EvaluationClient } from "@opencode/ai/experimental"
|
||||
|
||||
describe("public exports", () => {
|
||||
test("root exposes app-facing runtime APIs", () => {
|
||||
@@ -37,6 +40,9 @@ describe("public exports", () => {
|
||||
expect(TestLLM.layer).toBeFunction()
|
||||
expect(TestLLM.testLayer).toBeFunction()
|
||||
expect(TestLLM.Test.of).toBeFunction()
|
||||
expect(Evaluation.run).toBeFunction()
|
||||
expect(EvaluationClient.layer).toBeDefined()
|
||||
expect(EvaluationClient.fetchLayer).toBeDefined()
|
||||
})
|
||||
|
||||
test("route barrel exposes route-authoring APIs", () => {
|
||||
@@ -66,6 +72,8 @@ describe("public exports", () => {
|
||||
expect(CloudflareWorkersAI.configure).toBeFunction()
|
||||
expect(CloudflareWorkersAI.configure({ accountId: "fixture", apiKey: "fixture" }).model).toBeFunction()
|
||||
expect(OpenRouter.model).toBeFunction()
|
||||
expect(TypeSafeAI.experimental.evaluation).toBeFunction()
|
||||
expect(OpenCodeZen.experimental.evaluation).toBeFunction()
|
||||
expect(XAI.model).toBeFunction()
|
||||
expect(XAI.provider.responses).toBe(XAI.responses)
|
||||
expect(XAI.provider.chat).toBe(XAI.chat)
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/calls-a-tool",
|
||||
"recordedAt": "2026-08-23T17:21:51.036Z"
|
||||
"recordedAt": "2026-09-22T03:46:29.725Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,7 +25,7 @@
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_425130\"},\"thoughtSignature\": \"AY89a1+1fXnLgYhHMuN3Ak6LBhT6PcrYOW7iPav4LfsacvG/Z6l1yJ+AsU7vWhFj/JyPIbsJJQ+GjohM9sCIZ6nqUOIg3reo/7osmrCvFrVHedTHQcwiPzoz2Kp3gb+uWjFAXxk1EX4IRAKcu0ox1W/Z9PpuZvHkTerGO2a82e02N6MAF1YhhtbXFvSdqLRih2Os68rdOk5/Bcld7ol8qUgeyIZ3CtI3OJ5jwRcD8LjvK33A7ZFzH5Bxp/peUmXvqnu5iNhnGBxZaJy/vupCtxRZxjaS+ojG0/UhyrnRiKIpbzQ0FBkxePPn8GCX/LOe2y3GUc98co8lN8OOuCd9ZmEdx5AjHmQkPO9fAV9SxG6Bda6SDWVL8o/Uz3WSQYoUEfAdoajEWIBvcisoeCJjb7zgmRRZ9VQSPl3RXj5LFRvX8jn0YKV1CahYbc24jA==\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 102,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 47},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:50.308576Z\",\"responseId\": \"LiyLauDqErCErb8Pj8aWkAs\"}\r\n\r\n"
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"functionCall\": {\"name\": \"lookup_weather\",\"args\": {\"city\": \"Paris\"},\"id\": \"call_28936\"},\"thoughtSignature\": \"AY89a1/MVeBxtwu9l/96clFobOrrd7Q5MTf5o8/A22fc++1EsFYVeCFx9WCGmJ+D3yW3FWlRXKtJECYfprSoxixCSOYBNiV9g8/IkejbAxF26k1vxTsHkoqts0O2s8GqqBSRAavJfKQ/taTRbPuIq+b+RKZ8SDJFoWDnFjXpdS5S164uJTjmgs4ALd8oB1oQRycMsTbAY51TmkcNUnhARpjKQfqiizz4KfSRFjx7xDnza0v4jj73m2GV86eLDhVT7trIT+Dl5DsJt6RBLOE2Amse54mcpIbsYaANlY1YDU6FKuUBqnuwpl0OfhMXwWuYMogv1tH/0rTXsqNwmHyt/b9ZZXqYCdFXpMGt8udmoZ0d\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 39,\"candidatesTokenCount\": 16,\"totalTokenCount\": 93,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 39}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 16}],\"thoughtsTokenCount\": 38},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:28.953809Z\",\"responseId\": \"FPqxatGbOsSorb8PtY2z8Q4\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+8
-4
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/continues-after-a-tool-result",
|
||||
"recordedAt": "2026-08-23T17:21:51.853Z"
|
||||
"recordedAt": "2026-09-22T03:46:30.691Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -14,14 +18,14 @@
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
"body": "{\"contents\":[{\"role\":\"user\",\"parts\":[{\"text\":\"What is the weather in Paris?\"}]},{\"role\":\"model\",\"parts\":[{\"functionCall\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"args\":{\"city\":\"Paris\"}},\"thoughtSignature\":\"skip_thought_signature_validator\"}]},{\"role\":\"user\",\"parts\":[{\"functionResponse\":{\"id\":\"call_paris_1\",\"name\":\"lookup_weather\",\"response\":{\"name\":\"lookup_weather\",\"content\":\"18C, light rain\"}}}]}],\"tools\":[{\"functionDeclarations\":[{\"name\":\"lookup_weather\",\"description\":\"Look up the current weather for a city\",\"parameters\":{\"required\":[\"city\"],\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}}}}]}]}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a197c+fpHJftPtcufnqMAyoRQKVEQK+KeG+RVHVx2wKil3L4jP4YWvfVbcuOFr2jio4Kre/hCrDANAoMFSvaZrdaPeo1b5bXQSmJKMH03yM5M6q6ME6JiBvXym143U4exIde4UbOh2tMeyXMvB3aWxcavIHd78g5G5QPLreo6A3LO5871cYYVeRwteY+/zbEdqfaAq1hlk6WYpWkNljYpjMyKwr15YC8rFLh3HYayS9tTN++GGrk/reZn6C3OEPlzPou/pXRATzcEAGVl/TW\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 98,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}],\"thoughtsTokenCount\": 24},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:51.220919Z\",\"responseId\": \"LyyLave9DbWnrb8P1IjLmQQ\"}\r\n\r\n"
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"The weather\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" in Paris is currently 18°C with light rain.\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a18aVI8Uxr2VaOI822u0DyuQdy4B00uhLnjqYb5Qb6Mkscccm018knLtYThB5UX8dRv1VFsORSQ0Qo6Gx9RCng2AK9EPce7p\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 59,\"candidatesTokenCount\": 15,\"totalTokenCount\": 74,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 59}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 15}]},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:29.830958Z\",\"responseId\": \"Ffqxau7bMoCOrb8P_Iu5sA0\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"tags": ["prefix:google-vertex", "provider:google-vertex", "protocol:gemini"],
|
||||
"tags": [
|
||||
"prefix:google-vertex",
|
||||
"provider:google-vertex",
|
||||
"protocol:gemini"
|
||||
],
|
||||
"name": "google-vertex/streams-text",
|
||||
"recordedAt": "2026-08-23T17:21:50.112Z"
|
||||
"recordedAt": "2026-09-22T03:46:28.840Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
@@ -21,7 +25,7 @@
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a1+BGsRqlGpfT0psLB4jeTkT5rDV2HFOlrRuF7aVxDOjqNVUku6t4azeSnxpd+msHWuwXj4RS+7gmVlzVs+JNi8uj+iZWTBCi71vSh9kdK9ed/sHv9J7uL9ZWSOcgbhX/hxdXaUp5yVbQzHFXPjR9A/IkEkHV8VKarDZVFE1T1uASia74lkmyBeZZz+DQmRsLwbUHzFUKlF3qnk/SliLo21ZgASd7itlALQ0PBLJZwgeI3g7tDscDSE18hnB11Fky8q7MLd3HY16zbDvHBEMb18pmmPelPI01KdrCIwMSou/01/u5jiSUCc3pFksZawUj3tAHocHSC3ZKAQQQuUXGe5tm61C2E40/NANBeePc1S4HYE6Yo/vtX6tE02LDky5IQWX09H6+DZ7fpopP5nCUfcKPHa3hVjYquWYYMtZgXO4ZpxfVd3lt1VUDuJNN3BMMCZapjBoJZFPXPJ5t/yg9Rnd791+msGH77b4wztz1vtsPrT9oV9g6SDo9ZUH6BaOcbK7fw8FaXcGw+55malEwQy6zpRLGecooBu70p6RwhaAUyKIMX49y+F2hkNxQxDeBUNckJnu6n4w+KLyjP+bR0gqPJbGjVfteHm+QujqjJdBBT/m1u9kPo1nIbzdEs/PIADBdbuV7TkD/HoRFKpLnNmM2no8ioTtFEjKBDz4ippGi15r8pGgA6wIb/1HAvOGh+PVERdGcbelVTgfONwBqjQ7B1wmEizCfyYuMIskfwjxDGayfKlpDxrnNeogtEct9u5/DjEKlURlg9MtmW1B9P8BXYJ+7SCiRJWwW6bzB+5C+MLCnETl/mljDizoJMHK8DKIhI4oxBsrWXEuoHFwEwGIeOZq0BofH2Jz/l6+KIboV/zd581Kk0zPg/rlI6acfjUEtXtbF+t0+jzoJN7006x4i2tqXeJZ+4e5yisSArEsfJ0YzNWoJtBHG9V9/euDcEP3+jsr98efaQaQbLMPvT/Hb7CYQ7ChhGfcGxQ=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 150,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 142},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-08-23T17:21:48.528714Z\",\"responseId\": \"LCyLasqiIO6crb8P1sDboQc\"}\r\n\r\n"
|
||||
"body": "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"Hello\"}]}}],\"usageMetadata\": {\"trafficType\": \"ON_DEMAND\"},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:26.748531Z\",\"responseId\": \"EvqxavPXLbqerb8P1eSViQs\"}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\",\"thoughtSignature\": \"AY89a18msz0aRAZc4U/Zl7rEXjdTJsE0EWee1+hD2dT7h711q6uIRem7KWCLlji7JVSu2hDV1L8uFh8SGAO5jU4MBSZ+mq661EVXf9DZBF1CV+fetSpCUggurbYHWGGGvLRxjI96W0wR93EMoV/1pLHa4fgGVcFupoFsz/3n9J3wCkT4pRvHjn+hC7V11aSMI07t/OUeo1ssAziob9apuB+HUm2/EO17ph9TZvg3rydE5J0nmJ+DnYGLl5FfQJHgnnsjR5qaKs5wVNIKNA811lv/K5Iee/O6gMNXI3GAcGUvPKHMD5oQSs5B343blFUiqw47H1WZ9KWBoLXhVcnss6hLPmpDchInVhr3ObAHQhZIaBDBnhQR6nuc5BtgoFnwWw3w5G1yNNFt3qT3c75Rd/8NamdtveyskDbrHxQ5GkUo7ICuE/3SQG0yqWhVDjakYgVqjLG3BTKRg6I/dT5+VCeQbdxTZ+ScWkJfXYDEnlZlTjMLz2FS+B+9Zwez/1Dy/pm9H51jvuXI942kv31DfDVcTTwvKmb3SqIB9D4aomb16gDAywC7I4L/+ZwsqmcQoUvVtHGEe5NL3r6fgWtWzKht+60ZgBalmzdBfhZHd/bDXvTAX7SVpHraXjfpcdGrtv1LhU6Kr3gol94TR5Xh9dWKNhwR0s6at7MNCpj3eG9ogrWkmYSiqNeVT1ovscWvH70tDCtCNwY5sIUJVUSWdHc2OdzFxNIPkzh9OuHIn0o9Dbmup6obFoQOgi587HA/Sqvre9pDVqVMzn7w57naDmg+lsR9wS67BxbV4tVrvvfeVQAQQRft5SmSu7WCBnaWuM2/o890nE5ynFG+M2FqApJLgdfXozOguo/hROrD3WxLUyT8+FDsoCY8ky6YYTaF3OlOHpuD5Zmw4UGSdDbcEzhc6cRqBAYfMpxzN58VwNRrjG/MGTY3jrvOLJDk3sF7URa7Tn5YuRLp/2jL5YKWF2QftWiW9jl1YeORYQwNdLBuRK9L4/WOxNRWhYZToAJfwT4byh5dKzY8OnjeFcJy8RVvuRpoYeQY1P63ie/K1D3TgJEwKQXylLdPwtZRGM/P5sm2dcf5F+lRfZGufmv+Q++unrqWr6uv3IlgVGKPa8zTh+ipSaik7taKd+jertOSYkgjrrUfjC0/oyg5giuNqXh+ebIxQC7TMX8bManc5N3aomFuYXmIJ4iT/euQctq8N3gPPWmUNmrnXztdEZgpQdV2ahj+yRr1yxCriOfRo3Az3oPMPEaXxrqDc8RoWt/lOgzzp7KfBGoOVzYdvaDGr8MhsZ8Pd7HD0vZW8TNSl8R7cEK3G5EotNA+s6lF/n/RH5ewmg3cQK8bnnZ4oNjqYo3pGtw+H2rbefMMzAk4mybGe9uDqSdhWJgBzRGsMXRW+0atn9tuJTu6vPNn9asNvwXS0lcvpQskbe5sK3yhzyFzqwrJxs7Ji6yiu6kOtsQ5Girzk1835A2Zun6ZegGtQH8DbgIPdSBMkh6kypndBu4ns2gg97g9yB8XFGbv3c60sxaXPRuycl9IjBL81Yy6n6PCOBx7Pqm9xrzPn7QzgtetKxCmNUMYJvF+myiQFrMdL1w+1DD7FoZX9VNBDbNWgcfhgnL8tlHSmTb36KnScJOe8D+3n3+aDfkWpiHjrLrGJsHlaek/Ji2Cgwa0dIC6EhQitNttskIKVMa+yD6D1T6Dvz/eolnutjYwbGb3y9mtpQzmVwf2Zp2dzxYRWZk=\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 7,\"candidatesTokenCount\": 1,\"totalTokenCount\": 285,\"trafficType\": \"ON_DEMAND\",\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 7}],\"candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 1}],\"thoughtsTokenCount\": 277},\"modelVersion\": \"gemini-3.5-flash\",\"createTime\": \"2026-09-22T03:46:26.748531Z\",\"responseId\": \"EvqxavPXLbqerb8P1eSViQs\"}\r\n\r\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "jev-1.13-free",
|
||||
"tags": [
|
||||
"prefix:opencode-zen-evaluation",
|
||||
"provider:opencode",
|
||||
"protocol:system-one"
|
||||
],
|
||||
"name": "opencode-zen-evaluation/evaluates-choice-score-and-boolean-questions",
|
||||
"recordedAt": "2026-09-22T03:44:57.511Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://opencode.ai/zen/v1/systemone",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-1.13-free\",\"state\":\"I was charged twice for the same invoice. Please refund the duplicate payment today.\",\"questions\":{\"department\":{\"type\":\"choice\",\"instructions\":\"Which team should handle this support request?\",\"criteria\":{\"billing\":\"Payments, invoices, refunds, or failed charges\",\"technical\":\"Bugs, outages, or integrations\",\"sales\":\"Pricing, upgrades, or new accounts\"}},\"urgency\":{\"type\":\"score\",\"instructions\":\"How urgent is this support request?\",\"criteria\":[\"Can wait for normal support\",\"Needs prompt attention\",\"Actively blocking revenue\"]},\"refund\":{\"type\":\"noul\",\"instructions\":\"Is the customer asking for a refund?\"}}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-1.13-free\",\"answers\":{\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1,\"probabilities\":{\"technical\":0,\"sales\":0,\"billing\":1}},\"urgency\":{\"type\":\"score\",\"score\":1.02,\"confidence\":0.95,\"legend\":{\"0\":\"Can wait for normal support\",\"1\":\"Needs prompt attention\",\"2\":\"Actively blocking revenue\"},\"probabilities\":{\"0\":0,\"1\":0.97,\"2\":0.03}},\"refund\":{\"type\":\"noul\",\"noul\":0.99}},\"usage\":{\"input_tokens\":422,\"output_tokens\":69}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "jev-latest",
|
||||
"tags": [
|
||||
"prefix:typesafe-evaluation",
|
||||
"provider:typesafe-ai",
|
||||
"protocol:system-one"
|
||||
],
|
||||
"name": "typesafe-evaluation/evaluates-choice-score-and-boolean-questions",
|
||||
"recordedAt": "2026-09-22T03:44:28.626Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.typesafe.ai/v1/systemone",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-latest\",\"state\":\"I was charged twice for the same invoice. Please refund the duplicate payment today.\",\"questions\":{\"department\":{\"type\":\"choice\",\"instructions\":\"Which team should handle this support request?\",\"criteria\":{\"billing\":\"Payments, invoices, refunds, or failed charges\",\"technical\":\"Bugs, outages, or integrations\",\"sales\":\"Pricing, upgrades, or new accounts\"}},\"urgency\":{\"type\":\"score\",\"instructions\":\"How urgent is this support request?\",\"criteria\":[\"Can wait for normal support\",\"Needs prompt attention\",\"Actively blocking revenue\"]},\"refund\":{\"type\":\"noul\",\"instructions\":\"Is the customer asking for a refund?\"}}}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"jev-1.13.0\",\"answers\":{\"department\":{\"type\":\"choice\",\"choice\":\"billing\",\"confidence\":1.0,\"probabilities\":{\"billing\":1.0,\"sales\":0.0,\"technical\":0.0}},\"urgency\":{\"type\":\"score\",\"score\":1.01,\"confidence\":0.97,\"legend\":{\"0\":\"Can wait for normal support\",\"1\":\"Needs prompt attention\",\"2\":\"Actively blocking revenue\"},\"probabilities\":{\"0\":0.0,\"1\":0.98,\"2\":0.02}},\"refund\":{\"type\":\"noul\",\"noul\":0.99}},\"usage\":{\"input_tokens\":422,\"output_tokens\":69}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Evaluation } from "../../src/experimental.js"
|
||||
import { OpenCodeZen, TypeSafeAI } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const questions = {
|
||||
department: {
|
||||
type: "choice",
|
||||
instructions: "Which team should handle this support request?",
|
||||
criteria: {
|
||||
billing: "Payments, invoices, refunds, or failed charges",
|
||||
technical: "Bugs, outages, or integrations",
|
||||
sales: "Pricing, upgrades, or new accounts",
|
||||
},
|
||||
},
|
||||
urgency: {
|
||||
type: "score",
|
||||
instructions: "How urgent is this support request?",
|
||||
criteria: ["Can wait for normal support", "Needs prompt attention", "Actively blocking revenue"],
|
||||
},
|
||||
refund: { type: "boolean", instructions: "Is the customer asking for a refund?" },
|
||||
} as const
|
||||
|
||||
const state = "I was charged twice for the same invoice. Please refund the duplicate payment today."
|
||||
|
||||
const typesafe = recordedTests({
|
||||
prefix: "typesafe-evaluation",
|
||||
provider: "typesafe-ai",
|
||||
protocol: "system-one",
|
||||
requires: ["TYPESAFE_API_KEY"],
|
||||
metadata: { model: "jev-latest" },
|
||||
})
|
||||
|
||||
const zen = recordedTests({
|
||||
prefix: "opencode-zen-evaluation",
|
||||
provider: "opencode",
|
||||
protocol: "system-one",
|
||||
requires: ["OPENCODE_API_KEY"],
|
||||
metadata: { model: "jev-1.13-free" },
|
||||
})
|
||||
|
||||
describe("experimental Evaluation recorded", () => {
|
||||
typesafe.effect("evaluates choice score and boolean questions", () =>
|
||||
assertEvaluation(
|
||||
TypeSafeAI.configure({ apiKey: process.env.TYPESAFE_API_KEY ?? "fixture" }).experimental.evaluation("jev-latest"),
|
||||
"typesafe",
|
||||
),
|
||||
)
|
||||
|
||||
zen.effect("evaluates choice score and boolean questions", () =>
|
||||
assertEvaluation(
|
||||
OpenCodeZen.configure({ apiKey: process.env.OPENCODE_API_KEY ?? "fixture" }).experimental.evaluation(
|
||||
"jev-1.13-free",
|
||||
),
|
||||
"opencode",
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const assertEvaluation = (
|
||||
model: ReturnType<typeof TypeSafeAI.experimental.evaluation>,
|
||||
metadataKey: "typesafe" | "opencode",
|
||||
) =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Evaluation.run({ model, state, questions })
|
||||
expect(response.model).toStartWith("jev-")
|
||||
expect(response.answers.department.type).toBe("choice")
|
||||
expect(response.answers.department.choice).toBe("billing")
|
||||
expect(response.answers.department.probabilities?.billing).toBeGreaterThan(0.5)
|
||||
expect(response.answers.urgency.type).toBe("score")
|
||||
expect(response.answers.urgency.score).toBeGreaterThanOrEqual(0)
|
||||
expect(response.answers.urgency.score).toBeLessThanOrEqual(2)
|
||||
expect(response.answers.refund.type).toBe("boolean")
|
||||
expect(response.answers.refund.probability).toBeGreaterThan(0.5)
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.providerMetadata?.[metadataKey]?.confidence).toBeDefined()
|
||||
})
|
||||
@@ -114,7 +114,7 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
|
||||
it.effect("preserves function call ids in lowered Vertex bodies", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -142,15 +142,14 @@ describe("Google Vertex providers", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(JSON.stringify(prepared.body.contents)).not.toContain('"id"')
|
||||
expect(prepared.body.contents).toMatchObject([
|
||||
{ role: "model", parts: [{ functionCall: { id: undefined, name: "lookup", args: { query: "weather" } } }] },
|
||||
{ role: "model", parts: [{ functionCall: { id: "call_1", name: "lookup", args: { query: "weather" } } }] },
|
||||
{
|
||||
role: "user",
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: undefined,
|
||||
id: "call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
@@ -235,12 +234,23 @@ describe("Google Vertex providers", () => {
|
||||
parts: [
|
||||
{ text: "Thinking.", thought: true, thoughtSignature: "reasoning_sig" },
|
||||
{ text: "Checking.", thoughtSignature: "text_sig" },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
{
|
||||
functionCall: { id: "provider_call_1", name: "lookup", args: { query: "weather" } },
|
||||
thoughtSignature: "tool_sig",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: "sunny" } } }],
|
||||
parts: [
|
||||
{
|
||||
functionResponse: {
|
||||
id: "provider_call_1",
|
||||
name: "lookup",
|
||||
response: { name: "lookup", content: "sunny" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
|
||||
@@ -6,6 +6,8 @@ import * as path from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { LLMClient, RequestExecutor } from "../src/route.js"
|
||||
import { ImageClient } from "../src/image-client.js"
|
||||
import { EvaluationClient } from "../src/experimental/evaluation-client.js"
|
||||
import type { Service as EvaluationClientService } from "../src/experimental/evaluation-client.js"
|
||||
import type { Service as ImageClientService } from "../src/image-client.js"
|
||||
import type { Service as LLMClientService } from "../src/route/client.js"
|
||||
import type { Service as RequestExecutorService } from "../src/route/executor.js"
|
||||
@@ -18,7 +20,12 @@ import {
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES_DIR = path.resolve(__dirname, "fixtures", "recordings")
|
||||
|
||||
type RecordedEnv = RequestExecutorService | LLMClientService | ImageClientService | Socket.WebSocketConstructor
|
||||
type RecordedEnv =
|
||||
| RequestExecutorService
|
||||
| LLMClientService
|
||||
| ImageClientService
|
||||
| EvaluationClientService
|
||||
| Socket.WebSocketConstructor
|
||||
|
||||
type RecordedTestsOptions = RecordedGroupOptions & {
|
||||
readonly options?: HttpRecorder.RecorderOptions
|
||||
@@ -92,6 +99,7 @@ export const recordedTests = (options: RecordedTestsOptions) =>
|
||||
requestExecutor,
|
||||
LLMClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
ImageClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
EvaluationClient.layer.pipe(Layer.provide(requestExecutor)),
|
||||
webSocket,
|
||||
)
|
||||
},
|
||||
|
||||
@@ -19,6 +19,13 @@ export function mountBrowserPane() {
|
||||
session: "Alpha",
|
||||
mounted: true,
|
||||
visible: true,
|
||||
url: undefined as string | undefined,
|
||||
loading: false,
|
||||
generation: 0,
|
||||
delayNavigation: false,
|
||||
pendingURL: undefined as string | undefined,
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
})
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
@@ -47,13 +54,33 @@ export function mountBrowserPane() {
|
||||
opened: () => !!registrations.get(store.session),
|
||||
state: () => ({ tabs: tabs.filter((tab) => tab.title === store.session), focusedTabID: null }),
|
||||
tabs: () => tabs.filter((tab) => tab.title === store.session),
|
||||
active: () => tabs.find((tab) => tab.title === store.session) ?? tabs[0],
|
||||
active: () => {
|
||||
const tab = tabs.find((tab) => tab.title === store.session) ?? tabs[0]
|
||||
return {
|
||||
...tab,
|
||||
url: store.url ?? tab.url,
|
||||
loading: store.loading,
|
||||
generation: store.generation,
|
||||
loadError: store.loadErrors[store.session],
|
||||
}
|
||||
},
|
||||
registration: () => registrations.get(store.session),
|
||||
error: () => undefined,
|
||||
error: () => store.error ?? (store.loadErrors[store.session] ? "Request failed" : undefined),
|
||||
suspended: () => false,
|
||||
close: () => undefined,
|
||||
open: () => undefined,
|
||||
command: () => undefined,
|
||||
command: (command) => {
|
||||
setStore("error", undefined)
|
||||
if (command.type === "navigate" || command.type === "reload") setStore("loadErrors", store.session, undefined)
|
||||
if (command.type === "navigate") {
|
||||
if (store.delayNavigation) {
|
||||
setStore("pendingURL", command.url)
|
||||
return
|
||||
}
|
||||
setStore({ url: command.url, loading: false, generation: store.generation + 1 })
|
||||
}
|
||||
if (command.type === "stop") setStore("loading", false)
|
||||
},
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -64,6 +91,32 @@ export function mountBrowserPane() {
|
||||
{(name) => <button onClick={() => setStore({ session: name, mounted: name !== "Empty" })}>{name}</button>}
|
||||
</For>
|
||||
<button onClick={() => setStore("mounted", false)}>Unmount pane</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: false })}>Blank page</button>
|
||||
<button onClick={() => setStore({ url: "about:blank", loading: true })}>Loading page</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({ loading: true, generation: store.generation + 1, loadErrors: { [store.session]: undefined } })
|
||||
}
|
||||
>
|
||||
Load current page
|
||||
</button>
|
||||
<button onClick={() => setStore("loadErrors", store.session, "ERR_CONNECTION_REFUSED")}>Failed page</button>
|
||||
<button onClick={() => setStore("delayNavigation", true)}>Delay navigation</button>
|
||||
<button onClick={() => setStore({ error: "ERR_BLOCKED_BY_CLIENT", pendingURL: undefined })}>
|
||||
Block navigation
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
setStore({
|
||||
url: store.pendingURL,
|
||||
pendingURL: undefined,
|
||||
loading: false,
|
||||
generation: store.generation + 1,
|
||||
})
|
||||
}
|
||||
>
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
|
||||
@@ -57,3 +57,153 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await toggle.click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
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()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText('Or prompt "Open in the app browser"', { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
story("keeps Stop available and hides the empty state while a blank page loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Loading page", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await root.getByRole("button", { name: "Stop", exact: true }).click()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("keeps the submitted URL visible until the browser reports navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await address.fill("https://unsubmitted.example/")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("restores the current URL when a submitted navigation is blocked", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://blocked.example/")
|
||||
await address.press("Enter")
|
||||
await expect(address).toHaveValue("https://blocked.example/")
|
||||
await root.getByRole("button", { name: "Block navigation", exact: true }).click()
|
||||
await expect(root.getByText("ERR_BLOCKED_BY_CLIENT", { exact: true })).toBeVisible()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("shows a themed failure state for only the failed tab and allows retry", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Check the URL and your connection, then try again.", { exact: true })).toBeVisible()
|
||||
await expect(root.getByText("Request failed", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(root.getByRole("textbox", { name: "Browser address", exact: true })).toHaveValue(
|
||||
"https://alpha.example/",
|
||||
)
|
||||
|
||||
await root.getByRole("button", { name: "Beta", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Beta")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Alpha", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
|
||||
await root.getByRole("button", { name: "Reload", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("returns a failed tab to the empty state when an empty URL is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Failed page", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeVisible()
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("")
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByText("URL can't be reached", { exact: true })).toBeHidden()
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeVisible()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeDisabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
})
|
||||
|
||||
story("selects the full URL when the address field gains focus", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://alpha.example/".length)
|
||||
await address.pressSequentially("https://example.com/")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await address.focus()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
await address.press("ArrowRight")
|
||||
await address.click()
|
||||
await expect(address).toHaveJSProperty("selectionStart", 0)
|
||||
await expect(address).toHaveJSProperty("selectionEnd", "https://example.com/".length)
|
||||
})
|
||||
|
||||
story("keeps the current page visible while a submitted URL loads", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Delay navigation", exact: true }).click()
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("https://example.com/")
|
||||
await address.press("Enter")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
|
||||
await root.getByRole("button", { name: "Load current page", exact: true }).click()
|
||||
await expect(root.getByRole("button", { name: "Stop", exact: true })).toBeEnabled()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await root.getByRole("button", { name: "Complete navigation", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(address).toHaveValue("https://example.com/")
|
||||
})
|
||||
|
||||
story("keeps the current page and restores its URL when an empty address is submitted", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const address = root.getByRole("textbox", { name: "Browser address", exact: true })
|
||||
await address.fill("")
|
||||
await address.press("Enter")
|
||||
await expect(address).not.toBeFocused()
|
||||
await expect(address).toHaveValue("https://alpha.example/")
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(root.getByText("Enter URL", { exact: true })).toBeHidden()
|
||||
await expect(root.getByRole("button", { name: "Reload", exact: true })).toBeEnabled()
|
||||
})
|
||||
|
||||
@@ -17,14 +17,12 @@ const milestones = [
|
||||
"electronSpawnStarted",
|
||||
"debugEndpointReady",
|
||||
"electronStarted",
|
||||
"layersReady",
|
||||
"windowVisible",
|
||||
"serviceEnsureStarted",
|
||||
"serviceSpawnRequested",
|
||||
"serviceReady",
|
||||
"backgroundLoadingReady",
|
||||
"rendererViteConnected",
|
||||
"rendererInitializationStarted",
|
||||
"rendererInitializationReady",
|
||||
"windowVisible",
|
||||
"homeReady",
|
||||
] as const
|
||||
const phases = [
|
||||
@@ -128,14 +126,12 @@ const milestonePatterns: ReadonlyArray<{ name: Milestone; text: string }> = [
|
||||
{ name: "electronSpawnStarted", text: "starting electron app..." },
|
||||
{ name: "debugEndpointReady", text: "DevTools listening on ws://" },
|
||||
{ name: "electronStarted", text: "app starting" },
|
||||
{ name: "layersReady", text: "layers ready" },
|
||||
{ name: "windowVisible", text: "main window visible" },
|
||||
{ name: "serviceEnsureStarted", text: "starting v2 background service" },
|
||||
{ name: "serviceSpawnRequested", text: "v2 CLI background service starting" },
|
||||
{ name: "serviceReady", text: "v2 CLI background service ready" },
|
||||
{ name: "backgroundLoadingReady", text: "loading task finished" },
|
||||
{ name: "rendererViteConnected", text: "[vite] connected." },
|
||||
{ name: "rendererInitializationStarted", text: "awaiting server ready" },
|
||||
{ name: "rendererInitializationReady", text: "server ready" },
|
||||
{ name: "windowVisible", text: "main window visible" },
|
||||
]
|
||||
|
||||
async function createColdProfile() {
|
||||
@@ -404,14 +400,12 @@ function requireMilestones(observed: Partial<Record<Milestone, number>>) {
|
||||
electronSpawnStarted: get("electronSpawnStarted"),
|
||||
debugEndpointReady: get("debugEndpointReady"),
|
||||
electronStarted: get("electronStarted"),
|
||||
layersReady: get("layersReady"),
|
||||
windowVisible: get("windowVisible"),
|
||||
serviceEnsureStarted: get("serviceEnsureStarted"),
|
||||
serviceSpawnRequested: get("serviceSpawnRequested"),
|
||||
serviceReady: get("serviceReady"),
|
||||
backgroundLoadingReady: get("backgroundLoadingReady"),
|
||||
rendererViteConnected: get("rendererViteConnected"),
|
||||
rendererInitializationStarted: get("rendererInitializationStarted"),
|
||||
rendererInitializationReady: get("rendererInitializationReady"),
|
||||
windowVisible: get("windowVisible"),
|
||||
homeReady: get("homeReady"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,14 +20,12 @@ describe("desktop startup benchmark", () => {
|
||||
["electronSpawnStarted", "starting electron app..."],
|
||||
["debugEndpointReady", "DevTools listening on ws://"],
|
||||
["electronStarted", "app starting"],
|
||||
["layersReady", "layers ready"],
|
||||
["windowVisible", "main window visible"],
|
||||
["serviceEnsureStarted", "starting v2 background service"],
|
||||
["serviceSpawnRequested", "v2 CLI background service starting"],
|
||||
["serviceReady", "v2 CLI background service ready"],
|
||||
["backgroundLoadingReady", "loading task finished"],
|
||||
["rendererViteConnected", "[vite] connected."],
|
||||
["rendererInitializationStarted", "awaiting server ready"],
|
||||
["rendererInitializationReady", "server ready"],
|
||||
["windowVisible", "main window visible"],
|
||||
] as const
|
||||
cases.forEach(([milestone, line]) => {
|
||||
expect(milestoneForLine(`\u001b[32m${line}\u001b[39m`)).toBe(milestone)
|
||||
@@ -58,14 +56,12 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
|
||||
electronSpawnStarted: 7,
|
||||
debugEndpointReady: 8,
|
||||
electronStarted: 9,
|
||||
serviceEnsureStarted: 10,
|
||||
serviceSpawnRequested: 11,
|
||||
serviceReady: 12,
|
||||
backgroundLoadingReady: 13,
|
||||
rendererViteConnected: 14,
|
||||
rendererInitializationStarted: 15,
|
||||
rendererInitializationReady: 16,
|
||||
windowVisible: 17,
|
||||
layersReady: 10,
|
||||
windowVisible: 11,
|
||||
serviceEnsureStarted: 12,
|
||||
serviceSpawnRequested: 13,
|
||||
serviceReady: 14,
|
||||
rendererViteConnected: 15,
|
||||
homeReady: commandToHomeReadyMs,
|
||||
}
|
||||
return {
|
||||
@@ -80,8 +76,8 @@ function sample(run: number, commandToHomeReadyMs: number): DesktopStartupSample
|
||||
electronStartup: 2,
|
||||
serviceSpawnWait: 1,
|
||||
serviceProcessStartup: 1,
|
||||
rendererStartup: commandToHomeReadyMs - 14,
|
||||
visibleWindowToHome: commandToHomeReadyMs - 17,
|
||||
rendererStartup: commandToHomeReadyMs - 15,
|
||||
visibleWindowToHome: commandToHomeReadyMs - 11,
|
||||
},
|
||||
service: { version: "2.0.0-local-test", url: "http://127.0.0.1:3000", pid: run },
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
const shifted = await content.evaluate((element) => getComputedStyle(element).translate)
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
// Keep issuing resize events before the idle timer expires, including crossing the width cutoff.
|
||||
for (const width of [1520, 1280, 1600]) {
|
||||
for (const width of [1520, 1280, 1800]) {
|
||||
await page.setViewportSize({ width, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "true")
|
||||
await page.clock.runFor(100)
|
||||
@@ -84,6 +84,7 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(content).toHaveAttribute("data-summary-motion", "transitionrun,transitionend,")
|
||||
await expect(content).not.toHaveCSS("translate", shifted)
|
||||
await page.setViewportSize({ width: 1440, height: 900 })
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveCSS("translate", shifted)
|
||||
|
||||
await content.evaluate((element) => element.setAttribute("data-summary-motion", ""))
|
||||
@@ -112,7 +113,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.setViewportSize({ width: 1800, height: 900 })
|
||||
await expect(content).toHaveCSS("translate", "0px")
|
||||
await expect(panel).toHaveAttribute("data-summary-resizing", "false")
|
||||
await expect(content).toHaveCSS("translate", "none")
|
||||
await expect(summary).toBeVisible()
|
||||
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
|
||||
@@ -116,7 +116,7 @@ test("single-server settings expose scoped pages without a server picker", async
|
||||
await expect(connection.getByRole("heading", { name: "Connection", exact: true })).toBeVisible()
|
||||
await expect(connection.locator('[data-component="settings-list"]')).toHaveCSS("padding-left", "16px")
|
||||
await expect(connection.locator(".settings-servers-row")).toHaveCSS("padding-top", "20px")
|
||||
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "4px")
|
||||
await expect(connection.locator(".settings-servers-lead")).toHaveCSS("column-gap", "10px")
|
||||
await expect(connection.locator(".settings-servers-copy")).toHaveCSS("row-gap", "6px")
|
||||
await expect(settings.getByRole("heading", { name: "Preferences", exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Terminal shell", { exact: true })).toBeVisible()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -65,14 +65,14 @@
|
||||
}
|
||||
|
||||
/* Keep the 1000px conversation intact, with 320px for the summary and its gutters.
|
||||
Move only as far as needed; wider panels already have enough space in the margin. */
|
||||
@container session-chat (min-width: 1320px) {
|
||||
When they overlap, center the conversation in the space that remains beside the summary. */
|
||||
@container session-chat (min-width: 1320px) and (width < 1640px) {
|
||||
[data-slot="session-chat-panel"][data-summary-open="true"]
|
||||
:is([data-timeline-virtual-content], [data-component="session-composer-dock"] > div) {
|
||||
--session-summary-translate: min(0px, calc(50cqi - 820px));
|
||||
--session-summary-translate: -160px;
|
||||
|
||||
&:dir(rtl) {
|
||||
--session-summary-translate: max(0px, calc(820px - 50cqi));
|
||||
--session-summary-translate: 160px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,6 +625,16 @@ export const dict = {
|
||||
"toast.file.loadFailed.title": "Failed to load file",
|
||||
"file.error.notFound": "File not found: {{name}}",
|
||||
"toast.file.listFailed.title": "Failed to list files",
|
||||
"file.view.preview": "Preview",
|
||||
"file.view.source": "Source",
|
||||
"file.view.openInBrowser": "Open in browser",
|
||||
"file.view.binary": "Binary file · {{size}}",
|
||||
"file.view.table.rows.one": "{{count}} row",
|
||||
"file.view.table.rows.other": "{{count}} rows",
|
||||
"file.view.table.columns.one": "{{count}} column",
|
||||
"file.view.table.columns.other": "{{count}} columns",
|
||||
"file.view.table.truncated": "Showing the first {{shown}} of {{total}} rows.",
|
||||
"file.view.fontSample": "Sphinx of black quartz, judge my vow.",
|
||||
|
||||
"toast.context.noLineSelection.title": "No line selection",
|
||||
"toast.context.noLineSelection.description": "Select a line range in a file tab first.",
|
||||
@@ -906,6 +916,10 @@ export const dict = {
|
||||
"session.browser.replaced": "Browser control moved to another desktop window.",
|
||||
"session.browser.suspended": "Browser suspended. Interact with this session to reconnect.",
|
||||
"session.browser.address.placeholder": "Enter URL",
|
||||
"session.browser.empty.title": "Enter URL",
|
||||
"session.browser.empty.description": 'Or prompt "Open in the app browser"',
|
||||
"session.browser.failed.title": "URL can't be reached",
|
||||
"session.browser.failed.description": "Check the URL and your connection, then try again.",
|
||||
|
||||
"titlebar.update": "Update",
|
||||
"titlebar.tabs": "Tabs",
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { Codec } from "./codec"
|
||||
|
||||
// Type-level checks: these compile only if inference matches what `typeof schema.Type` gave callers.
|
||||
test("struct types infer optional and required fields", () => {
|
||||
const s = Codec.struct({ id: Codec.string, tab: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
|
||||
const value: typeof s.Type = { id: "x" }
|
||||
const tab: string | undefined = value.tab
|
||||
const n: number | undefined = value.n
|
||||
const t = Codec.transform(s, { decode: (old) => old.tab ?? old.id, encode: (v) => ({ id: v }) })
|
||||
const out: string | Codec.Invalid = t.decode({ id: "a" })
|
||||
const onlyOptional = Codec.struct({ tab: Codec.optional(Codec.string) })
|
||||
const empty: typeof onlyOptional.Type = {}
|
||||
const maybe: string | undefined = empty.tab
|
||||
const viaTransform = Codec.transform(onlyOptional, { decode: (old) => old.tab, encode: (tab) => ({ tab }) })
|
||||
expect([tab, n, out, maybe, viaTransform.decode({})]).toEqual([undefined, undefined, "a", undefined, undefined])
|
||||
})
|
||||
@@ -1,85 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Codec } from "./codec"
|
||||
|
||||
describe("Codec", () => {
|
||||
test("primitives reject the wrong shape and finite numbers only", () => {
|
||||
expect(Codec.string.decode("a")).toBe("a")
|
||||
expect(Codec.string.decode(1)).toBe(Codec.INVALID)
|
||||
expect(Codec.number.decode(1.5)).toBe(1.5)
|
||||
expect(Codec.number.decode(Number.NaN)).toBe(Codec.INVALID)
|
||||
expect(Codec.nonNegativeInt.decode(-1)).toBe(Codec.INVALID)
|
||||
expect(Codec.literals(["a", "b"]).decode("c")).toBe(Codec.INVALID)
|
||||
expect(Codec.literal("x").decode("x")).toBe("x")
|
||||
})
|
||||
|
||||
test("struct keeps optional fields absent and rejects invalid required ones", () => {
|
||||
const codec = Codec.struct({ id: Codec.string, title: Codec.optional(Codec.string), n: Codec.lenientOptional(Codec.number) })
|
||||
expect(codec.decode({ id: "1" })).toEqual({ id: "1" })
|
||||
expect(codec.decode({ id: "1", title: "t", n: "bad" })).toEqual({ id: "1", title: "t" })
|
||||
expect(codec.decode({ id: "1", title: 3 })).toBe(Codec.INVALID)
|
||||
expect(codec.decode({ title: "t" })).toBe(Codec.INVALID)
|
||||
expect(codec.decode([])).toBe(Codec.INVALID)
|
||||
expect(codec.encode({ id: "1" })).toEqual({ id: "1" })
|
||||
const value: typeof codec.Type = { id: "1", title: undefined }
|
||||
expect(value.id).toBe("1")
|
||||
})
|
||||
|
||||
test("lenient collections recover what they can", () => {
|
||||
const items = Codec.lenientArray(Codec.struct({ id: Codec.string }))
|
||||
expect(items.decode([{ id: "a" }, { id: 1 }, "x", { id: "b" }])).toEqual([{ id: "a" }, { id: "b" }])
|
||||
expect(items.decode("nope")).toEqual([])
|
||||
expect(Codec.array(Codec.string).decode(["a", 1])).toBe(Codec.INVALID)
|
||||
const map = Codec.lenientRecord(Codec.boolean)
|
||||
expect(map.decode({ a: true, b: "x" })).toEqual({})
|
||||
expect(map.decode({ a: true })).toEqual({ a: true })
|
||||
})
|
||||
|
||||
test("union, transform and fallback compose", () => {
|
||||
const session = Codec.struct({ type: Codec.literal("session"), id: Codec.string })
|
||||
const draft = Codec.struct({ type: Codec.literal("draft"), directory: Codec.string })
|
||||
const tab = Codec.union([session, draft])
|
||||
expect(tab.decode({ type: "draft", directory: "/x" })).toEqual({ type: "draft", directory: "/x" })
|
||||
expect(tab.decode({ type: "other" })).toBe(Codec.INVALID)
|
||||
const upper = Codec.transform(Codec.string, { decode: (s) => s.toUpperCase(), encode: (s) => s.toLowerCase() })
|
||||
expect(upper.decode("ab")).toBe("AB")
|
||||
expect(upper.encode("AB")).toBe("ab")
|
||||
const safe = Codec.fallback(Codec.number, () => 7)
|
||||
expect(safe.decode("x")).toBe(7)
|
||||
expect(safe.decode(undefined)).toBe(7)
|
||||
expect(safe.decode(2)).toBe(2)
|
||||
})
|
||||
|
||||
test("brand constructs and decodes as its base", () => {
|
||||
const Key = Codec.brand<"ServerConnection.Key">()
|
||||
const key = Key.make("http://a")
|
||||
expect(Key.decode(key)).toBe(key)
|
||||
expect(Key.decode(3)).toBe(Codec.INVALID)
|
||||
})
|
||||
|
||||
test("withInitial recovers field by field and merges new defaults", () => {
|
||||
const layout = Codec.struct({
|
||||
sidebar: Codec.struct({ opened: Codec.boolean, width: Codec.number }),
|
||||
theme: Codec.lenientOptional(Codec.literals(["light", "dark"])),
|
||||
})
|
||||
const initial: typeof layout.Type = { sidebar: { opened: true, width: 240 } }
|
||||
const codec = Codec.fromJsonString(Codec.withInitial(layout, initial))
|
||||
expect(codec.decode(JSON.stringify({ sidebar: { opened: false, width: "wide" }, theme: "dark" }))).toEqual({
|
||||
sidebar: { opened: false, width: 240 },
|
||||
theme: "dark",
|
||||
})
|
||||
expect(codec.decode(JSON.stringify({ sidebar: 5 }))).toEqual(initial)
|
||||
expect(codec.decode("{not json")).toBe(Codec.INVALID)
|
||||
expect(JSON.parse(codec.encode({ sidebar: { opened: true, width: 1 } }))).toEqual({ sidebar: { opened: true, width: 1 } })
|
||||
})
|
||||
|
||||
test("migrate reads the old shape first", () => {
|
||||
const current = Codec.struct({ tabs: Codec.array(Codec.string) })
|
||||
const previous = Codec.struct({ tab: Codec.optional(Codec.string) })
|
||||
const read = Codec.transform(previous, {
|
||||
decode: (old) => ({ tabs: old.tab ? [old.tab] : [] }),
|
||||
encode: (value) => ({ tab: value.tabs[0] }),
|
||||
})
|
||||
const codec = Codec.withInitial(Codec.migrate(current, read), { tabs: [] })
|
||||
expect(codec.decode({ tab: "a" })).toEqual({ tabs: ["a"] })
|
||||
})
|
||||
})
|
||||
@@ -1,324 +0,0 @@
|
||||
export * as Codec from "./codec"
|
||||
|
||||
// Plain codecs for persisted state. They replace Effect Schema in the renderer's initial module
|
||||
// graph, where Effect's own module initialisation was the single largest startup cost that was not
|
||||
// rendering. Semantics mirror the Persistence helpers: decoding never throws, `INVALID` marks a
|
||||
// value that cannot be recovered, and the lenient combinators recover what they can.
|
||||
|
||||
export const INVALID: unique symbol = Symbol.for("opencode/persistence/codec/invalid")
|
||||
export type Invalid = typeof INVALID
|
||||
|
||||
const tag: unique symbol = Symbol.for("opencode/persistence/codec")
|
||||
|
||||
export interface Of<T, E = unknown> {
|
||||
readonly [tag]: true
|
||||
/** Phantom: `typeof codec.Type` is the decoded type, as with Effect schemas. */
|
||||
readonly Type: T
|
||||
readonly Encoded: E
|
||||
readonly optional?: boolean
|
||||
decode(input: unknown): T | Invalid
|
||||
encode(value: T): E
|
||||
}
|
||||
|
||||
export type Any = Of<any, any>
|
||||
export type Type<C extends Any> = C["Type"]
|
||||
|
||||
export function isCodec(value: unknown): value is Any {
|
||||
return typeof value === "object" && value !== null && tag in value
|
||||
}
|
||||
|
||||
export function make<T, E = unknown>(decode: (input: unknown) => T | Invalid, encode: (value: T) => E): Of<T, E> {
|
||||
return { [tag]: true, decode, encode } as Of<T, E>
|
||||
}
|
||||
|
||||
export function is<T>(codec: Of<T>, input: unknown): input is T {
|
||||
return codec.decode(input) !== INVALID
|
||||
}
|
||||
|
||||
export function decodeOption<T>(codec: Of<T>, input: unknown): T | undefined {
|
||||
const value = codec.decode(input)
|
||||
return value === INVALID ? undefined : value
|
||||
}
|
||||
|
||||
export function decodeOrThrow<T>(codec: Of<T>, input: unknown): T {
|
||||
const value = codec.decode(input)
|
||||
if (value === INVALID) throw new Error("Value does not match its codec")
|
||||
return value
|
||||
}
|
||||
|
||||
const identity = <T>(value: T) => value
|
||||
|
||||
export const string: Of<string, string> = make((v) => (typeof v === "string" ? v : INVALID), identity)
|
||||
export const boolean: Of<boolean, boolean> = make((v) => (typeof v === "boolean" ? v : INVALID), identity)
|
||||
export const unknown: Of<unknown, unknown> = make((v) => v, identity)
|
||||
/** Finite numbers only: NaN and infinities are not JSON and never valid state. */
|
||||
export const number: Of<number, number> = make((v) => (typeof v === "number" && Number.isFinite(v) ? v : INVALID), identity)
|
||||
export const int: Of<number, number> = make((v) => (typeof v === "number" && Number.isInteger(v) ? v : INVALID), identity)
|
||||
export const nonNegativeInt: Of<number, number> = make(
|
||||
(v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 ? v : INVALID),
|
||||
identity,
|
||||
)
|
||||
|
||||
export function literal<const L extends string | number | boolean | null>(value: L): Of<L, L> {
|
||||
return make((v) => (v === value ? value : INVALID), identity)
|
||||
}
|
||||
|
||||
export function literals<const L extends ReadonlyArray<string | number | boolean | null>>(values: L): Of<L[number], L[number]> {
|
||||
const set = new Set<unknown>(values)
|
||||
return make((v) => (set.has(v) ? (v as L[number]) : INVALID), identity)
|
||||
}
|
||||
|
||||
/** A string carrying a nominal brand, with the constructor Effect's `Schema.brand` gave callers. */
|
||||
export function brand<B extends string>(): Of<string & { readonly [K in B]: B }, string> & {
|
||||
make(value: string): string & { readonly [K in B]: B }
|
||||
} {
|
||||
return Object.assign(make<string & { readonly [K in B]: B }, string>((v) => (typeof v === "string" ? (v as never) : INVALID), identity), {
|
||||
make: (value: string) => value as never,
|
||||
})
|
||||
}
|
||||
|
||||
export function nullOr<T, E>(codec: Of<T, E>): Of<T | null, E | null> {
|
||||
return make((v) => (v === null ? null : codec.decode(v)), (v) => (v === null ? null : codec.encode(v)))
|
||||
}
|
||||
|
||||
export function undefinedOr<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> {
|
||||
return make((v) => (v === undefined ? undefined : codec.decode(v)), (v) => (v === undefined ? undefined : codec.encode(v)))
|
||||
}
|
||||
|
||||
/** A struct field that may be absent. Present but invalid values make the struct invalid. */
|
||||
export function optional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
|
||||
return { ...undefinedOr(codec), optional: true } as never
|
||||
}
|
||||
|
||||
/** A struct field that may be absent, and whose invalid values are dropped rather than rejected. */
|
||||
export function lenientOptional<T, E>(codec: Of<T, E>): Of<T | undefined, E | undefined> & { readonly optional: true } {
|
||||
return {
|
||||
...make<T | undefined, E | undefined>(
|
||||
(v) => {
|
||||
if (v === undefined) return undefined
|
||||
const value = codec.decode(v)
|
||||
return value === INVALID ? undefined : value
|
||||
},
|
||||
(v) => (v === undefined ? undefined : codec.encode(v)),
|
||||
),
|
||||
optional: true,
|
||||
} as never
|
||||
}
|
||||
|
||||
type Fields = Record<string, Any>
|
||||
type OptionalKeys<F extends Fields> = { [K in keyof F]: F[K] extends { optional: true } ? K : never }[keyof F]
|
||||
type RequiredKeys<F extends Fields> = Exclude<keyof F, OptionalKeys<F>>
|
||||
type Simplify<T> = { [K in keyof T]: T[K] } & {}
|
||||
export type StructType<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Type"] } & { [K in OptionalKeys<F>]?: F[K]["Type"] }
|
||||
>
|
||||
export type StructEncoded<F extends Fields> = Simplify<
|
||||
{ [K in RequiredKeys<F>]: F[K]["Encoded"] } & { [K in OptionalKeys<F>]?: F[K]["Encoded"] }
|
||||
>
|
||||
|
||||
export interface Struct<F extends Fields> extends Of<StructType<F>, StructEncoded<F>> {
|
||||
readonly fields: F
|
||||
}
|
||||
|
||||
export function struct<const F extends Fields>(fields: F): Struct<F> {
|
||||
const entries = Object.entries(fields)
|
||||
return {
|
||||
...make<StructType<F>, StructEncoded<F>>(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const record = input as Record<string, unknown>
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, codec] of entries) {
|
||||
const present = Object.hasOwn(record, key)
|
||||
if (!present && codec.optional) continue
|
||||
const value = codec.decode(record[key])
|
||||
if (value === INVALID) return INVALID
|
||||
if (value !== undefined || present) out[key] = value
|
||||
}
|
||||
return out as StructType<F>
|
||||
},
|
||||
(value) => {
|
||||
const out: Record<string, unknown> = {}
|
||||
for (const [key, codec] of entries) {
|
||||
const field = (value as Record<string, unknown>)[key]
|
||||
if (field === undefined && !Object.hasOwn(value as object, key)) continue
|
||||
out[key] = codec.encode(field)
|
||||
}
|
||||
return out as StructEncoded<F>
|
||||
},
|
||||
),
|
||||
fields,
|
||||
}
|
||||
}
|
||||
|
||||
export function array<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (!Array.isArray(input)) return INVALID
|
||||
const out: T[] = []
|
||||
for (const item of input) {
|
||||
const value = codec.decode(item)
|
||||
if (value === INVALID) return INVALID
|
||||
out.push(value)
|
||||
}
|
||||
return out
|
||||
},
|
||||
(value) => value.map((item) => codec.encode(item)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Keeps the items that decode and drops the rest, like `Persistence.array`. */
|
||||
export function lenientArray<T, E>(codec: Of<T, E>): Of<T[], E[]> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (!Array.isArray(input)) return []
|
||||
return input.flatMap((item) => {
|
||||
const value = codec.decode(item)
|
||||
return value === INVALID ? [] : [value]
|
||||
})
|
||||
},
|
||||
(value) => value.map((item) => codec.encode(item)),
|
||||
)
|
||||
}
|
||||
|
||||
export function record<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return INVALID
|
||||
const out: Record<string, T> = {}
|
||||
for (const [key, item] of Object.entries(input)) {
|
||||
const value = codec.decode(item)
|
||||
if (value === INVALID) return INVALID
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
},
|
||||
(value) => Object.fromEntries(Object.entries(value).map(([key, item]) => [key, codec.encode(item)])),
|
||||
)
|
||||
}
|
||||
|
||||
/** An invalid record becomes empty rather than failing the whole store, like `Persistence.record`. */
|
||||
export function lenientRecord<T, E>(codec: Of<T, E>): Of<Record<string, T>, Record<string, E>> {
|
||||
const strict = record(codec)
|
||||
return make(
|
||||
(input) => {
|
||||
const value = strict.decode(input)
|
||||
return value === INVALID ? {} : value
|
||||
},
|
||||
strict.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function union<const C extends ReadonlyArray<Any>>(codecs: C): Of<C[number]["Type"], C[number]["Encoded"]> {
|
||||
return make(
|
||||
(input) => {
|
||||
for (const codec of codecs) {
|
||||
const value = codec.decode(input)
|
||||
if (value !== INVALID) return value
|
||||
}
|
||||
return INVALID
|
||||
},
|
||||
(value) => {
|
||||
// Encode with the first member that accepts the value's shape; members are disjoint in practice.
|
||||
for (const codec of codecs) if (codec.decode(value) !== INVALID) return codec.encode(value)
|
||||
return value as C[number]["Encoded"]
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/** Maps a decoded value into another shape, the replacement for `decodeTo` + `SchemaGetter.transform`. */
|
||||
export function transform<T, E, T2>(
|
||||
codec: Of<T, E>,
|
||||
options: { decode: (value: T) => T2; encode: (value: T2) => T },
|
||||
): Of<T2, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
const value = codec.decode(input)
|
||||
return value === INVALID ? INVALID : options.decode(value)
|
||||
},
|
||||
(value) => codec.encode(options.encode(value)),
|
||||
)
|
||||
}
|
||||
|
||||
/** Invalid and missing values become `value()`, like `Persistence.fallback`. */
|
||||
export function fallback<T, E>(codec: Of<T, E>, value: () => T): Of<T, E> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (input === undefined) return value()
|
||||
const decoded = codec.decode(input)
|
||||
return decoded === INVALID ? value() : decoded
|
||||
},
|
||||
codec.encode,
|
||||
)
|
||||
}
|
||||
|
||||
export function fromJsonString<T, E>(codec: Of<T, E>): Of<T, string> {
|
||||
return make(
|
||||
(input) => {
|
||||
if (typeof input !== "string") return INVALID
|
||||
try {
|
||||
return codec.decode(JSON.parse(input))
|
||||
} catch {
|
||||
return INVALID
|
||||
}
|
||||
},
|
||||
(value) => JSON.stringify(codec.encode(value)),
|
||||
)
|
||||
}
|
||||
|
||||
export type Migrated<C extends Any> = { readonly current: C; readonly read: Of<unknown> }
|
||||
|
||||
/** Older stored shapes go through `read` first; `current` describes what the store holds today. */
|
||||
export function migrate<C extends Any>(current: C, read: Of<unknown>): Migrated<C> {
|
||||
return { current, read }
|
||||
}
|
||||
|
||||
function isMigrated<C extends Any>(definition: C | Migrated<C>): definition is Migrated<C> {
|
||||
return !isCodec(definition) && "current" in definition
|
||||
}
|
||||
|
||||
// Stored values recover field by field against the initial value: an object's valid fields are
|
||||
// kept, invalid or missing ones take their initial counterpart, and the result is merged over the
|
||||
// initial so new fields appear with their defaults. Mirrors `Persistence.withInitial`.
|
||||
export function withInitial<C extends Any>(definition: C | Migrated<C>, initial: Type<C>): Of<Type<C>, unknown> {
|
||||
const codec = isMigrated(definition) ? definition.current : definition
|
||||
const read = isMigrated(definition) ? definition.read : unknown
|
||||
return make(
|
||||
(input) => {
|
||||
const stored = read.decode(input)
|
||||
if (stored === INVALID) return INVALID
|
||||
return merge(initial, recover(codec, stored, initial))
|
||||
},
|
||||
(value) => codec.encode(value),
|
||||
)
|
||||
}
|
||||
|
||||
function recover(codec: Any, value: unknown, initial: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if ("fields" in codec && isObject(value)) {
|
||||
const fields = (codec as Struct<Fields>).fields
|
||||
return Object.fromEntries(
|
||||
Object.entries(fields).flatMap(([name, field]) => {
|
||||
const defaults = isObject(initial) ? initial[name] : undefined
|
||||
const next = recover(field, value[name], defaults)
|
||||
if (next === undefined && !Object.hasOwn(value, name) && defaults === undefined) return []
|
||||
return [[name, next]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
const decoded = codec.decode(value)
|
||||
return decoded === INVALID ? initial : decoded
|
||||
}
|
||||
|
||||
function merge(initial: unknown, value: unknown): unknown {
|
||||
if (value === undefined) return initial
|
||||
if (!isObject(initial) || !isObject(value)) return value
|
||||
return Object.fromEntries(
|
||||
[...new Set([...Object.keys(initial), ...Object.keys(value)])].map((key) => [key, merge(initial[key], value[key])]),
|
||||
)
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Option, Schema } from "effect"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { ScopedKey, ServerScope } from "@/runtime/server/scope"
|
||||
import { Codec } from "./codec"
|
||||
import { persistStore } from "./persist"
|
||||
import { Persistence } from "./schema"
|
||||
|
||||
@@ -473,57 +472,25 @@ export function removePersisted(
|
||||
}
|
||||
}
|
||||
|
||||
type Definition<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any> =
|
||||
| S
|
||||
| Persistence.Migrated<Extract<S, Schema.ConstraintCodec<object, unknown>>>
|
||||
| Codec.Migrated<Extract<S, Codec.Any>>
|
||||
|
||||
// Persisted stores are moving from Effect Schema to the plain codecs in ./codec so the renderer
|
||||
// stops paying for Effect at startup; both are accepted while the migration is underway.
|
||||
function serializer<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
definition: Definition<S>,
|
||||
initial: S["Type"],
|
||||
) {
|
||||
if (Codec.isCodec(definition) || (!("current" in definition) ? false : Codec.isCodec(definition.current))) {
|
||||
const codec = Codec.withInitial(definition as Codec.Any | Codec.Migrated<Codec.Any>, initial)
|
||||
const json = Codec.fromJsonString(codec)
|
||||
return {
|
||||
decode: (raw: string) => Codec.decodeOption(json, raw) as S["Type"] | undefined,
|
||||
deserialize: (raw: unknown) => Codec.decodeOrThrow(json, raw) as S["Type"],
|
||||
serialize: (value: S["Type"]) => json.encode(value),
|
||||
encode: (value: S["Type"]) => codec.encode(value),
|
||||
initial: Codec.decodeOrThrow(codec, codec.encode(initial)) as S["Type"],
|
||||
}
|
||||
}
|
||||
const schema = definition as Schema.ConstraintCodec<object, unknown> | Persistence.Migrated<Schema.ConstraintCodec<object, unknown>>
|
||||
const initialized = Persistence.withInitial(schema, initial as object)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
return {
|
||||
decode: (raw: string) => Option.getOrUndefined(decode(raw)) as S["Type"] | undefined,
|
||||
deserialize: Schema.decodeUnknownSync(json) as (raw: unknown) => S["Type"],
|
||||
serialize: Schema.encodeSync(json) as (value: S["Type"]) => string,
|
||||
encode: Schema.encodeSync(initialized) as (value: S["Type"]) => unknown,
|
||||
initial: Schema.decodeUnknownSync(Schema.toType(initialized))(initial as object) as S["Type"],
|
||||
}
|
||||
}
|
||||
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Codec.Any>(
|
||||
export function persisted<S extends Schema.ConstraintCodec<object, unknown>>(
|
||||
target: string | PersistTarget,
|
||||
schema: Definition<S>,
|
||||
schema: S | Persistence.Migrated<S>,
|
||||
initial: NoInfer<S["Type"]>,
|
||||
platformOverride?: Platform,
|
||||
): PersistedWithReady<S["Type"]> {
|
||||
const platform = platformOverride ?? usePlatform()
|
||||
const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform)
|
||||
|
||||
const codec = serializer<S>(schema, initial)
|
||||
const { encode, serialize } = codec
|
||||
const initialized = Persistence.withInitial(schema, initial)
|
||||
const json = Schema.fromJsonString(initialized)
|
||||
const decode = Schema.decodeUnknownOption(json)
|
||||
const encode = Schema.encodeSync(initialized)
|
||||
const serialize = Schema.encodeSync(json)
|
||||
const normalize = (raw: string) => {
|
||||
const value = codec.decode(raw)
|
||||
if (value !== undefined) return serialize(value)
|
||||
const value = decode(raw)
|
||||
if (Option.isSome(value)) return serialize(value.value)
|
||||
}
|
||||
const store = createStore<S["Type"]>(codec.initial)
|
||||
const store = createStore<S["Type"]>(Schema.decodeUnknownSync(Schema.toType(initialized))(initial))
|
||||
const isDesktop = platform.platform === "desktop" && !!platform.storage
|
||||
const draft = config.draft ? platform.draftStore : undefined
|
||||
const prefix = `${config.storage ?? "default"}:`
|
||||
@@ -635,7 +602,7 @@ export function persisted<S extends Schema.ConstraintCodec<object, unknown> | Co
|
||||
name: config.key,
|
||||
storage,
|
||||
serialize,
|
||||
deserialize: codec.deserialize,
|
||||
deserialize: Schema.decodeUnknownSync(json),
|
||||
sync: channel ? messageSync(channel) : undefined,
|
||||
// Drafts take the encoded document itself so large text is externalized without the store
|
||||
// re-parsing the serialized form on every save.
|
||||
|
||||
@@ -19,6 +19,7 @@ export type BrowserPaneCommand = Browser.Action
|
||||
export type BrowserPaneState = Browser.State | null
|
||||
export type BrowserPaneEvent =
|
||||
| { type: "focus"; tabID: Browser.TabID }
|
||||
| { type: "preview"; path: string }
|
||||
| { type: "state"; state: BrowserPaneState; error?: string }
|
||||
|
||||
export type BrowserPaneRegistration = {
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { Brand } from "effect"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
|
||||
// The server key's brand is shared with the Effect schema in ./persistence.ts (type only, so this
|
||||
// module loads nothing of Effect), letting stores port to plain codecs one at a time.
|
||||
export type ServerKey = string & Brand.Brand<"ServerConnection.Key">
|
||||
|
||||
export const ServerKey: Codec.Of<ServerKey, string> & { make(value: string): ServerKey } = Object.assign(
|
||||
Codec.make<ServerKey, string>((v) => (typeof v === "string" ? (v as ServerKey) : Codec.INVALID), (v) => v),
|
||||
{ make: (value: string) => value as ServerKey },
|
||||
)
|
||||
@@ -33,6 +33,8 @@ export type FileContent = {
|
||||
}
|
||||
encoding?: "base64"
|
||||
mimeType?: string
|
||||
/** On-disk size when the bytes themselves are not retained. */
|
||||
size?: number
|
||||
}
|
||||
|
||||
export type Path = {
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { createEffect, createMemo, getOwner, on, onCleanup, runWithOwner } from "solid-js"
|
||||
import { batch, createEffect, createMemo, createRoot, getOwner, on, onCleanup, runWithOwner } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { BrowserPaneCommand } from "@/runtime/platform/browser-pane"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { useServer } from "@/runtime/server/current"
|
||||
import type { SessionStateKey } from "@/runtime/server/scope"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { findSessionTab, tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useCurrentRoute } from "@/shell/state/layout"
|
||||
import { useCurrentRoute, useLayout } from "@/shell/state/layout"
|
||||
import { sessionBrowserTab } from "@/shell/state/session-tabs"
|
||||
import { createEventListener } from "@solid-primitives/event-listener"
|
||||
import { createBrowserConnection, type BrowserConnectionState } from "./connection"
|
||||
|
||||
@@ -35,13 +36,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
const shellTabs = useTabs()
|
||||
const layout = useLayout()
|
||||
const route = useCurrentRoute()
|
||||
const owner = getOwner()
|
||||
const [store, setStore] = createStore<Record<string, BrowserAttachment | undefined>>({})
|
||||
// Servers whose plugin lacks the browser RPC; sessions on them stop retrying.
|
||||
const [unsupported, setUnsupported] = createStore<Record<string, true | undefined>>({})
|
||||
const live = new Map<string, Live>()
|
||||
const focus = new Map<string, Set<(tabID: Browser.TabID) => void>>()
|
||||
const preview = new Map<string, Set<(path: string) => void>>()
|
||||
const key = (server: Server, sessionID: string) => `${server.key}\n${sessionID}`
|
||||
const enabled = createMemo(
|
||||
() => !!platform.browserPane && settings.ready() && settings.general.experimentalBrowser(),
|
||||
@@ -87,11 +89,14 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
enabled,
|
||||
supported: (server: Server) => !unsupported[server.key],
|
||||
state: (server: Server, sessionID: string) => store[key(server, sessionID)],
|
||||
attach(server: Server, sessionID: string) {
|
||||
attach(server: Server, sessionID: string, sessionKey: SessionStateKey) {
|
||||
const id = key(server, sessionID)
|
||||
if (live.has(id)) return
|
||||
const pane = platform.browserPane
|
||||
if (!pane || !enabled() || unsupported[server.key] || server.health?.incompatible) return
|
||||
// Focus requests write to the owning session's layout even while another shell tab is routed,
|
||||
// so the Review pane and browser tab are already selected when the user returns to it.
|
||||
const tabs = createRoot((dispose) => ({ dispose, layout: layout.tabs(sessionKey) }), owner)
|
||||
const connection = createBrowserConnection({
|
||||
pane,
|
||||
// Resolve the current port at every wake, including after sidecar replacement.
|
||||
@@ -100,7 +105,15 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
sessionID,
|
||||
endpoint: { ...server.conn.http, url: server.ctx.sdk.url },
|
||||
}),
|
||||
focus: (tabID) => focus.get(id)?.forEach((listener) => listener(tabID)),
|
||||
focus: (tabID) => {
|
||||
const tab = sessionBrowserTab(tabID)
|
||||
batch(() => {
|
||||
shellTabs.setPane(findSessionTab(shellTabs.store, server.key, sessionID), "review", true)
|
||||
if (!tabs.layout.all().includes(tab)) tabs.layout.setAll([...tabs.layout.all(), tab])
|
||||
tabs.layout.setActive(tab)
|
||||
})
|
||||
},
|
||||
preview: (path) => preview.get(id)?.forEach((listener) => listener(path)),
|
||||
change: (state) => {
|
||||
if (state.error === "browser.pane.unsupported") {
|
||||
setUnsupported(server.key, true)
|
||||
@@ -138,17 +151,18 @@ export const { use: useBrowserAttachments, provider: BrowserAttachmentsProvider
|
||||
entry.dispose = () => {
|
||||
unsubscribe?.forEach((dispose) => dispose())
|
||||
connection.dispose()
|
||||
tabs.dispose()
|
||||
}
|
||||
},
|
||||
/** Desktop focus requests for a mounted session route; nothing is replayed to routes mounted later. */
|
||||
onFocus(server: Server, sessionID: string, listener: (tabID: Browser.TabID) => void) {
|
||||
/** Agent requests to show a file in this session's Review pane. */
|
||||
onPreview(server: Server, sessionID: string, listener: (path: string) => void) {
|
||||
const id = key(server, sessionID)
|
||||
const listeners = focus.get(id) ?? new Set()
|
||||
const listeners = preview.get(id) ?? new Set()
|
||||
listeners.add(listener)
|
||||
focus.set(id, listeners)
|
||||
preview.set(id, listeners)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
if (!listeners.size) focus.delete(id)
|
||||
if (!listeners.size) preview.delete(id)
|
||||
}
|
||||
},
|
||||
command(server: Server, sessionID: string, command: BrowserPaneCommand) {
|
||||
|
||||
@@ -28,10 +28,12 @@ function fixture() {
|
||||
commands: Browser.Action[]
|
||||
}[] = []
|
||||
const endpoint = { url: "http://localhost:4096" }
|
||||
const previews: string[] = []
|
||||
const connection = createBrowserConnection({
|
||||
target: () => ({ serverKey: "browser-test", sessionID: "ses_browser", endpoint: { ...endpoint } }),
|
||||
change: (state) => states.push(state),
|
||||
focus: () => {},
|
||||
preview: (path) => previews.push(path),
|
||||
pane: {
|
||||
register(target, emit) {
|
||||
const call = { target, emit, closed: false, commands: [] as Browser.Action[] }
|
||||
@@ -50,9 +52,21 @@ function fixture() {
|
||||
})
|
||||
connection.wake()
|
||||
calls[0].emit({ type: "state", state: browser })
|
||||
return { connection, calls, states, endpoint }
|
||||
return { connection, calls, states, endpoint, previews }
|
||||
}
|
||||
|
||||
test("preview requests reach the session without touching connection state", () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
const before = app.states.length
|
||||
app.calls[0].emit({ type: "preview", path: "docs/report.pdf" })
|
||||
expect(app.previews).toEqual(["docs/report.pdf"])
|
||||
expect(app.states).toHaveLength(before)
|
||||
} finally {
|
||||
app.connection.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
test("suspension retains tabs and reconnects once on demand using the current endpoint", async () => {
|
||||
const app = fixture()
|
||||
try {
|
||||
|
||||
@@ -19,6 +19,7 @@ export function createBrowserConnection(input: {
|
||||
target: () => BrowserPaneTarget
|
||||
change: (state: BrowserConnectionState) => void
|
||||
focus: (tabID: Browser.TabID) => void
|
||||
preview: (path: string) => void
|
||||
}) {
|
||||
const state: BrowserConnectionState = { browser: null, suspended: false }
|
||||
let disposed = false
|
||||
@@ -33,6 +34,7 @@ export function createBrowserConnection(input: {
|
||||
(event) => {
|
||||
if (disposed || state.registration !== registration) return
|
||||
if (event.type === "focus") return input.focus(event.tabID)
|
||||
if (event.type === "preview") return input.preview(event.path)
|
||||
if (event.error === "browser.pane.unsupported" || event.error === "browser.pane.replaced") {
|
||||
blocked = true
|
||||
registration.close()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { batch, createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { batch, createEffect, createMemo, on } from "solid-js"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
@@ -33,13 +33,6 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
attachment()?.browser?.tabs.filter((tab) => session.layout.tabs().all().includes(sessionBrowserTab(tab.id))) ??
|
||||
[],
|
||||
)
|
||||
const focus = (tabID: Browser.TabID) => {
|
||||
session.layout.view().reviewPanel.open()
|
||||
const tabs = session.layout.tabs()
|
||||
const key = sessionBrowserTab(tabID)
|
||||
if (!tabs.all().includes(key)) tabs.setAll([...tabs.all(), key])
|
||||
tabs.setActive(key)
|
||||
}
|
||||
const command = (command: BrowserPaneCommand) => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
@@ -66,8 +59,7 @@ export function createSessionBrowser(session: SessionModel) {
|
||||
createEffect(() => {
|
||||
const sessionID = session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID)
|
||||
onCleanup(attachments.onFocus(server, sessionID, focus))
|
||||
if (attachments.enabled()) attachments.attach(server, sessionID, session.layout.sessionKey())
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
|
||||
@@ -20,13 +20,18 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const command = useCommand()
|
||||
const state = props.browser.active
|
||||
const address = () => (state()?.url === "about:blank" ? "" : (state()?.url ?? ""))
|
||||
const failed = () => !!state()?.loadError
|
||||
const registration = props.browser.registration
|
||||
const button = { variant: "ghost", size: "large" } as const
|
||||
const [store, setStore] = createStore({
|
||||
address: "",
|
||||
editing: false,
|
||||
submitted: false,
|
||||
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
|
||||
navigating: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
let surface: HTMLDivElement | undefined
|
||||
let addressDisplay: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
@@ -43,7 +48,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
title: language.t("command.browser.reload"),
|
||||
category: language.t("command.category.view"),
|
||||
keybind: "f5",
|
||||
disabled: !props.visible || !state(),
|
||||
disabled: !props.visible || !address(),
|
||||
onSelect: () => {
|
||||
const tab = state()
|
||||
if (tab) props.browser.command({ type: "reload", tabID: tab.id })
|
||||
@@ -71,7 +76,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const top = Math.round(rect.top * zoom)
|
||||
const right = Math.round(rect.right * zoom)
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
const visible = props.visible && store.visible && !dialog.active && !covered(rect)
|
||||
// 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)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
@@ -107,7 +114,25 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
if (frame === undefined) frame = requestAnimationFrame(tick)
|
||||
}
|
||||
|
||||
createEffect(() => !store.editing && setStore("address", address()))
|
||||
createEffect(on([() => state()?.id, address], () => !store.editing && setStore("address", address())))
|
||||
// Any reported movement, including a rejected or blocked request, ends the submitted navigation.
|
||||
createEffect(
|
||||
on(
|
||||
[() => state()?.id, () => state()?.generation, () => state()?.loading, () => props.browser.error()],
|
||||
() => setStore("navigating", false),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// A blocked or rejected submission leaves the page where it was; show that page's URL again.
|
||||
createEffect(
|
||||
on(
|
||||
() => props.browser.error(),
|
||||
(error) => {
|
||||
if (error && !store.editing) setStore("address", address())
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
createEffect(
|
||||
on(registration, (current) => {
|
||||
// Session routes can change before this pane unmounts. Hide the registration
|
||||
@@ -123,6 +148,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
() => store.visible,
|
||||
() => props.visible,
|
||||
() => state()?.id,
|
||||
empty,
|
||||
failed,
|
||||
registration,
|
||||
],
|
||||
() => {
|
||||
@@ -157,7 +184,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
|
||||
return (
|
||||
<aside id="browser-panel" class="relative size-full min-w-0 overflow-hidden bg-v2-background-bg-base flex flex-col">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-2 border-b border-v2-border-border-muted">
|
||||
<div class="h-10 shrink-0 flex items-center gap-1 px-3 border-b border-v2-border-border-muted">
|
||||
<For each={["back", "forward"] as const}>
|
||||
{(direction) => (
|
||||
<Tooltip placement="top" value={language.t(direction === "back" ? "common.goBack" : "common.goForward")}>
|
||||
@@ -193,7 +220,7 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
>
|
||||
<IconButton
|
||||
{...button}
|
||||
disabled={!state()}
|
||||
disabled={!state()?.loading && !address()}
|
||||
aria-label={language.t(state()?.loading ? "prompt.action.stop" : "error.page.action.reload")}
|
||||
onClick={() => {
|
||||
const tab = state()
|
||||
@@ -212,8 +239,13 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
const tab = state()
|
||||
if (tab && store.address.trim())
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: store.address })
|
||||
const url = store.address.trim()
|
||||
if (!tab) return
|
||||
if (url || failed()) {
|
||||
setStore({ submitted: true, address: url, navigating: true })
|
||||
props.browser.command({ type: "navigate", tabID: tab.id, url: url || "about:blank" })
|
||||
}
|
||||
event.currentTarget.querySelector("input")?.blur()
|
||||
}}
|
||||
>
|
||||
<input
|
||||
@@ -224,8 +256,14 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
disabled={!state()}
|
||||
placeholder={language.t("session.browser.address.placeholder")}
|
||||
aria-label={language.t("session.browser.address")}
|
||||
onFocus={() => setStore("editing", true)}
|
||||
onBlur={() => setStore({ editing: false, address: address() })}
|
||||
onFocus={(event) => {
|
||||
setStore("editing", true)
|
||||
event.currentTarget.select()
|
||||
}}
|
||||
onClick={(event) => event.currentTarget.select()}
|
||||
onBlur={() =>
|
||||
setStore({ editing: false, address: store.submitted ? store.address : address(), submitted: false })
|
||||
}
|
||||
onInput={(event) => setStore("address", event.currentTarget.value)}
|
||||
onScroll={(event) => {
|
||||
if (addressDisplay) addressDisplay.scrollLeft = event.currentTarget.scrollLeft
|
||||
@@ -243,12 +281,27 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<Show when={props.browser.error()}>
|
||||
<Show when={props.browser.error() && !failed()}>
|
||||
<div class="shrink-0 px-3 py-1.5 text-12-regular text-text-danger-base border-b border-v2-border-border-muted">
|
||||
{props.browser.error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<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
|
||||
dir="auto"
|
||||
class="flex size-full flex-col items-center justify-center gap-2 p-6 pb-[200px] text-center text-text-weak"
|
||||
>
|
||||
<Icon name="globe" size="large" class="mb-2 shrink-0" />
|
||||
<div class="text-[13px] font-medium leading-[var(--line-height-compact)] text-text-strong">
|
||||
{language.t(failed() ? "session.browser.failed.title" : "session.browser.empty.title")}
|
||||
</div>
|
||||
<div class="text-13-regular leading-[var(--line-height-base)]">
|
||||
{language.t(failed() ? "session.browser.failed.description" : "session.browser.empty.description")}
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.browser.suspended()}>
|
||||
<p class="px-6 text-center text-13-regular text-v2-text-text-subtle" role="status">
|
||||
{language.t("session.browser.suspended")}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/* The control defaults to a fixed 232px with equal segments; here it hugs its two labels. */
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2"] {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item"] {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 14px;
|
||||
}
|
||||
|
||||
/* Shrink-wrapped segments round the label box down a fraction; the ellipsis overflow then clips glyph edges. */
|
||||
[data-slot="artifact-toolbar"] [data-slot="segmented-control-v2-item-label"] {
|
||||
overflow: visible;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"] {
|
||||
--artifact-check: color-mix(in oklch, var(--v2-text-text-base) 5%, transparent);
|
||||
background-color: var(--v2-background-bg-deep);
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-checker] {
|
||||
background-image:
|
||||
linear-gradient(45deg, var(--artifact-check) 25%, transparent 25%),
|
||||
linear-gradient(-45deg, var(--artifact-check) 25%, transparent 25%),
|
||||
linear-gradient(45deg, transparent 75%, var(--artifact-check) 75%),
|
||||
linear-gradient(-45deg, transparent 75%, var(--artifact-check) 75%);
|
||||
background-size: 16px 16px;
|
||||
background-position:
|
||||
0 0,
|
||||
0 8px,
|
||||
8px -8px,
|
||||
-8px 0;
|
||||
}
|
||||
|
||||
[data-slot="artifact-media"] {
|
||||
display: block;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 0 0 0.5px var(--v2-border-border-base),
|
||||
0 12px 32px -12px color-mix(in oklch, var(--v2-text-text-base) 25%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="fit"] [data-slot="artifact-media"] {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="fit"][data-overflow] [data-slot="artifact-media"] {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
[data-slot="artifact-stage"][data-zoom="actual"] [data-slot="artifact-media"] {
|
||||
cursor: zoom-out;
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] {
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] th,
|
||||
[data-slot="artifact-table"] td {
|
||||
padding: 5px 12px;
|
||||
border-bottom: 1px solid var(--v2-border-border-muted);
|
||||
border-inline-end: 1px solid var(--v2-border-border-muted);
|
||||
white-space: pre;
|
||||
max-width: 40ch;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: start;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] tbody tr:hover td {
|
||||
background: color-mix(in oklch, var(--v2-text-text-base) 3%, transparent);
|
||||
}
|
||||
|
||||
[data-slot="artifact-table"] td[data-index] {
|
||||
color: var(--v2-text-text-faint);
|
||||
text-align: end;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -0,0 +1,454 @@
|
||||
import { createEffect, createMemo, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Button } from "@opencode/ui/button"
|
||||
import { FileIcon } from "@opencode/ui/file-icon"
|
||||
import { SegmentedControl, SegmentedControlItem } from "@opencode/ui/segmented-control"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { Markdown } from "@opencode/session-ui/markdown"
|
||||
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import {
|
||||
artifactKind,
|
||||
blobUrlFromContent,
|
||||
contentBytes,
|
||||
parseDelimited,
|
||||
resolveArtifactPath,
|
||||
type ArtifactKind,
|
||||
} from "@/workspaces/files/artifact"
|
||||
import { useArtifactOpener } from "@/session/files/open-artifact"
|
||||
import "./artifact-view.css"
|
||||
|
||||
type ArtifactMode = "preview" | "source"
|
||||
|
||||
/** Facts a viewer learns from the decoded media, shown in the toolbar. */
|
||||
type ArtifactInfo = { width?: number; height?: number; duration?: number; rows?: number; columns?: number }
|
||||
|
||||
type MediaProps = {
|
||||
path: string
|
||||
content: FileContent
|
||||
onInfo: (info: ArtifactInfo) => void
|
||||
/** The browser could not decode the bytes; the host falls back to the binary placeholder. */
|
||||
onError: () => void
|
||||
}
|
||||
|
||||
/** Kinds that render a preview from their text and can toggle back to highlighted source. */
|
||||
const previewableKinds = new Set<ArtifactKind>(["svg", "html", "markdown", "mermaid", "table"])
|
||||
|
||||
/**
|
||||
* Renders a loaded non-text file: media, documents, and data get a dedicated viewer with a toolbar;
|
||||
* previewable text kinds can switch to `source`, which the host supplies (its code view).
|
||||
*/
|
||||
export function ArtifactView(props: { path: string; content: FileContent; cacheKey?: string; source: JSX.Element }) {
|
||||
const language = useLanguage()
|
||||
const [state, setState] = createStore({
|
||||
mode: "preview" as ArtifactMode,
|
||||
info: {} as ArtifactInfo,
|
||||
// Media the browser could not decode falls back to the binary placeholder.
|
||||
undecodable: false,
|
||||
})
|
||||
createEffect(
|
||||
on(
|
||||
() => props.content,
|
||||
() => setState({ mode: "preview", info: {}, undecodable: false }),
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
|
||||
const kind = createMemo<ArtifactKind | "binary">(() => {
|
||||
if (props.content.type === "binary" && !props.content.mimeType) return "binary"
|
||||
if (state.undecodable) return "binary"
|
||||
return artifactKind(props.path)
|
||||
})
|
||||
const previewable = createMemo(() => {
|
||||
const value = kind()
|
||||
return value !== "binary" && previewableKinds.has(value)
|
||||
})
|
||||
const meta = createMemo(() => {
|
||||
const info = state.info
|
||||
return [
|
||||
info.width && info.height ? `${info.width} × ${info.height}` : undefined,
|
||||
info.duration ? formatDuration(info.duration) : undefined,
|
||||
info.rows !== undefined ? language.plural("file.view.table.rows", Math.max(0, info.rows - 1)) : undefined,
|
||||
info.columns !== undefined ? language.plural("file.view.table.columns", info.columns) : undefined,
|
||||
formatBytes(language.intl(), contentBytes(props.content)),
|
||||
].filter((item): item is string => !!item)
|
||||
})
|
||||
|
||||
const media = { onInfo: (info: ArtifactInfo) => setState("info", info), onError: () => setState("undecodable", true) }
|
||||
const rendered = () => (
|
||||
<ScrollView class="min-h-0 flex-1">
|
||||
<Show
|
||||
when={kind() === "markdown"}
|
||||
fallback={<ArtifactMermaid text={props.content.content} cacheKey={props.cacheKey} />}
|
||||
>
|
||||
<ArtifactMarkdown path={props.path} text={props.content.content} cacheKey={props.cacheKey} />
|
||||
</Show>
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<ArtifactToolbar
|
||||
mode={state.mode}
|
||||
onModeChange={previewable() ? (mode) => setState("mode", mode) : undefined}
|
||||
meta={meta()}
|
||||
actions={
|
||||
<Show when={kind() === "html"}>
|
||||
<OpenInBrowserButton path={props.path} />
|
||||
</Show>
|
||||
}
|
||||
/>
|
||||
<Show when={!previewable() || state.mode === "preview"} fallback={props.source}>
|
||||
<Switch>
|
||||
<Match when={kind() === "image" || kind() === "svg"}>
|
||||
<ArtifactImage path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "video"}>
|
||||
<ArtifactVideo path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "audio"}>
|
||||
<ArtifactAudio path={props.path} content={props.content} {...media} />
|
||||
</Match>
|
||||
<Match when={kind() === "pdf" || kind() === "html"}>
|
||||
<ArtifactFrame path={props.path} content={props.content} kind={kind() === "pdf" ? "pdf" : "html"} />
|
||||
</Match>
|
||||
<Match when={kind() === "font"}>
|
||||
<ArtifactFont path={props.path} content={props.content} />
|
||||
</Match>
|
||||
<Match when={kind() === "table"}>
|
||||
<ArtifactTable path={props.path} text={props.content.content} onInfo={media.onInfo} />
|
||||
</Match>
|
||||
<Match when={kind() === "markdown" || kind() === "mermaid"}>{rendered()}</Match>
|
||||
<Match when={kind() === "binary"}>
|
||||
<ArtifactBinary path={props.path} size={formatBytes(language.intl(), contentBytes(props.content))} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function formatBytes(locale: string, bytes: number) {
|
||||
const units = ["byte", "kilobyte", "megabyte", "gigabyte"] as const
|
||||
const index = Math.min(units.length - 1, bytes > 0 ? Math.floor(Math.log10(bytes) / 3) : 0)
|
||||
const value = bytes / 1000 ** index
|
||||
return new Intl.NumberFormat(locale, {
|
||||
style: "unit",
|
||||
unit: units[index],
|
||||
// "short" bytes render as the singular "byte"; the long form pluralizes correctly.
|
||||
unitDisplay: index === 0 ? "long" : "short",
|
||||
maximumFractionDigits: value >= 100 || index === 0 ? 0 : 1,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const total = Math.round(seconds)
|
||||
const minutes = Math.floor(total / 60)
|
||||
return `${minutes}:${String(total % 60).padStart(2, "0")}`
|
||||
}
|
||||
|
||||
function ArtifactToolbar(props: {
|
||||
mode?: ArtifactMode
|
||||
onModeChange?: (mode: ArtifactMode) => void
|
||||
meta: string[]
|
||||
actions?: JSX.Element
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-slot="artifact-toolbar" class="flex h-10 shrink-0 items-center gap-3 px-4">
|
||||
<Show when={props.onModeChange}>
|
||||
<SegmentedControl
|
||||
value={props.mode ?? "preview"}
|
||||
onChange={(value) => {
|
||||
if (value === "preview" || value === "source") props.onModeChange?.(value)
|
||||
}}
|
||||
>
|
||||
<SegmentedControlItem value="preview">{language.t("file.view.preview")}</SegmentedControlItem>
|
||||
<SegmentedControlItem value="source">{language.t("file.view.source")}</SegmentedControlItem>
|
||||
</SegmentedControl>
|
||||
</Show>
|
||||
<div class="ms-auto flex min-w-0 items-center gap-3">
|
||||
<div class="flex min-w-0 items-center gap-2 text-12-regular text-text-weak">
|
||||
<For each={props.meta}>
|
||||
{(item, index) => (
|
||||
<>
|
||||
<Show when={index() > 0}>
|
||||
<span aria-hidden class="text-text-faint">
|
||||
·
|
||||
</span>
|
||||
</Show>
|
||||
<span class="truncate tabular-nums">{item}</span>
|
||||
</>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
{props.actions}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OpenInBrowserButton(props: { path: string }) {
|
||||
const language = useLanguage()
|
||||
const artifacts = useArtifactOpener()
|
||||
return (
|
||||
<Show when={artifacts.canOpenInBrowser(props.path)}>
|
||||
<Button size="small" variant="ghost" icon="globe" onClick={() => artifacts.openInBrowser(props.path)}>
|
||||
{language.t("file.view.openInBrowser")}
|
||||
</Button>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function createBlobUrl(content: () => FileContent) {
|
||||
return createMemo(() => {
|
||||
const value = blobUrlFromContent(content())
|
||||
onCleanup(() => URL.revokeObjectURL(value))
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
/** Images and SVG previews: fit the pane, click to inspect at 1:1 when the image is larger. */
|
||||
function ArtifactImage(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
const [state, setState] = createStore({ zoom: "fit" as "fit" | "actual", overflow: false, width: 0, height: 0 })
|
||||
let stage: HTMLDivElement | undefined
|
||||
const measure = () => {
|
||||
if (!stage) return
|
||||
setState("overflow", state.width > stage.clientWidth - 48 || state.height > stage.clientHeight - 48)
|
||||
}
|
||||
createResizeObserver(
|
||||
() => stage,
|
||||
() => measure(),
|
||||
)
|
||||
createEffect(() => {
|
||||
url()
|
||||
setState({ zoom: "fit", overflow: false })
|
||||
})
|
||||
return (
|
||||
<div
|
||||
ref={stage}
|
||||
data-slot="artifact-stage"
|
||||
data-checker
|
||||
data-zoom={state.zoom}
|
||||
data-overflow={state.overflow || undefined}
|
||||
class="relative min-h-0 flex-1 overflow-auto"
|
||||
>
|
||||
<div
|
||||
classList={{
|
||||
"absolute inset-0 flex items-center justify-center p-6": state.zoom === "fit",
|
||||
"flex min-h-full min-w-full w-max items-center justify-center p-6": state.zoom === "actual",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
data-slot="artifact-media"
|
||||
src={url()}
|
||||
alt={getFilename(props.path)}
|
||||
draggable={false}
|
||||
onError={() => props.onError()}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
setState({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
props.onInfo({ width: image.naturalWidth, height: image.naturalHeight })
|
||||
measure()
|
||||
}}
|
||||
onClick={() => {
|
||||
if (!state.overflow && state.zoom === "fit") return
|
||||
setState("zoom", state.zoom === "fit" ? "actual" : "fit")
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactVideo(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
return (
|
||||
<div data-slot="artifact-stage" data-zoom="fit" class="relative min-h-0 flex-1 overflow-hidden">
|
||||
<div class="absolute inset-0 flex items-center justify-center p-6">
|
||||
<video
|
||||
data-slot="artifact-media"
|
||||
class="w-full bg-black"
|
||||
controls
|
||||
preload="metadata"
|
||||
playsinline
|
||||
onError={() => props.onError()}
|
||||
src={url()}
|
||||
onLoadedMetadata={(event) => {
|
||||
const video = event.currentTarget
|
||||
props.onInfo({ width: video.videoWidth, height: video.videoHeight, duration: video.duration })
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactAudio(props: MediaProps) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
return (
|
||||
<div data-slot="artifact-stage" class="relative min-h-0 flex-1 overflow-auto">
|
||||
<div class="absolute inset-0 flex items-center justify-center p-6">
|
||||
<div class="flex w-full max-w-lg flex-col items-center gap-5 rounded-xl border border-v2-border-border-muted bg-v2-background-bg-base px-8 py-8 shadow-[var(--v2-elevation-raised)]">
|
||||
<div class="flex size-14 items-center justify-center rounded-full bg-v2-background-bg-layer-02">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="size-7" />
|
||||
</div>
|
||||
<div class="max-w-full truncate text-14-medium text-text-strong">{getFilename(props.path)}</div>
|
||||
<audio
|
||||
class="w-full"
|
||||
onError={() => props.onError()}
|
||||
controls
|
||||
preload="metadata"
|
||||
src={url()}
|
||||
onLoadedMetadata={(event) => props.onInfo({ duration: event.currentTarget.duration })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactFrame(props: { path: string; content: FileContent; kind: "pdf" | "html" }) {
|
||||
const url = createBlobUrl(() => props.content)
|
||||
// PDF Open Parameters: start with the thumbnail pane closed and the page fitted to the pane width.
|
||||
const src = () => (props.kind === "pdf" ? `${url()}#navpanes=0&view=FitH` : url())
|
||||
return (
|
||||
<iframe
|
||||
class="block h-full w-full flex-1 border-0 bg-white"
|
||||
title={getFilename(props.path)}
|
||||
src={src()}
|
||||
// The PDF viewer is Chromium's own and does not run in a sandboxed frame. HTML runs as an
|
||||
// opaque origin: no app storage, cookies, or credentialed requests reach it.
|
||||
sandbox={props.kind === "html" ? "allow-scripts allow-popups allow-forms allow-modals" : undefined}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactMarkdown(props: { path: string; text: string; cacheKey?: string }) {
|
||||
const parent = useMarkdown()
|
||||
const artifacts = useArtifactOpener()
|
||||
// getDirectory yields "/" for a root-level file, which would make relative links absolute.
|
||||
const dir = createMemo(() => (props.path.includes("/") || props.path.includes("\\") ? getDirectory(props.path) : ""))
|
||||
// Absolute references bypass the file's directory; relative ones resolve against it.
|
||||
const resolve = (href: string) => (/^([a-z]:)?\//i.test(href) ? href : (resolveArtifactPath(dir(), href) ?? href))
|
||||
return (
|
||||
<MarkdownProvider
|
||||
readImage={(src, signal) => parent?.readImage?.(resolve(src), signal) ?? Promise.resolve(undefined)}
|
||||
openLocalFile={(href) => artifacts.open(href, dir())}
|
||||
>
|
||||
<div class="mx-auto w-full max-w-3xl px-8 py-6">
|
||||
<Markdown text={props.text} cacheKey={props.cacheKey} class="select-text" />
|
||||
</div>
|
||||
</MarkdownProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/** Mermaid sources render through the same fenced-block pipeline the timeline uses. */
|
||||
function ArtifactMermaid(props: { text: string; cacheKey?: string }) {
|
||||
return (
|
||||
<div class="mx-auto w-full max-w-4xl px-8 py-6">
|
||||
<Markdown text={`\`\`\`mermaid\n${props.text}\n\`\`\``} cacheKey={props.cacheKey} class="select-text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactTable(props: { path: string; text: string; onInfo: (info: ArtifactInfo) => void }) {
|
||||
const language = useLanguage()
|
||||
const parsed = createMemo(() => parseDelimited(props.text, props.path.toLowerCase().endsWith(".tsv") ? "\t" : ","))
|
||||
createEffect(() => props.onInfo({ rows: parsed().total, columns: parsed().columns }))
|
||||
// Pad the header to the widest row so no data column is dropped.
|
||||
const header = () => Array.from({ length: parsed().columns }, (_, index) => parsed().rows[0]?.[index] ?? "")
|
||||
const body = () => parsed().rows.slice(1)
|
||||
return (
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<table data-slot="artifact-table" class="min-w-full text-13-regular text-text-base">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-index />
|
||||
<For each={header()}>{(cell) => <th>{cell}</th>}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<For each={body()}>
|
||||
{(row, index) => (
|
||||
<tr>
|
||||
<td data-index>{index() + 1}</td>
|
||||
<For each={header()}>{(_, column) => <td>{row[column()] ?? ""}</td>}</For>
|
||||
</tr>
|
||||
)}
|
||||
</For>
|
||||
</tbody>
|
||||
</table>
|
||||
<Show when={parsed().total > parsed().rows.length}>
|
||||
<div class="px-4 py-3 text-12-regular text-text-weak">
|
||||
{language.t("file.view.table.truncated", { shown: parsed().rows.length - 1, total: parsed().total - 1 })}
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const specimenSizes = [12, 16, 24, 40, 64]
|
||||
|
||||
function ArtifactFont(props: { path: string; content: FileContent }) {
|
||||
const language = useLanguage()
|
||||
const url = createBlobUrl(() => props.content)
|
||||
const family = createMemo(() => `artifact-${Math.random().toString(36).slice(2)}`)
|
||||
createEffect(() => {
|
||||
const face = new FontFace(family(), `url(${url()})`)
|
||||
document.fonts.add(face)
|
||||
void face.load().catch(() => undefined)
|
||||
onCleanup(() => document.fonts.delete(face))
|
||||
})
|
||||
return (
|
||||
<div class="min-h-0 flex-1 overflow-auto">
|
||||
<div class="mx-auto flex w-full max-w-3xl flex-col gap-6 px-8 py-8" style={{ "font-family": `"${family()}"` }}>
|
||||
<div class="text-text-strong" style={{ "font-size": "56px", "line-height": "1.1" }}>
|
||||
{getFilename(props.path).replace(/\.[^.]+$/, "")}
|
||||
</div>
|
||||
<div class="break-all text-text-base" style={{ "font-size": "22px", "line-height": "1.4" }}>
|
||||
ABCDEFGHIJKLMNOPQRSTUVWXYZ
|
||||
<br />
|
||||
abcdefghijklmnopqrstuvwxyz
|
||||
<br />
|
||||
0123456789 !?&@#%(){}[]
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 border-t border-v2-border-border-muted pt-6">
|
||||
<For each={specimenSizes}>
|
||||
{(size) => (
|
||||
<div class="flex items-baseline gap-4">
|
||||
<span
|
||||
class="w-8 shrink-0 text-12-regular text-text-faint tabular-nums"
|
||||
style={{ "font-family": "var(--font-family-mono)" }}
|
||||
>
|
||||
{size}
|
||||
</span>
|
||||
<span class="text-text-base" style={{ "font-size": `${size}px`, "line-height": "1.25" }}>
|
||||
{language.t("file.view.fontSample")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ArtifactBinary(props: { path: string; size: string }) {
|
||||
const language = useLanguage()
|
||||
return (
|
||||
<div data-slot="artifact-stage" class="relative min-h-0 flex-1">
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
|
||||
<FileIcon node={{ path: props.path, type: "file" }} class="size-8 text-text-weak" />
|
||||
<div class="text-14-medium text-text-strong">{getFilename(props.path)}</div>
|
||||
<div class="text-13-regular text-text-weak">{language.t("file.view.binary", { size: props.size })}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Switch } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, Match, on, onCleanup, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
@@ -11,8 +11,9 @@ import { LineCommentOverflowIcon } from "@opencode/ui/line-comment"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
import { ScrollView } from "@opencode/ui/scroll-view"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/workspaces/files/model"
|
||||
import { artifactKind } from "@/workspaces/files/artifact"
|
||||
import { ArtifactView } from "@/session/files/artifact-view"
|
||||
import { useComments } from "@/composer/comments"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useComposerState } from "@/composer/persistence"
|
||||
@@ -205,6 +206,11 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
})
|
||||
const contents = createMemo(() => state()?.content?.content ?? "")
|
||||
const cacheKey = createMemo(() => sampledChecksum(contents()))
|
||||
// Plain text keeps the code view; every other kind is rendered by ArtifactView.
|
||||
const artifact = createMemo(() => {
|
||||
const content = state()?.content
|
||||
return content?.type === "binary" || artifactKind(path() ?? "") !== "text"
|
||||
})
|
||||
const selectedLines = createMemo<SelectedLineRange | null>(() => {
|
||||
const p = path()
|
||||
if (!p) return null
|
||||
@@ -433,39 +439,42 @@ export function SessionFileView(props: SessionFileViewProps) {
|
||||
}}
|
||||
search={search}
|
||||
class="select-text"
|
||||
media={{
|
||||
mode: "auto",
|
||||
path: path(),
|
||||
current: state()?.content,
|
||||
onLoad: scrollSync.queueRestore,
|
||||
onError: (args: { kind: "image" | "audio" | "svg" }) => {
|
||||
if (args.kind !== "svg") return
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.file.loadFailed.title"),
|
||||
})
|
||||
},
|
||||
}}
|
||||
// Media and previews have their own viewers below; the code view only ever shows text.
|
||||
media={{ mode: "off" }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
// The code view scrolls inside ScrollView so line state and scroll position persist per tab.
|
||||
const codeView = (source: string) => (
|
||||
<ScrollView class="min-h-0 flex-1" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
|
||||
{renderFile(source)}
|
||||
</ScrollView>
|
||||
)
|
||||
|
||||
const content = () => (
|
||||
<div class="mt-3 relative h-full min-h-0">
|
||||
<ScrollView class="h-full" viewportRef={scrollSync.setViewport} onScroll={scrollSync.handleScroll}>
|
||||
<Switch>
|
||||
<Match when={state()?.loaded}>{renderFile(contents())}</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}…</div>
|
||||
</Match>
|
||||
<Match when={state()?.notFound ? state()?.name : undefined}>
|
||||
{(name) => (
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</ScrollView>
|
||||
<div class="mt-3 relative h-full min-h-0 flex flex-col">
|
||||
<Switch>
|
||||
<Match when={state()?.loaded ? state()?.content : undefined}>
|
||||
{(value) => (
|
||||
<Show when={artifact()} fallback={codeView(value().content)}>
|
||||
<ArtifactView
|
||||
path={path() ?? ""}
|
||||
content={value()}
|
||||
cacheKey={cacheKey()}
|
||||
source={codeView(value().content)}
|
||||
/>
|
||||
</Show>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={state()?.loading}>
|
||||
<div class="px-6 py-4 text-text-weak">{language.t("common.loading")}…</div>
|
||||
</Match>
|
||||
<Match when={state()?.notFound ? state()?.name : undefined}>
|
||||
{(name) => <div class="px-6 py-4 text-text-weak">{language.t("file.error.notFound", { name: name() })}</div>}
|
||||
</Match>
|
||||
<Match when={state()?.error}>{(err) => <div class="px-6 py-4 text-text-weak">{err()}</div>}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createEffect, onCleanup, type ParentProps } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { MarkdownProvider, useMarkdown } from "@opencode/session-ui/context/markdown"
|
||||
import { useBrowserAttachments } from "@/session/browser/attachments"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { useFile } from "@/workspaces/files/model"
|
||||
import { artifactKind, resolveArtifactPath } from "@/workspaces/files/artifact"
|
||||
import { encodeFilePath } from "@/workspaces/files/path"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { createOpenSessionFileTab } from "@/session/helpers"
|
||||
import type { createSessionBrowser } from "@/session/browser/model"
|
||||
|
||||
/** Routes local links in timeline markdown to the artifact opener while keeping image loading. */
|
||||
export function ArtifactMarkdownProvider(props: ParentProps) {
|
||||
const markdown = useMarkdown()
|
||||
const artifacts = useArtifactOpener()
|
||||
return (
|
||||
<MarkdownProvider readImage={markdown?.readImage} openLocalFile={(path) => artifacts.open(path)}>
|
||||
{props.children}
|
||||
</MarkdownProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens files the agent references as side-panel tabs, inside or outside the workspace, or as
|
||||
* a browser tab for HTML when the desktop can load the file directly.
|
||||
*/
|
||||
export const { use: useArtifactOpener, provider: ArtifactOpenerProvider } = createSimpleContext({
|
||||
name: "ArtifactOpener",
|
||||
init: (props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) => {
|
||||
const file = useFile()
|
||||
const server = useServer()
|
||||
const location = useWorkspaceLocation()
|
||||
const attachments = useBrowserAttachments()
|
||||
const { tabs, view } = useSessionLayout()
|
||||
|
||||
const root = () => location().directory.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
|
||||
/**
|
||||
* Turn a link into a path `useFile` can load: workspace-relative when it is under the root,
|
||||
* otherwise absolute. Relative links resolve against `base`; ones that climb past the root
|
||||
* become absolute too, so a `../../shared/report.pdf` still opens.
|
||||
*/
|
||||
const resolve = (href: string, base?: string) => {
|
||||
// Agents cite locations as path:line or path:line:col; the file is what opens.
|
||||
const value = href.replaceAll("\\", "/").replace(/:\d+(?::\d+)?$/, "")
|
||||
if (/^[a-z]:\//i.test(value) || value.startsWith("/")) return file.normalize(value)
|
||||
const relative = resolveArtifactPath(base ?? "", value)
|
||||
if (relative !== undefined) return file.normalize(relative)
|
||||
// Climbing past the workspace root: resolve from the referencing folder's absolute location.
|
||||
const dir = base ? `${root()}/${base.replace(/\/+$/, "")}` : root()
|
||||
return file.normalize(resolveArtifactPath(dir, value) ?? value)
|
||||
}
|
||||
|
||||
const showTab = createOpenSessionFileTab({
|
||||
normalizeTab: (tab) => tab,
|
||||
openTab: (tab) => tabs().open(tab),
|
||||
pathFromTab: file.pathFromTab,
|
||||
loadFile: () => undefined,
|
||||
openReviewPanel: () => {
|
||||
if (!view().reviewPanel.opened()) view().reviewPanel.open()
|
||||
},
|
||||
setActive: (tab) => tabs().setActive(tab),
|
||||
})
|
||||
|
||||
// Inline paths are guessed from text, so confirm the file exists before a tab appears for it.
|
||||
const openTab = (path: string) => {
|
||||
void file.load(path).then(() => {
|
||||
if (file.get(path)?.loaded) showTab(file.tab(path))
|
||||
})
|
||||
}
|
||||
|
||||
// The desktop's own sidecar shares this disk, and its browser pane accepts file:// URLs inside the
|
||||
// session workspace only. Forwarded loopback servers do not qualify, matching the desktop policy.
|
||||
const canOpenInBrowser = (path?: string) =>
|
||||
ServerConnection.builtin(server.conn) &&
|
||||
props.browser.available() &&
|
||||
props.browser.attached() &&
|
||||
(path === undefined || !file.absolute(path))
|
||||
|
||||
const openInBrowser = (path: string) => {
|
||||
props.browser.command({ type: "tabs.open", url: `file://${encodeFilePath(`${root()}/${path}`)}` })
|
||||
}
|
||||
|
||||
/** Open `href` as referenced from `base` (a workspace-relative directory, "" for the root). */
|
||||
const open = (href: string, base?: string) => {
|
||||
const path = resolve(href, base)
|
||||
if (!path) return
|
||||
if (artifactKind(path) === "html" && canOpenInBrowser(path)) return openInBrowser(path)
|
||||
openTab(path)
|
||||
}
|
||||
|
||||
// The agent's browser.preview tool arrives through the desktop browser pane attachment.
|
||||
createEffect(() => {
|
||||
const sessionID = props.session.identity.sessionID()
|
||||
if (!sessionID) return
|
||||
onCleanup(attachments.onPreview(server, sessionID, (path) => open(path)))
|
||||
})
|
||||
|
||||
return { canOpenInBrowser, openInBrowser, open }
|
||||
},
|
||||
})
|
||||
@@ -173,7 +173,9 @@ export function SessionFileBrowserTab(props: {
|
||||
<SessionFilePanelV2Empty>
|
||||
<div class="flex flex-col items-center gap-2 text-center text-text-weak">
|
||||
<Icon name="file-tree" size="large" class="mb-2" />
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">{language.t("command.file.open")}</div>
|
||||
<div class="text-[13px] font-medium leading-[13px] text-text-strong">
|
||||
{language.t("command.file.open")}
|
||||
</div>
|
||||
<div class="h-5 text-13-regular leading-5">{language.t("session.files.selectToOpen")}</div>
|
||||
</div>
|
||||
</SessionFilePanelV2Empty>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { children, createMemo, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { IconButton } from "@opencode/ui/icon-button"
|
||||
import { Keybind } from "@opencode/ui/keybind"
|
||||
import { Tooltip } from "@opencode/ui/tooltip"
|
||||
import { Tabs } from "@opencode/ui/tabs"
|
||||
@@ -69,9 +71,19 @@ export function SortableTab(props: {
|
||||
placement="bottom"
|
||||
gutter={10}
|
||||
>
|
||||
<Tabs.CloseButton
|
||||
class="h-5 w-5"
|
||||
onClick={() => props.onTabClose(props.tab)}
|
||||
<IconButton
|
||||
size="small"
|
||||
variant="ghost-muted"
|
||||
onPointerDown={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}}
|
||||
onClick={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
props.onTabClose(props.tab)
|
||||
}}
|
||||
icon={<Icon name="xmark-small" />}
|
||||
aria-label={language.t("common.closeTab")}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
@@ -137,6 +137,7 @@ export function useSessionModel() {
|
||||
tabs: layout.tabs,
|
||||
view: layout.view,
|
||||
tabKey: layout.tabKey,
|
||||
sessionKey: layout.sessionKey,
|
||||
},
|
||||
ownership: createSessionOwnership(layout.sessionKey),
|
||||
tabs: {
|
||||
|
||||
@@ -155,6 +155,9 @@ export function createSessionReview(input: {
|
||||
const count = () => diffs().length
|
||||
const hasChanges = () => count() > 0
|
||||
const ready = () => {
|
||||
// A project without VCS never enables vcsQuery, so its status stays "pending" forever.
|
||||
const project = input.session.project()
|
||||
if (project && !project.vcs) return true
|
||||
if (mode() === "git" || mode() === "branch") return !vcsQuery.isPending
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -249,7 +249,7 @@ function ReviewTitle(props: { review: SessionReviewModel }) {
|
||||
function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
const noGit = () => props.review.noGit()
|
||||
const text = () => {
|
||||
if (props.review.mode() === "git") return language.t("session.review.noUncommittedChanges")
|
||||
if (props.review.mode() === "branch") return language.t("session.review.noBranchChanges")
|
||||
@@ -282,7 +282,7 @@ function ReviewEmpty(props: { review: SessionReviewModel; loadingClass: string }
|
||||
function ReviewPanelEmpty(props: { review: SessionReviewModel }) {
|
||||
const language = useLanguage()
|
||||
const loading = () => (props.review.mode() === "git" || props.review.mode() === "branch") && !props.review.ready()
|
||||
const noGit = () => props.review.mode() === "turn" && props.review.noGit()
|
||||
const noGit = () => props.review.noGit()
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
|
||||
@@ -38,6 +38,7 @@ import { SessionReviewToggle } from "./header/session-header-actions"
|
||||
import { createAnimatedPresence } from "@/runtime/animated-presence"
|
||||
import { createSessionBrowser } from "./browser/model"
|
||||
import { createTimelineCache } from "./timeline/cache"
|
||||
import { ArtifactMarkdownProvider, ArtifactOpenerProvider } from "./files/open-artifact"
|
||||
|
||||
const SessionMobileFiles = lazy(async () => {
|
||||
const { SessionMobileFiles } = await import("./files/session-mobile-files")
|
||||
@@ -50,14 +51,26 @@ const SessionSummaryPanel = lazy(async () => {
|
||||
})
|
||||
|
||||
export function SessionScreen(props: { session: SessionModel }) {
|
||||
// The timeline cache captures its owner when created, so link handling must be provided above it.
|
||||
const browser = createSessionBrowser(props.session)
|
||||
return (
|
||||
<ArtifactOpenerProvider session={props.session} browser={browser}>
|
||||
<ArtifactMarkdownProvider>
|
||||
<SessionScreenContent session={props.session} browser={browser} />
|
||||
</ArtifactMarkdownProvider>
|
||||
</ArtifactOpenerProvider>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionScreenContent(props: { session: SessionModel; browser: ReturnType<typeof createSessionBrowser> }) {
|
||||
const session = props.session
|
||||
const browser = props.browser
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const browser = createSessionBrowser(session)
|
||||
const screen = createSessionScreenLayout(session)
|
||||
const timeline = createSessionTimelineInteraction(session)
|
||||
const timelineSearch = createTimelineSearchController({
|
||||
|
||||
@@ -1102,10 +1102,6 @@
|
||||
padding-block: 20px;
|
||||
}
|
||||
|
||||
.settings-server-connection .settings-servers-lead {
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.settings-servers-row:not(:last-child) {
|
||||
padding-bottom: 16px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
@@ -9,7 +9,7 @@ import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { ServerKey } from "@/runtime/server/persistence"
|
||||
import { TabStorage } from "@/shell/tabs/schema"
|
||||
import { decode64 } from "@/runtime/persistence/base64"
|
||||
import { same } from "@/runtime/persistence/equality"
|
||||
import { createScrollPersistence, type SessionScroll } from "./scroll"
|
||||
@@ -165,7 +165,7 @@ export const layoutSchema = Persistence.struct({
|
||||
sessionView: Persistence.record(Persistence.fallback(sessionViewSchema, () => ({ scroll: {} }))),
|
||||
home: Persistence.struct({
|
||||
selection: Persistence.struct({
|
||||
server: Schema.optional(ServerKey),
|
||||
server: Schema.optional(TabStorage.ServerKey),
|
||||
directory: Schema.optional(Schema.String),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,58 +1,63 @@
|
||||
export * as TabStorage from "./schema"
|
||||
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { ServerKey } from "@/runtime/server/key"
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { ServerKey } from "@/runtime/server/persistence"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
export { ServerKey }
|
||||
|
||||
export const Session = Codec.struct({
|
||||
type: Codec.literal("session"),
|
||||
export const Session = Persistence.struct({
|
||||
type: Schema.Literal("session"),
|
||||
server: ServerKey,
|
||||
sessionId: Codec.string,
|
||||
routeSessionId: Codec.lenientOptional(Codec.string),
|
||||
routeParentId: Codec.lenientOptional(Codec.string),
|
||||
sessionId: Schema.String,
|
||||
routeSessionId: Persistence.optional(Schema.String),
|
||||
routeParentId: Persistence.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const Draft = Codec.struct({
|
||||
type: Codec.literal("draft"),
|
||||
draftID: Codec.string,
|
||||
export const Draft = Persistence.struct({
|
||||
type: Schema.Literal("draft"),
|
||||
draftID: Schema.String,
|
||||
server: ServerKey,
|
||||
directory: Codec.string,
|
||||
worktree: Codec.lenientOptional(Codec.string),
|
||||
branch: Codec.lenientOptional(Codec.string),
|
||||
mcp: Codec.lenientOptional(Codec.struct({ target: Codec.string, states: Codec.lenientRecord(Codec.boolean) })),
|
||||
directory: Schema.String,
|
||||
worktree: Persistence.optional(Schema.String),
|
||||
branch: Persistence.optional(Schema.String),
|
||||
mcp: Persistence.optional(Persistence.struct({ target: Schema.String, states: Persistence.record(Schema.Boolean) })),
|
||||
})
|
||||
|
||||
// A stored route that only repeats the session id carries nothing; drop it and its parent.
|
||||
const SessionCodec = Codec.transform(Session, {
|
||||
decode: (tab) => ({
|
||||
type: tab.type,
|
||||
server: tab.server,
|
||||
sessionId: tab.sessionId,
|
||||
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
|
||||
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
|
||||
: {}),
|
||||
}),
|
||||
encode: (tab) => tab,
|
||||
})
|
||||
|
||||
export const Tab = Codec.union([Session, Draft])
|
||||
export const Tabs = Codec.lenientArray(Codec.union([SessionCodec, Draft]))
|
||||
export const Recent = Codec.struct({
|
||||
key: Codec.optional(Codec.string),
|
||||
})
|
||||
export const Info = Codec.struct({
|
||||
title: Codec.optional(Codec.string),
|
||||
directory: Codec.optional(Codec.string),
|
||||
})
|
||||
export const Infos = Codec.record(Info)
|
||||
export const Panes = Codec.record(
|
||||
Codec.struct({
|
||||
terminal: Codec.optional(Codec.boolean),
|
||||
review: Codec.optional(Codec.boolean),
|
||||
terminalHeight: Codec.optional(Codec.number),
|
||||
sessionWidth: Codec.optional(Codec.number),
|
||||
const SessionCodec = Session.pipe(
|
||||
Schema.decodeTo(Schema.toType(Session), {
|
||||
decode: SchemaGetter.transform((tab) => ({
|
||||
type: tab.type,
|
||||
server: tab.server,
|
||||
sessionId: tab.sessionId,
|
||||
...(tab.routeSessionId && tab.routeSessionId !== tab.sessionId
|
||||
? { routeSessionId: tab.routeSessionId, ...(tab.routeParentId ? { routeParentId: tab.routeParentId } : {}) }
|
||||
: {}),
|
||||
})),
|
||||
encode: SchemaGetter.transform((tab) => tab),
|
||||
}),
|
||||
)
|
||||
export const ClosedTab = Codec.struct({ tab: SessionCodec, index: Codec.nonNegativeInt })
|
||||
export const Closed = Codec.lenientArray(ClosedTab)
|
||||
|
||||
export const Tab = Schema.Union([Session, Draft])
|
||||
export const Tabs = Persistence.array(Schema.Union([SessionCodec, Draft]))
|
||||
export const Recent = Persistence.struct({
|
||||
key: Schema.optional(Schema.String),
|
||||
})
|
||||
export const Info = Persistence.struct({
|
||||
title: Schema.optional(Schema.String),
|
||||
directory: Schema.optional(Schema.String),
|
||||
})
|
||||
export const Infos = Schema.Record(Schema.String, Schema.mutableKey(Info))
|
||||
export const Panes = Schema.Record(
|
||||
Schema.String,
|
||||
Schema.mutableKey(
|
||||
Persistence.struct({
|
||||
terminal: Schema.optional(Schema.Boolean),
|
||||
review: Schema.optional(Schema.Boolean),
|
||||
terminalHeight: Schema.optional(Schema.Finite),
|
||||
sessionWidth: Schema.optional(Schema.Finite),
|
||||
}),
|
||||
),
|
||||
)
|
||||
export const ClosedTab = Schema.Struct({ tab: SessionCodec, index: Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)) })
|
||||
export const Closed = Persistence.array(ClosedTab)
|
||||
|
||||
@@ -3,12 +3,13 @@ import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed"
|
||||
import { findSessionTab, sessionIDHasOpenTab, tabHref, tabKey, type SessionTab, type Tab } from "./tabs"
|
||||
import { Schema } from "effect"
|
||||
import { TabStorage } from "./schema"
|
||||
import type { ServerConnection } from "@/runtime/server/registry"
|
||||
import { Codec } from "@/runtime/persistence/codec"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
const decodeTabs = ((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Tabs, []), input))
|
||||
const decodeTabs = Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Tabs, []))
|
||||
|
||||
function sessionTab(sessionId: string): SessionTab {
|
||||
return { type: "session", server, sessionId }
|
||||
@@ -25,7 +26,7 @@ describe("tab migration", () => {
|
||||
}
|
||||
const restored = decodeTabs([legacy, draft])
|
||||
expect(restored).toEqual([legacy, draft])
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(restored))).toEqual([legacy, draft])
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(restored))).toEqual([legacy, draft])
|
||||
})
|
||||
|
||||
test("drops null and malformed persisted tabs", () => {
|
||||
@@ -63,13 +64,13 @@ describe("tab migration", () => {
|
||||
draft,
|
||||
])
|
||||
expect(tabs).toEqual([sessionTab("root"), draft])
|
||||
expect(TabStorage.Tabs.encode(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(TabStorage.Tabs.encode(tabs))).toEqual(tabs)
|
||||
expect(Schema.encodeSync(TabStorage.Tabs)(tabs)).toEqual(tabs)
|
||||
expect(decodeTabs(Schema.encodeSync(TabStorage.Tabs)(tabs))).toEqual(tabs)
|
||||
})
|
||||
|
||||
test("salvages valid closed session tabs", () => {
|
||||
expect(
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Closed, []), input))([
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Closed, []))([
|
||||
{ tab: sessionTab("a"), index: 1 },
|
||||
{ tab: sessionTab("b"), index: -1 },
|
||||
{ tab: { type: "draft", server, draftID: "d", directory: "/project" }, index: 0 },
|
||||
@@ -80,16 +81,16 @@ describe("tab migration", () => {
|
||||
|
||||
test("validates auxiliary tab state", () => {
|
||||
expect(
|
||||
((input: unknown) => Codec.decodeOrThrow(Codec.withInitial(TabStorage.Recent, { key: undefined }), input))({ key: 1 }),
|
||||
Schema.decodeUnknownSync(Persistence.withInitial(TabStorage.Recent, { key: undefined }))({ key: 1 }),
|
||||
).toEqual({ key: undefined })
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Panes, {})).toEqual({})
|
||||
expect(Codec.decodeOrThrow(TabStorage.Infos, { tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Panes)({})).toEqual({})
|
||||
expect(Schema.decodeUnknownSync(TabStorage.Infos)({ tab: { title: "Title", directory: "/project" } })).toEqual({
|
||||
tab: { title: "Title", directory: "/project" },
|
||||
})
|
||||
const panes = Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(TabStorage.Panes.encode(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(() => Codec.decodeOrThrow(TabStorage.Panes, { tab: { terminal: "yes" } })).toThrow()
|
||||
const panes = Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(Schema.encodeSync(TabStorage.Panes)(panes)).toEqual({ tab: { terminal: true, terminalHeight: 300 } })
|
||||
expect(() => Schema.decodeUnknownSync(TabStorage.Panes)({ tab: { terminal: "yes" } })).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -208,5 +209,3 @@ describe("closed tab stack", () => {
|
||||
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
[data-titlebar-tab] {
|
||||
[data-titlebar-tab],
|
||||
[data-titlebar-tab-action] {
|
||||
--tab-base: var(--v2-background-bg-deep);
|
||||
--tab-overlay: transparent;
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
@@ -68,6 +69,14 @@
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:hover, :focus-visible) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-hover);
|
||||
}
|
||||
|
||||
[data-titlebar-tab-action]:is(:active, [data-state="pressed"]) {
|
||||
--tab-overlay: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] {
|
||||
background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base);
|
||||
}
|
||||
|
||||
@@ -351,9 +351,10 @@ export function Titlebar(props: {
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-home"
|
||||
data-state={layout.route().type === "home" ? "pressed" : undefined}
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base data-[state=pressed]:bg-v2-background-bg-layer-02 data-[state=pressed]:text-v2-text-text-base"
|
||||
class="group mb-1 flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base data-[state=pressed]:text-v2-text-text-base"
|
||||
onClick={toggleHome}
|
||||
aria-label={language.t("home.title")}
|
||||
aria-pressed={layout.route().type === "home"}
|
||||
@@ -650,8 +651,9 @@ export function Titlebar(props: {
|
||||
{homeButton(true)}
|
||||
<button
|
||||
type="button"
|
||||
data-titlebar-tab-action
|
||||
data-action="vertical-tabs-new-session"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:bg-v2-background-bg-layer-02 hover:text-v2-text-text-base"
|
||||
class="group flex h-7 w-full shrink-0 items-center gap-1.5 rounded-[6px] ps-1.5 pe-2 text-[13px] leading-4 text-v2-text-text-faint hover:text-v2-text-text-base"
|
||||
onClick={openNewTab}
|
||||
aria-label={language.t("command.session.new")}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
artifactKind,
|
||||
bytesToBase64,
|
||||
contentBytes,
|
||||
fileContentFromBytes,
|
||||
MAX_MEDIA_BYTES,
|
||||
parseDelimited,
|
||||
resolveArtifactPath,
|
||||
} from "./artifact"
|
||||
|
||||
describe("artifactKind", () => {
|
||||
test.each([
|
||||
["shot.PNG", "image"],
|
||||
["logo.svg", "svg"],
|
||||
["song.mp3", "audio"],
|
||||
["demo.mp4", "video"],
|
||||
["clip.webm", "video"],
|
||||
["paper.pdf", "pdf"],
|
||||
["out/index.html", "html"],
|
||||
["README.md", "markdown"],
|
||||
["flow.mmd", "mermaid"],
|
||||
["data.csv", "table"],
|
||||
["data.tsv", "table"],
|
||||
["Inter.woff2", "font"],
|
||||
["src/app.ts", "text"],
|
||||
["Makefile", "text"],
|
||||
[".env", "text"],
|
||||
["archive.tar.gz", "text"],
|
||||
] as const)("classifies %s as %s", (path, kind) => {
|
||||
expect(artifactKind(path)).toBe(kind)
|
||||
})
|
||||
})
|
||||
|
||||
describe("fileContentFromBytes", () => {
|
||||
test("keeps media as base64 with a mime type", () => {
|
||||
const content = fileContentFromBytes("a.png", new Uint8Array([137, 80, 78, 71]))
|
||||
expect(content).toEqual({ type: "binary", content: "iVBORw==", encoding: "base64", mimeType: "image/png" })
|
||||
})
|
||||
|
||||
test("decodes text and svg with a mime type", () => {
|
||||
expect(fileContentFromBytes("a.svg", new TextEncoder().encode("<svg/>"))).toEqual({
|
||||
type: "text",
|
||||
content: "<svg/>",
|
||||
mimeType: "image/svg+xml",
|
||||
})
|
||||
expect(fileContentFromBytes("a.ts", new TextEncoder().encode("const a = 1"))).toEqual({
|
||||
type: "text",
|
||||
content: "const a = 1",
|
||||
mimeType: undefined,
|
||||
})
|
||||
})
|
||||
|
||||
test("keeps only the size of media above the cap", () => {
|
||||
const content = fileContentFromBytes("big.mp4", new Uint8Array(MAX_MEDIA_BYTES + 1))
|
||||
expect(content).toEqual({ type: "binary", content: "", size: MAX_MEDIA_BYTES + 1 })
|
||||
})
|
||||
|
||||
test("marks unknown binaries without keeping bytes", () => {
|
||||
expect(fileContentFromBytes("a.bin", new Uint8Array([1, 0, 2]))).toEqual({ type: "binary", content: "", size: 3 })
|
||||
})
|
||||
|
||||
test("encodes large buffers in chunks", () => {
|
||||
const bytes = new Uint8Array(70_000).fill(65)
|
||||
expect(bytesToBase64(bytes)).toBe(Buffer.from(bytes).toString("base64"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("contentBytes", () => {
|
||||
test("recovers byte counts from base64 and text", () => {
|
||||
expect(contentBytes({ type: "binary", content: "iVBORw==", encoding: "base64" })).toBe(4)
|
||||
expect(contentBytes({ type: "binary", content: "iVBORwA=", encoding: "base64" })).toBe(5)
|
||||
expect(contentBytes({ type: "text", content: "héllo" })).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseDelimited", () => {
|
||||
test("handles quotes, embedded delimiters, newlines, and CRLF", () => {
|
||||
const parsed = parseDelimited('name,note\r\n"Smith, J","says ""hi""\nand more"\nplain,\n', ",")
|
||||
expect(parsed.rows).toEqual([
|
||||
["name", "note"],
|
||||
["Smith, J", 'says "hi"\nand more'],
|
||||
["plain", ""],
|
||||
])
|
||||
expect(parsed.total).toBe(3)
|
||||
expect(parsed.columns).toBe(2)
|
||||
})
|
||||
|
||||
test("counts rows past the limit without keeping them", () => {
|
||||
const parsed = parseDelimited("a\tb\n1\t2\n3\t4\n5\t6", "\t", 2)
|
||||
expect(parsed.rows).toHaveLength(2)
|
||||
expect(parsed.total).toBe(4)
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveArtifactPath", () => {
|
||||
test.each([
|
||||
["docs", "guide.md", "docs/guide.md"],
|
||||
["docs", "./img/a.png", "docs/img/a.png"],
|
||||
["docs/api", "../index.md", "docs/index.md"],
|
||||
["", "src/app.ts", "src/app.ts"],
|
||||
["docs", "sub\\win.md", "docs/sub/win.md"],
|
||||
["", "docs/guide.md", "docs/guide.md"],
|
||||
["/tmp/notes/", "../out/a.pdf", "/tmp/out/a.pdf"],
|
||||
["C:/tmp/notes/", "img.png", "C:/tmp/notes/img.png"],
|
||||
["/repo", "../shared/report.pdf", "/shared/report.pdf"],
|
||||
])("resolves %s + %s", (base, href, expected) => {
|
||||
expect(resolveArtifactPath(base, href)).toBe(expected)
|
||||
})
|
||||
|
||||
test.each([
|
||||
["docs", "../../etc/passwd"],
|
||||
["", "../x"],
|
||||
["docs", "/abs/path"],
|
||||
])("rejects %s + %s", (base, href) => {
|
||||
expect(resolveArtifactPath(base, href)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { FileContent } from "@/runtime/server/types"
|
||||
|
||||
export type ArtifactKind =
|
||||
| "image"
|
||||
| "svg"
|
||||
| "audio"
|
||||
| "video"
|
||||
| "pdf"
|
||||
| "html"
|
||||
| "markdown"
|
||||
| "mermaid"
|
||||
| "table"
|
||||
| "font"
|
||||
| "text"
|
||||
|
||||
const mimes = new Map([
|
||||
["png", "image/png"],
|
||||
["jpg", "image/jpeg"],
|
||||
["jpeg", "image/jpeg"],
|
||||
["gif", "image/gif"],
|
||||
["webp", "image/webp"],
|
||||
["avif", "image/avif"],
|
||||
["bmp", "image/bmp"],
|
||||
["ico", "image/x-icon"],
|
||||
["tif", "image/tiff"],
|
||||
["tiff", "image/tiff"],
|
||||
["heic", "image/heic"],
|
||||
["svg", "image/svg+xml"],
|
||||
["mp3", "audio/mpeg"],
|
||||
["wav", "audio/wav"],
|
||||
["ogg", "audio/ogg"],
|
||||
["oga", "audio/ogg"],
|
||||
["m4a", "audio/mp4"],
|
||||
["aac", "audio/aac"],
|
||||
["flac", "audio/flac"],
|
||||
["opus", "audio/ogg"],
|
||||
["weba", "audio/webm"],
|
||||
["mp4", "video/mp4"],
|
||||
["m4v", "video/mp4"],
|
||||
["webm", "video/webm"],
|
||||
["mov", "video/quicktime"],
|
||||
["ogv", "video/ogg"],
|
||||
["mkv", "video/x-matroska"],
|
||||
["pdf", "application/pdf"],
|
||||
["html", "text/html"],
|
||||
["htm", "text/html"],
|
||||
["md", "text/markdown"],
|
||||
["markdown", "text/markdown"],
|
||||
["mdx", "text/markdown"],
|
||||
["mmd", "text/vnd.mermaid"],
|
||||
["mermaid", "text/vnd.mermaid"],
|
||||
["csv", "text/csv"],
|
||||
["tsv", "text/tab-separated-values"],
|
||||
["ttf", "font/ttf"],
|
||||
["otf", "font/otf"],
|
||||
["woff", "font/woff"],
|
||||
["woff2", "font/woff2"],
|
||||
])
|
||||
|
||||
export function artifactExtension(path: string) {
|
||||
const name = path.split(/[\\/]/).pop() ?? ""
|
||||
const index = name.lastIndexOf(".")
|
||||
if (index <= 0) return ""
|
||||
return name.slice(index + 1).toLowerCase()
|
||||
}
|
||||
|
||||
export function artifactMime(path: string) {
|
||||
return mimes.get(artifactExtension(path))
|
||||
}
|
||||
|
||||
export function artifactKind(path: string): ArtifactKind {
|
||||
const mime = artifactMime(path)
|
||||
if (!mime) return "text"
|
||||
if (mime === "image/svg+xml") return "svg"
|
||||
if (mime === "application/pdf") return "pdf"
|
||||
if (mime === "text/html") return "html"
|
||||
if (mime === "text/markdown") return "markdown"
|
||||
if (mime === "text/vnd.mermaid") return "mermaid"
|
||||
if (mime === "text/csv" || mime === "text/tab-separated-values") return "table"
|
||||
if (mime.startsWith("image/")) return "image"
|
||||
if (mime.startsWith("audio/")) return "audio"
|
||||
if (mime.startsWith("font/")) return "font"
|
||||
return "video"
|
||||
}
|
||||
|
||||
/** Kinds whose bytes are kept as base64 so media elements can play them without a text round trip. */
|
||||
const binaryKinds = new Set<ArtifactKind>(["image", "audio", "video", "pdf", "font"])
|
||||
|
||||
/** Text files never contain NUL; a NUL in the first 8 KiB marks an unknown binary. */
|
||||
function isBinaryBytes(bytes: Uint8Array) {
|
||||
return bytes.subarray(0, 8192).includes(0)
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes: Uint8Array) {
|
||||
const parts: string[] = []
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
parts.push(String.fromCharCode(...bytes.subarray(index, index + 0x8000)))
|
||||
}
|
||||
return btoa(parts.join(""))
|
||||
}
|
||||
|
||||
/** Media above this stays a placeholder: base64 encoding on the main thread and the LRU budget both suffer. */
|
||||
export const MAX_MEDIA_BYTES = 25 * 1024 * 1024
|
||||
|
||||
export function fileContentFromBytes(path: string, bytes: Uint8Array): FileContent {
|
||||
const kind = artifactKind(path)
|
||||
const mimeType = artifactMime(path)
|
||||
if (binaryKinds.has(kind)) {
|
||||
if (bytes.length > MAX_MEDIA_BYTES) return { type: "binary", content: "", size: bytes.length }
|
||||
return { type: "binary", content: bytesToBase64(bytes), encoding: "base64", mimeType }
|
||||
}
|
||||
// Unknown binaries keep no bytes: the viewer only shows a placeholder for them.
|
||||
if (kind === "text" && isBinaryBytes(bytes)) return { type: "binary", content: "", size: bytes.length }
|
||||
return { type: "text", content: new TextDecoder().decode(bytes), mimeType }
|
||||
}
|
||||
|
||||
/** Approximate on-disk size of loaded content. */
|
||||
export function contentBytes(content: FileContent) {
|
||||
if (content.size !== undefined) return content.size
|
||||
if (content.encoding === "base64") {
|
||||
const padding = content.content.endsWith("==") ? 2 : content.content.endsWith("=") ? 1 : 0
|
||||
return Math.floor((content.content.length * 3) / 4) - padding
|
||||
}
|
||||
return new TextEncoder().encode(content.content).length
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse RFC 4180 style delimited text. Quoted fields may contain the delimiter, newlines, and
|
||||
* doubled quotes. Rows beyond `limit` are counted but not returned.
|
||||
*/
|
||||
export function parseDelimited(text: string, delimiter: string, limit = 1000) {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ""
|
||||
let quoted = false
|
||||
let total = 0
|
||||
const endRow = () => {
|
||||
row.push(field)
|
||||
field = ""
|
||||
const blank = row.length === 1 && row[0] === ""
|
||||
if (!blank) {
|
||||
total++
|
||||
if (rows.length < limit) rows.push(row)
|
||||
}
|
||||
row = []
|
||||
}
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const char = text[index]!
|
||||
if (quoted) {
|
||||
if (char !== '"') {
|
||||
field += char
|
||||
continue
|
||||
}
|
||||
if (text[index + 1] === '"') {
|
||||
field += '"'
|
||||
index++
|
||||
continue
|
||||
}
|
||||
quoted = false
|
||||
continue
|
||||
}
|
||||
if (char === '"' && field === "") {
|
||||
quoted = true
|
||||
continue
|
||||
}
|
||||
if (char === delimiter) {
|
||||
row.push(field)
|
||||
field = ""
|
||||
continue
|
||||
}
|
||||
if (char === "\r") continue
|
||||
if (char === "\n") {
|
||||
endRow()
|
||||
continue
|
||||
}
|
||||
field += char
|
||||
}
|
||||
if (field !== "" || row.length > 0) endRow()
|
||||
const columns = rows.reduce((max, current) => Math.max(max, current.length), 0)
|
||||
return { rows, total, columns }
|
||||
}
|
||||
|
||||
/** Build a blob URL from loaded content. Callers revoke it when the viewer unmounts. */
|
||||
export function blobUrlFromContent(content: FileContent) {
|
||||
const type = content.mimeType ?? "application/octet-stream"
|
||||
if (content.encoding !== "base64") return URL.createObjectURL(new Blob([content.content], { type }))
|
||||
const raw = atob(content.content)
|
||||
const bytes = Uint8Array.from(raw, (char) => char.charCodeAt(0))
|
||||
return URL.createObjectURL(new Blob([bytes], { type }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a relative link against a directory. A relative base yields a workspace-relative path and
|
||||
* an absolute base an absolute one; undefined when the link climbs past the base's root.
|
||||
*/
|
||||
export function resolveArtifactPath(base: string, href: string) {
|
||||
const target = href.replaceAll("\\", "/")
|
||||
if (target.startsWith("/")) return undefined
|
||||
const dir = base.replaceAll("\\", "/")
|
||||
const segments = [...dir.split("/").filter(Boolean)]
|
||||
for (const segment of target.split("/")) {
|
||||
if (!segment || segment === ".") continue
|
||||
if (segment !== "..") {
|
||||
segments.push(segment)
|
||||
continue
|
||||
}
|
||||
if (segments.length === 0) return undefined
|
||||
segments.pop()
|
||||
}
|
||||
return `${dir.startsWith("/") ? "/" : ""}${segments.join("/")}`
|
||||
}
|
||||
@@ -5,11 +5,12 @@ import { createSimpleContext } from "@opencode/ui/context"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode/util/encode"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { getDirectory, getFilename } from "@opencode/util/path"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useLayout } from "@/shell/state/layout"
|
||||
import { createPathHelpers } from "./path"
|
||||
import { fileContentFromBytes } from "./artifact"
|
||||
import {
|
||||
approxBytes,
|
||||
evictContentLru,
|
||||
@@ -185,14 +186,17 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
|
||||
setLoading(file)
|
||||
|
||||
// Files outside the workspace are read from their own directory, like markdown images.
|
||||
// The trailing separator from getDirectory keeps "/" and "C:/" valid, like readLocalImage.
|
||||
const request = path.absolute(file)
|
||||
? { path: getFilename(file), location: { directory: getDirectory(file) } }
|
||||
: { path: file, location: { directory } }
|
||||
const promise = serverSDK.api.file
|
||||
.read({ path: file, location: { directory } })
|
||||
.read(request)
|
||||
.then((data) => {
|
||||
if (scope() !== directory) return
|
||||
const content = { type: "text" as const, content: new TextDecoder().decode(data) }
|
||||
const content = fileContentFromBytes(file, data)
|
||||
setLoaded(file, content)
|
||||
|
||||
if (!content) return
|
||||
touchFileContent(file, approxBytes(content))
|
||||
evictContent(new Set([file]))
|
||||
})
|
||||
@@ -281,6 +285,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
return {
|
||||
ready: () => view().ready(),
|
||||
normalize: path.normalize,
|
||||
absolute: path.absolute,
|
||||
tab: path.tab,
|
||||
pathFromTab: path.pathFromTab,
|
||||
tree: {
|
||||
|
||||
@@ -21,6 +21,24 @@ describe("file path helpers", () => {
|
||||
expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts")
|
||||
})
|
||||
|
||||
test("keeps files outside the workspace absolute and round-trips them through tabs", () => {
|
||||
const posix = createPathHelpers(() => "/repo")
|
||||
expect(posix.normalize("/tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
|
||||
expect(posix.absolute("/tmp/out/report.pdf")).toBe(true)
|
||||
expect(posix.absolute("src/app.ts")).toBe(false)
|
||||
expect(posix.tab("/tmp/out/report.pdf")).toBe("file:///tmp/out/report.pdf")
|
||||
expect(posix.pathFromTab("file:///tmp/out/report.pdf")).toBe("/tmp/out/report.pdf")
|
||||
expect(posix.normalize("/repository/x.ts")).toBe("/repository/x.ts")
|
||||
|
||||
const windows = createPathHelpers(() => "C:\\repo")
|
||||
expect(windows.normalize("C:\\tmp\\font.ttf")).toBe("C:\\tmp\\font.ttf")
|
||||
expect(windows.normalize("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
|
||||
expect(windows.absolute("C:/tmp/font.ttf")).toBe(true)
|
||||
expect(windows.tab("C:/tmp/font.ttf")).toBe("file:///C:/tmp/font.ttf")
|
||||
expect(windows.pathFromTab("file:///C:/tmp/font.ttf")).toBe("C:/tmp/font.ttf")
|
||||
expect(windows.pathFromTab("file:///C:/repo/src/app.ts")).toBe("src/app.ts")
|
||||
})
|
||||
|
||||
test("normalizes Windows directory separators", () => {
|
||||
const path = createPathHelpers(() => "C:\\repo")
|
||||
expect(path.normalizeDir("frontend\\")).toBe("frontend")
|
||||
|
||||
@@ -105,7 +105,11 @@ export function createPathHelpers(scope: () => string) {
|
||||
const normalize = (input: string) => {
|
||||
const root = scope()
|
||||
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input))))
|
||||
// file:///C:/dir becomes /C:/dir once the protocol is gone; restore the drive form.
|
||||
let path = unquoteGitPath(decodeFilePath(stripQueryAndHash(stripFileProtocol(input)))).replace(
|
||||
/^[/\\]([A-Za-z]:)/,
|
||||
"$1",
|
||||
)
|
||||
|
||||
// Separator-agnostic prefix stripping for Cygwin/native Windows compatibility
|
||||
// Only case-insensitive on Windows (drive letter or UNC paths)
|
||||
@@ -116,20 +120,21 @@ export function createPathHelpers(scope: () => string) {
|
||||
canonPath.startsWith(canonRoot) &&
|
||||
(canonRoot.endsWith("/") || canonPath === canonRoot || canonPath[canonRoot.length] === "/")
|
||||
) {
|
||||
// Slice from original path to preserve native separators
|
||||
path = path.slice(root.length)
|
||||
// Slice from original path to preserve native separators, then drop the separator itself.
|
||||
path = path.slice(root.length).replace(/^[/\\]/, "")
|
||||
}
|
||||
|
||||
if (path.startsWith("./") || path.startsWith(".\\")) {
|
||||
path = path.slice(2)
|
||||
}
|
||||
|
||||
if (path.startsWith("/") || path.startsWith("\\")) {
|
||||
path = path.slice(1)
|
||||
}
|
||||
// An absolute path that is not under the root stays absolute; it is a file outside the workspace.
|
||||
return path
|
||||
}
|
||||
|
||||
/** Whether a normalized path points outside the workspace root. */
|
||||
const absolute = (path: string) => /^[A-Za-z]:[/\\]/.test(path) || path.startsWith("/") || path.startsWith("\\\\")
|
||||
|
||||
const tab = (input: string) => {
|
||||
const path = normalize(input)
|
||||
return `file://${encodeFilePath(path)}`
|
||||
@@ -149,6 +154,7 @@ export function createPathHelpers(scope: () => string) {
|
||||
|
||||
return {
|
||||
normalize,
|
||||
absolute,
|
||||
tab,
|
||||
pathFromTab,
|
||||
normalizeDir,
|
||||
|
||||
+21
-1
@@ -1,4 +1,5 @@
|
||||
import { readFileSync } from "node:fs"
|
||||
import { createRequire } from "node:module"
|
||||
import solidPlugin from "vite-plugin-solid"
|
||||
import tailwindcss from "@tailwindcss/vite"
|
||||
import { fileURLToPath } from "url"
|
||||
@@ -17,6 +18,24 @@ if (tailwindGenerate && typeof tailwindHotUpdate === "function") {
|
||||
}
|
||||
}
|
||||
|
||||
// The markdown worker imports these directly, so they are served unbundled to keep worker startup
|
||||
// stable. Vite applies `exclude` to every import inside a pre-bundle too, which would leave a bare
|
||||
// `import "marked"` in mermaid's chunk that the browser cannot resolve from this package.
|
||||
const workerDeps = ["@shikijs/stream", "marked", "marked-shiki", "remend"]
|
||||
|
||||
/** @type {import("rolldown").Plugin} */
|
||||
const bundleNestedWorkerDeps = {
|
||||
name: "opencode-desktop:bundle-nested-worker-deps",
|
||||
resolveId(id, importer) {
|
||||
if (!importer || !workerDeps.includes(id) || !importer.includes("node_modules")) return
|
||||
try {
|
||||
return createRequire(importer).resolve(id)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export const channel = (() => {
|
||||
const raw = process.env.OPENCODE_CHANNEL
|
||||
if (raw === "local" || raw === "dev" || raw === "beta" || raw === "prod") return raw
|
||||
@@ -44,8 +63,9 @@ export default [
|
||||
format: "es",
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
|
||||
exclude: workerDeps,
|
||||
include: ["@opencode/session-ui > mermaid", "@opencode/session-ui > mermaid > katex"],
|
||||
rolldownOptions: { plugins: [bundleNestedWorkerDeps] },
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -55,7 +55,7 @@ try {
|
||||
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
await waitForPlugin(info.url, headers, plugin)
|
||||
|
||||
const unauthorizedInfo = await fetch(new URL("/api/info", info.url), {
|
||||
signal: AbortSignal.timeout(5_000),
|
||||
@@ -139,7 +139,13 @@ async function waitForReady(url: string, headers: HeadersInit) {
|
||||
}
|
||||
|
||||
function exitsWithin(process: Bun.Subprocess, milliseconds: number) {
|
||||
return Promise.race([process.exited.then(() => true), Bun.sleep(milliseconds).then(() => false)])
|
||||
return new Promise<boolean>((resolve) => {
|
||||
const timeout = setTimeout(() => resolve(false), milliseconds)
|
||||
process.exited.then(() => {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function pluginSource() {
|
||||
@@ -159,11 +165,15 @@ async function pluginIDs(url: string, headers: HeadersInit) {
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForPlugin(url: string, headers: HeadersInit) {
|
||||
async function waitForPlugin(url: string, headers: HeadersInit, plugin: string) {
|
||||
const deadline = Date.now() + 10_000
|
||||
let attempt = 0
|
||||
while (Date.now() < deadline) {
|
||||
if ((await pluginIDs(url, headers)).includes("smoke")) return
|
||||
await Bun.sleep(25)
|
||||
// Native watchers may coalesce a single creation edge. Keep changing valid source so
|
||||
// the smoke proves that a later native event is delivered.
|
||||
if (++attempt % 10 === 0) await fs.writeFile(plugin, `${pluginSource()}// watcher retry ${attempt}\n`)
|
||||
}
|
||||
throw new Error("Compiled service did not discover the created plugin")
|
||||
}
|
||||
|
||||
@@ -5,34 +5,77 @@ import { Runtime } from "../../framework/runtime"
|
||||
import { Updater } from "../../services/updater"
|
||||
import { handlePromptErrors } from "../../ui/prompt"
|
||||
import { OPENCODE_VERSION } from "../../version"
|
||||
import { stripVTControlCharacters } from "node:util"
|
||||
|
||||
export default Runtime.handler(
|
||||
Commands.commands.upgrade,
|
||||
Effect.fn("cli.upgrade")(function* (input) {
|
||||
intro("Upgrade")
|
||||
const updater = yield* Updater.Service
|
||||
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
|
||||
if (!method)
|
||||
return yield* Effect.fail(
|
||||
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
|
||||
Effect.fn("cli.upgrade")(
|
||||
function* (input) {
|
||||
intro("Upgrade")
|
||||
const updater = yield* Updater.Service
|
||||
const method = Option.getOrUndefined(input.method) ?? (yield* updater.method())
|
||||
if (!method)
|
||||
return yield* Effect.fail(
|
||||
new Error("Could not detect the installation method. Pass --method to choose how to upgrade OpenCode."),
|
||||
)
|
||||
|
||||
log.info(`Using method: ${method}`)
|
||||
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
|
||||
const version = target.trim().replace(/^v/, "")
|
||||
if (version === OPENCODE_VERSION) {
|
||||
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
|
||||
outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`From ${OPENCODE_VERSION} → ${version}`)
|
||||
const progress = spinner()
|
||||
progress.start("Upgrading...")
|
||||
yield* updater.upgrade(method, target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
|
||||
)
|
||||
|
||||
log.info(`Using method: ${method}`)
|
||||
const target = Option.getOrUndefined(input.target) ?? (yield* updater.latest())
|
||||
const version = target.trim().replace(/^v/, "")
|
||||
if (version === OPENCODE_VERSION) {
|
||||
log.warn(`OpenCode upgrade skipped: ${version} is already installed`)
|
||||
outro("Done")
|
||||
return
|
||||
}
|
||||
|
||||
log.info(`From ${OPENCODE_VERSION} → ${version}`)
|
||||
const progress = spinner()
|
||||
progress.start("Upgrading...")
|
||||
yield* updater.upgrade(method, target).pipe(
|
||||
Effect.tap(() => Effect.sync(() => progress.stop("Upgrade complete"))),
|
||||
Effect.tapCause(() => Effect.sync(() => progress.stop("Upgrade failed", 1))),
|
||||
)
|
||||
outro("Done")
|
||||
}, handlePromptErrors),
|
||||
},
|
||||
(effect) =>
|
||||
handlePromptErrors(
|
||||
effect.pipe(
|
||||
Effect.mapError((error) =>
|
||||
error instanceof Updater.UpgradeError ? new Error(formatUpgradeError(error), { cause: error }) : error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
function formatUpgradeError(error: Updater.UpgradeError) {
|
||||
const clean = (value: string) => stripVTControlCharacters(value).replaceAll("\r", "").trim()
|
||||
const line = (value: string) => clean(value).replace(/\s+/g, " ")
|
||||
const detail = wrap(clean(error.detail), Math.max(24, (process.stdout.columns ?? 80) - 7)).join("\n")
|
||||
return [
|
||||
line(error.title),
|
||||
"",
|
||||
detail,
|
||||
...(error.command ? ["", field("Command", line(error.command))] : []),
|
||||
field("Retry", line(error.retry)),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
function field(label: string, value: string) {
|
||||
const prefix = label.padEnd(9)
|
||||
const width = Math.max(24, Math.min(61, (process.stdout.columns ?? 80) - prefix.length - 10))
|
||||
const lines = wrap(value, width)
|
||||
return lines.map((line, index) => `${index === 0 ? prefix : " ".repeat(prefix.length)}${line}`).join("\n")
|
||||
}
|
||||
|
||||
function wrap(value: string, width: number) {
|
||||
return value.split("\n").flatMap((source) => {
|
||||
if (source.length <= width) return [source]
|
||||
return source.split(/\s+/).reduce<string[]>((result, word) => {
|
||||
const index = result.length - 1
|
||||
if (index < 0 || result[index].length + word.length + 1 > width) result.push(word)
|
||||
else result[index] += ` ${word}`
|
||||
return result
|
||||
}, [])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { produce, type Draft } from "immer"
|
||||
import { applyEdits, modify, parse, type ParseError } from "jsonc-parser"
|
||||
import path from "path"
|
||||
import { ConfigMigration } from "./migrate"
|
||||
import { Info, SchemaURL } from "./schema"
|
||||
import { Info, normalizeLegacyTabs, SchemaURL } from "./schema"
|
||||
|
||||
export * from "./schema"
|
||||
|
||||
@@ -119,7 +119,7 @@ function merge(...values: readonly (Info | undefined)[]) {
|
||||
return Option.getOrElse(
|
||||
decode(
|
||||
values.reduce<Record<string, unknown>>(
|
||||
(result, value) => mergeRecords(result, value ?? {}),
|
||||
(result, value) => mergeRecords(result, normalizeLegacyTabs(value) ?? {}),
|
||||
{},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,3 +8,11 @@ export const Info = Schema.Struct({
|
||||
...Config.Info.fields,
|
||||
})
|
||||
export type Info = Schema.Schema.Type<typeof Info>
|
||||
|
||||
export function normalizeLegacyTabs(info: Info | undefined) {
|
||||
if (info?.tabs?.enabled === undefined) return info
|
||||
const tabs = { ...info.tabs }
|
||||
tabs.mode ??= tabs.enabled ? "on" : "off"
|
||||
delete tabs.enabled
|
||||
return { ...info, tabs }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
import { NodeRuntime, NodeServices } from "@effect/platform-node"
|
||||
import { Effect } from "effect"
|
||||
import { Cause, Effect } from "effect"
|
||||
import { getErrorReported } from "effect/Runtime"
|
||||
import { Commands } from "./commands/commands"
|
||||
import { Runtime } from "./framework/runtime"
|
||||
import { Observability } from "@opencode/util/observability"
|
||||
@@ -130,5 +131,15 @@ Effect.gen(function* () {
|
||||
Effect.provide(NodeServices.layer),
|
||||
Effect.scoped,
|
||||
Effect.tap(() => Effect.sync(() => process.exit(process.exitCode ?? 0))),
|
||||
NodeRuntime.runMain,
|
||||
// runMain's default reporter logs the fatal cause to stdout. Write it to stderr instead: the
|
||||
// desktop and `Service.ensure` only capture stderr from `serve --service`, so this is the only
|
||||
// channel through which a startup failure's reason reaches the user.
|
||||
Effect.tapCause((cause) =>
|
||||
Effect.sync(() => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return
|
||||
if (!getErrorReported(Cause.squash(cause))) return
|
||||
process.stderr.write(Cause.pretty(cause) + "\n")
|
||||
}),
|
||||
),
|
||||
NodeRuntime.runMain({ disableErrorReporting: true }),
|
||||
)
|
||||
|
||||
@@ -5,7 +5,9 @@ import { Context, Duration, Effect, FileSystem, Layer, Option, Ref, Schema } fro
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { parse, type ParseError } from "jsonc-parser"
|
||||
import path from "node:path"
|
||||
import { stripVTControlCharacters } from "node:util"
|
||||
import { action, parseReleaseVersion, type Policy } from "./updater-action"
|
||||
import { errorMessage } from "../util/error"
|
||||
|
||||
export const methods = ["curl", "npm", "pnpm", "bun", "yarn", "vp", "brew"] as const
|
||||
|
||||
@@ -13,10 +15,82 @@ export type Method = (typeof methods)[number]
|
||||
export type RunResult = { readonly type: "available" | "installed"; readonly version: string }
|
||||
export type CheckResult = RunResult | { readonly type: "unavailable"; readonly message: string }
|
||||
|
||||
export class UpgradeError extends Error {
|
||||
readonly title: string
|
||||
readonly detail: string
|
||||
readonly command?: string
|
||||
readonly retry: string
|
||||
|
||||
constructor(
|
||||
input: {
|
||||
readonly title: string
|
||||
readonly detail: string
|
||||
readonly command?: string
|
||||
readonly retry: string
|
||||
},
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(input.detail, options)
|
||||
this.name = "UpgradeError"
|
||||
this.title = input.title
|
||||
this.detail = input.detail
|
||||
this.command = input.command
|
||||
this.retry = input.retry
|
||||
}
|
||||
}
|
||||
|
||||
const decodeVpPackages = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(Schema.Array(Schema.Struct({ name: Schema.String }))),
|
||||
)
|
||||
|
||||
const installNames: Record<Method, string> = {
|
||||
curl: "The OpenCode installer",
|
||||
npm: "npm",
|
||||
pnpm: "pnpm",
|
||||
bun: "Bun",
|
||||
yarn: "Yarn",
|
||||
vp: "Vite+",
|
||||
brew: "Homebrew",
|
||||
}
|
||||
|
||||
function conciseDetail(input: string) {
|
||||
const lines = stripVTControlCharacters(input)
|
||||
.trim()
|
||||
.replaceAll("\r", "\n")
|
||||
.split("\n")
|
||||
.map((line) => line.trimEnd())
|
||||
.filter((line) => line.trim().length > 0)
|
||||
const tail = lines.slice(-12).join("\n")
|
||||
const clipped = tail.length > 2_000
|
||||
const detail = clipped ? `…${tail.slice(-1_999)}` : tail
|
||||
if (!detail) return
|
||||
if (lines.length <= 12 && !clipped) return detail
|
||||
return `${detail}\n\nOutput shortened to the last 12 lines.`
|
||||
}
|
||||
|
||||
function errorDetail(cause: unknown): string {
|
||||
if (cause instanceof AppProcess.AppProcessError) {
|
||||
const stderr = conciseDetail(cause.stderr ?? "")
|
||||
if (stderr) return stderr
|
||||
if (cause.cause !== undefined) return errorDetail(cause.cause)
|
||||
return cause.message
|
||||
}
|
||||
if (cause instanceof Error) {
|
||||
const detail = cause.cause === undefined ? undefined : errorDetail(cause.cause)
|
||||
if (!detail || detail === cause.message) return cause.message
|
||||
return `${cause.message}: ${detail}`
|
||||
}
|
||||
return errorMessage(cause)
|
||||
}
|
||||
|
||||
function resultDetail(result: { code: number; stdout: string; stderr: string }) {
|
||||
return (
|
||||
conciseDetail(result.stderr) ??
|
||||
conciseDetail(result.stdout) ??
|
||||
`The command exited with code ${result.code} without any error output.`
|
||||
)
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly run: (onInstall?: (version: string) => void) => Effect.Effect<RunResult | undefined>
|
||||
readonly check: () => Effect.Effect<CheckResult | undefined, Error>
|
||||
@@ -24,9 +98,9 @@ export interface Interface {
|
||||
readonly method: () => Effect.Effect<Method | undefined>
|
||||
readonly latest: () => Effect.Effect<string, Error>
|
||||
readonly upgrade: (method: Method, version: string) => Effect.Effect<void, Error>
|
||||
readonly removal: (method: Method) =>
|
||||
| { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> }
|
||||
| undefined
|
||||
readonly removal: (
|
||||
method: Method,
|
||||
) => { readonly command: ReadonlyArray<string>; readonly run: Effect.Effect<void, Error> } | undefined
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/cli/Updater") {}
|
||||
@@ -89,7 +163,6 @@ const make = Effect.gen(function* () {
|
||||
stdout: result.stdout.toString("utf8"),
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
})),
|
||||
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -119,7 +192,11 @@ const make = Effect.gen(function* () {
|
||||
]
|
||||
const results = yield* Effect.forEach(
|
||||
checks,
|
||||
(check) => exec(check.command).pipe(Effect.map((result) => ({ check, result }))),
|
||||
(check) =>
|
||||
exec(check.command).pipe(
|
||||
Effect.orElseSucceed(() => ({ code: 1, stdout: "", stderr: "" })),
|
||||
Effect.map((result) => ({ check, result })),
|
||||
),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return results.find((result) => {
|
||||
@@ -144,11 +221,7 @@ const make = Effect.gen(function* () {
|
||||
return {
|
||||
command,
|
||||
run: exec(command, "5 minutes").pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result.code === 0
|
||||
? Effect.void
|
||||
: Effect.fail(new Error(result.stderr.trim() || `Failed to uninstall with ${method}`)),
|
||||
),
|
||||
Effect.flatMap((result) => (result.code === 0 ? Effect.void : Effect.fail(new Error(resultDetail(result))))),
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -163,14 +236,44 @@ const make = Effect.gen(function* () {
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
},
|
||||
),
|
||||
catch: (cause) => new Error("Failed to check for updates", { cause }),
|
||||
catch: (cause) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: "Could not check for OpenCode updates",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Check your network, then run opencode upgrade again.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
})
|
||||
if (!response.ok) return yield* Effect.fail(new Error(`Update check failed with status ${response.status}`))
|
||||
if (!response.ok)
|
||||
return yield* Effect.fail(
|
||||
new UpgradeError({
|
||||
title: "Could not check for OpenCode updates",
|
||||
detail: `The update service returned HTTP ${response.status}.`,
|
||||
retry: "Try again in a few minutes.",
|
||||
}),
|
||||
)
|
||||
const data: { version: string; metadata?: { package?: string } } = yield* Effect.tryPromise({
|
||||
try: () => response.json(),
|
||||
catch: (cause) => new Error("Failed to read update information", { cause }),
|
||||
catch: (cause) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: "Could not read the OpenCode update information",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Try again in a few minutes.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
})
|
||||
if (!data.metadata?.package) return yield* Effect.fail(new Error("Update information did not include a package"))
|
||||
if (!data.metadata?.package)
|
||||
return yield* Effect.fail(
|
||||
new UpgradeError({
|
||||
title: "Could not read the OpenCode update information",
|
||||
detail: "The update service returned incomplete release information.",
|
||||
retry: "Try again in a few minutes.",
|
||||
}),
|
||||
)
|
||||
return { package: data.metadata.package, version: data.version }
|
||||
})
|
||||
|
||||
@@ -185,6 +288,40 @@ const make = Effect.gen(function* () {
|
||||
fs.remove(directory, { recursive: true, force: true }).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const runUpgrade = (input: {
|
||||
readonly method: Method
|
||||
readonly command: string[]
|
||||
readonly displayCommand?: string[]
|
||||
readonly title?: string
|
||||
readonly retry?: string
|
||||
}) => {
|
||||
const failure = (detail: string, cause?: unknown) =>
|
||||
new UpgradeError(
|
||||
{
|
||||
title: input.title ?? `${installNames[input.method]} could not install OpenCode`,
|
||||
detail,
|
||||
command: (input.displayCommand ?? input.command).join(" "),
|
||||
retry: input.retry ?? "Fix the issue above, then run opencode upgrade again.",
|
||||
},
|
||||
cause === undefined ? undefined : { cause },
|
||||
)
|
||||
return exec(input.command, "5 minutes").pipe(
|
||||
Effect.flatMap((result) =>
|
||||
result.code === 0 ? Effect.succeed(result) : Effect.fail(failure(resultDetail(result))),
|
||||
),
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof UpgradeError
|
||||
? cause
|
||||
: failure(
|
||||
cause instanceof AppProcess.AppProcessError && cause.stderr === undefined && cause.cause === undefined
|
||||
? `Failed to update with ${input.method}`
|
||||
: errorDetail(cause),
|
||||
cause,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const upgrade = Effect.fnUntraced(function* (method: Method, input: string) {
|
||||
if (!parseReleaseVersion(input)) return yield* Effect.fail(new Error(`Invalid version: ${input}`))
|
||||
const version = input.trim().replace(/^v/, "")
|
||||
@@ -212,31 +349,54 @@ const make = Effect.gen(function* () {
|
||||
? ["vp", "install", "-g", "--force", target]
|
||||
: ["vp", "update", "-g", target],
|
||||
}
|
||||
const result = yield* Effect.scoped(
|
||||
yield* Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
if (method === "bun") {
|
||||
// Bun does not prune old versions from its shared package cache.
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const cache = yield* temporaryDirectory("update-")
|
||||
return yield* exec(["bun", "install", "--global", "--trust", "--cache-dir", cache, target], "5 minutes")
|
||||
return yield* runUpgrade({
|
||||
method,
|
||||
command: ["bun", "install", "--global", "--trust", "--cache-dir", cache, target],
|
||||
displayCommand: ["bun", "install", "--global", "--trust", target],
|
||||
})
|
||||
}
|
||||
if (method === "curl") {
|
||||
yield* fs.makeDirectory(global.cache, { recursive: true })
|
||||
const directory = yield* temporaryDirectory("update-")
|
||||
const installer = path.join(directory, "install")
|
||||
const download = yield* exec(
|
||||
["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
"5 minutes",
|
||||
)
|
||||
if (download.code !== 0) return download
|
||||
return yield* exec(["bash", installer, "--version", version, "--no-modify-path"], "5 minutes")
|
||||
yield* runUpgrade({
|
||||
method,
|
||||
command: ["curl", "-fsSL", "-o", installer, "https://opencode.ai/v2/install"],
|
||||
displayCommand: ["curl", "-fsSL", "https://opencode.ai/v2/install"],
|
||||
title: "Could not download the OpenCode installer",
|
||||
retry: "Check your network, then run opencode upgrade again.",
|
||||
})
|
||||
return yield* runUpgrade({
|
||||
method,
|
||||
command: ["bash", installer, "--version", version, "--no-modify-path"],
|
||||
displayCommand: ["opencode", "upgrade", version, "--method", "curl"],
|
||||
title: "The OpenCode installer failed",
|
||||
})
|
||||
}
|
||||
if (method === "brew") return yield* exec(["brew", "upgrade", packageName], "5 minutes")
|
||||
return yield* exec(commands[method], "5 minutes")
|
||||
if (method === "brew") return yield* runUpgrade({ method, command: ["brew", "upgrade", packageName] })
|
||||
return yield* runUpgrade({ method, command: commands[method] })
|
||||
}),
|
||||
).pipe(Effect.mapError((cause) => new Error(`Failed to update with ${method}`, { cause })))
|
||||
if (result.code === 0) return
|
||||
return yield* Effect.fail(new Error(result.stderr.trim() || `Failed to update with ${method}`))
|
||||
).pipe(
|
||||
Effect.mapError((cause) =>
|
||||
cause instanceof UpgradeError
|
||||
? cause
|
||||
: new UpgradeError(
|
||||
{
|
||||
title: "Could not prepare the OpenCode upgrade",
|
||||
detail: errorDetail(cause),
|
||||
retry: "Fix the issue above, then run opencode upgrade again.",
|
||||
},
|
||||
{ cause },
|
||||
),
|
||||
),
|
||||
Effect.asVoid,
|
||||
)
|
||||
})
|
||||
|
||||
const inspect = Effect.fnUntraced(function* () {
|
||||
|
||||
@@ -78,7 +78,7 @@ test("merges inline CLI config content over the global config", async () => {
|
||||
await Bun.write(
|
||||
file,
|
||||
JSON.stringify({
|
||||
tabs: { enabled: true, scope: "global" },
|
||||
tabs: { mode: "on", scope: "global" },
|
||||
keybinds: { "app.exit": "ctrl+q" },
|
||||
plugins: ["global"],
|
||||
animations: true,
|
||||
@@ -105,7 +105,7 @@ test("merges inline CLI config content over the global config", async () => {
|
||||
}),
|
||||
)
|
||||
|
||||
expect(result.loaded.tabs).toEqual({ enabled: false, scope: "global" })
|
||||
expect(result.loaded.tabs).toEqual({ mode: "off", scope: "global" })
|
||||
expect(result.loaded.keybinds).toEqual({ "app.exit": "ctrl+q", "help.show": false })
|
||||
expect(result.loaded.plugins).toEqual(["inline"])
|
||||
expect(result.updated).toMatchObject({ animations: false, mouse: false })
|
||||
@@ -116,6 +116,26 @@ test("merges inline CLI config content over the global config", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("reads the legacy tabs toggle without rewriting it", async () => {
|
||||
await using directory = await tmpdir()
|
||||
const file = path.join(directory.path, "cli.json")
|
||||
await Bun.write(file, JSON.stringify({ tabs: { enabled: false } }))
|
||||
|
||||
const config = await run(
|
||||
directory.path,
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Config.Service
|
||||
expect((yield* service.get()).tabs).toEqual({ mode: "off" })
|
||||
return yield* service.update((draft) => {
|
||||
draft.animations = false
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
expect(config.tabs).toEqual({ mode: "off" })
|
||||
expect(await Bun.file(file).json()).toEqual({ tabs: { enabled: false }, animations: false })
|
||||
})
|
||||
|
||||
test("migrates tui and kv config into cli.json", async () => {
|
||||
await using directory = await tmpdir()
|
||||
await Bun.write(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||
- State model-visible diagnostics, logs, tool descriptions, and instructions directly. The execution context is already clear; do not repeat `Code Mode` or `CodeMode` unless the distinction is necessary.
|
||||
- When interpreter behavior or support changes, update `interpreter-support.md` and direct tests in the same PR.
|
||||
- Program values are `Value` (`src/interpreter/objects.ts`); host values are `unknown` and are copied in at the boundaries (`fromHost`, `fromJson`). Do not widen program-facing signatures back to `unknown`.
|
||||
- A built-in kind of object is one `Obj` subclass (`Wrapper` for host-backed data such as Date or Map, `Opaque` for machinery such as functions and promises) that overrides `tag`, `toString`, `toPrimitive`, `inspect`, `toHost`, and `iterator` as needed. Do not add `instanceof` ladders over the built-in classes elsewhere; ask the object.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
|
||||
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
|
||||
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
|
||||
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
|
||||
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own enumerable }`, a
|
||||
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
|
||||
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
|
||||
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
|
||||
@@ -38,8 +38,11 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
10,000,000 elements (`Array(n)`, `length =`, `Array.from`, `split`, `matchAll`, `concat`, `flat`; below the JS
|
||||
maximum of 2^32 - 1), and 10,000 pending promises at once. Exceeding one throws a `RangeError`. A single regular
|
||||
expression match can still run long on a pathological pattern; the host regex engine has no interrupt hook.
|
||||
- [ ] Strict-mode early errors: duplicate parameter names, `yield` as an identifier, and a trailing comma after a
|
||||
rest parameter are accepted unless the program itself begins with `"use strict"`.
|
||||
- [x] A trailing comma after a rest parameter is a syntax error, with or without `"use strict"`.
|
||||
- [x] A program that begins with `"use strict"` rejects `yield` as an identifier and duplicate parameter names at
|
||||
parse time. Without it, `yield` is an ordinary binding.
|
||||
- [ ] Duplicate parameter names in non-strict code throw when the function is called, instead of binding the last
|
||||
parameter as JavaScript does.
|
||||
|
||||
## Values and literals
|
||||
|
||||
@@ -84,7 +87,6 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
|
||||
`const { slice } = values` find the inherited built-in.
|
||||
- [ ] Member expressions as `for...in` targets (`for (x.y in obj)`).
|
||||
- [ ] `IteratorClose` during destructuring should throw a `TypeError` when `return()` yields a non-object.
|
||||
|
||||
## Statements and control flow
|
||||
|
||||
@@ -94,7 +96,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `for`, `while`, and `do...while`.
|
||||
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, Uint8Arrays, built-in iterators, custom
|
||||
synchronous iterators, and confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
|
||||
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references. `null`, `undefined`, and other
|
||||
non-objects iterate nothing. An un-awaited promise throws rather than iterating.
|
||||
- [x] Unlabeled `break` and `continue`.
|
||||
- [x] `try`, `catch`, optional catch bindings, and `finally`.
|
||||
- [x] `throw` with arbitrary values.
|
||||
@@ -108,7 +111,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
## Functions and callbacks
|
||||
|
||||
- [x] Function declarations, function expressions, and arrow functions.
|
||||
- [x] Synchronous and `async` functions.
|
||||
- [x] Synchronous and `async` functions. A line break between `function` and the name is allowed, as in JavaScript;
|
||||
a line break between `async` and `function` is not an async function.
|
||||
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
|
||||
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
|
||||
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
|
||||
@@ -120,8 +124,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `Boolean`, `Number`, `String`, `parseInt`, `parseFloat`, `isFinite`, `isNaN`, and URI helpers as callbacks.
|
||||
- [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; consumed arguments stay strictly validated (`Math.floor` still rejects a
|
||||
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
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`
|
||||
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`,
|
||||
@@ -141,11 +145,13 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
assignments, object literal keys, and destructuring or parameter defaults.
|
||||
- [x] Built-in functions are objects too, with `name` and `length` (`Math.max.length === 2`,
|
||||
`Array.prototype.push.name === "push"`).
|
||||
- [ ] A named function expression's name is not bound inside its own body.
|
||||
- [ ] Redeclaring a function in the same scope is rejected; in JavaScript the last declaration wins.
|
||||
- [ ] A line terminator between `async function` and the function name.
|
||||
- [ ] Generator and async generator functions evaluate parameter defaults and destructuring at the first `next()`
|
||||
rather than at the call, so their errors are not thrown synchronously.
|
||||
- [x] A named function expression's name is bound read-only inside its own body; assigning to it throws a
|
||||
`TypeError`, as in strict mode.
|
||||
- [x] Redeclaring a function in the same scope, or alongside a `var`, is allowed: the last declaration wins.
|
||||
- [x] Generator functions have their own `prototype` (inheriting the shared generator prototype), so
|
||||
`g() instanceof g` holds. Plain functions have none, since they cannot construct.
|
||||
- [x] Generator and async generator functions bind parameters (defaults, destructuring) at the call and defer only the
|
||||
body to the first `next()`, so a bad argument throws synchronously from the call site, as in JS.
|
||||
- [x] Synchronous and async generator declarations/expressions, `yield`, and `yield*`, including lazy bodies,
|
||||
`next(value)`, `return(value)`, `throw(value)`, exhaustion, promise adoption, async request ordering,
|
||||
`try`/`catch`/`finally`, and sync/async iterator symbols. Async `yield*` awaits values while adapting a sync
|
||||
@@ -158,7 +164,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
rejected by every synchronous consumer.
|
||||
- [x] Synchronous iterator acquisition and result validation follow `IteratorClose` boundaries: consumer errors and
|
||||
intentional early stops invoke `return()`, acquisition/`next()` failures do not, and an original consumer error
|
||||
wins over a cleanup failure. Async iterator consumption remains limited to `for await...of` and async `yield*`.
|
||||
wins over a cleanup failure. A generator's `return()` is an intentional stop, so a `return()` that throws or
|
||||
yields a non-object surfaces from it as a `TypeError`. Async iterator consumption remains limited to `for await...of` and async `yield*`.
|
||||
- [x] Portable generator protocol coverage is adapted from pinned Test262 cases for suspended-start, suspended-yield,
|
||||
and completed states; sync and async `next`/`return`/`throw`; finally yields and completion overrides; rejected
|
||||
yielded promises; mixed async request queues; sync and async `yield*` forwarding; malformed methods/results;
|
||||
@@ -180,7 +187,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
|
||||
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
|
||||
and non-callable values are not constructors. Error constructors take the ES2022 options object, so
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present.
|
||||
`new Error(message, { cause })` installs a non-enumerable `cause` when the option is present. `Error.isError`
|
||||
is true for every Error value.
|
||||
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
|
||||
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
|
||||
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
|
||||
@@ -191,13 +199,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
|
||||
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
|
||||
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
|
||||
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
|
||||
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode. `delete` of a
|
||||
non-reference (`delete 0`, `delete f()`) evaluates the operand and is `true`; `delete x` on a variable throws.
|
||||
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
|
||||
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
|
||||
- [ ] ToPrimitive on object operands: operators, `Error(message)`, `Date` arguments, and `parseInt` radix should call
|
||||
`valueOf`/`toString` in spec order and surface their throws.
|
||||
- [ ] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via `toString`) become string keys; only
|
||||
strings and numbers are accepted.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
|
||||
string keys.
|
||||
|
||||
## Promises and tools
|
||||
|
||||
@@ -233,6 +242,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are
|
||||
accepted, including `.then`/`.catch` handlers and collection callbacks, and vanish at the data boundary like
|
||||
any function.
|
||||
- [x] `Promise.withResolvers()`: the same promise and resolver callables as the constructor, as a `{ promise, resolve,
|
||||
reject }` object.
|
||||
- [x] `Promise.try(fn, ...args)`: calls `fn` synchronously; a throw rejects, a return fulfils, and a returned promise or
|
||||
thenable is adopted.
|
||||
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
|
||||
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
|
||||
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
|
||||
@@ -253,7 +266,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
not construct.
|
||||
- [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.
|
||||
- [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.
|
||||
- [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
|
||||
@@ -270,8 +285,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `Object.is` for supported data values.
|
||||
- [x] `Object.groupBy` over finite collections and custom synchronous iterators/generators, with string-key coercion
|
||||
and plain-object results.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`), `toLocaleString`, `valueOf`,
|
||||
`hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
- [x] `Object.prototype` methods on values: `toString` (`"[object Array]"`, `"[object Map]"`, `"[object Promise]"`, and so
|
||||
on for every built-in kind, as JS reports through `Symbol.toStringTag`), `toLocaleString` (calls the value's
|
||||
`toString`, as in JS), `valueOf`, `hasOwnProperty`, `isPrototypeOf`, and `propertyIsEnumerable`.
|
||||
|
||||
## Arrays
|
||||
|
||||
@@ -285,12 +301,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`lastIndexOf`.
|
||||
- [x] Aggregation: `reduce` and `reduceRight`.
|
||||
- [x] Ordering: `sort`, `toSorted`, `reverse`, and `toReversed`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, and `join`.
|
||||
- [x] Access/copying: `at`, `slice`, `concat`, `flat`, `with`, `join`, and `toLocaleString` (each element's
|
||||
`toLocaleString`, holes and nullish elements as empty strings).
|
||||
- [x] Mutation: `push`, `pop`, `shift`, `unshift`, `splice`, `fill`, and `copyWithin`.
|
||||
- [x] `keys`, `values`, `entries`, and `[Symbol.iterator]` (the same function as `values`) return live iterator objects
|
||||
with `next()` and `[Symbol.iterator]`, as in JS. Iterator objects are opaque references: they print as
|
||||
`[opaque reference]`, serialize to `{}`, and cannot be passed to extensions. Every built-in collection iterator
|
||||
shares one prototype, which is only observable through `getPrototypeOf`.
|
||||
shares one prototype. JavaScript gives each collection its own; the difference is not observable here because
|
||||
`Object.getPrototypeOf` is not exposed.
|
||||
- [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`.
|
||||
@@ -301,14 +319,17 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Assigning `length` to truncate or extend an array; invalid lengths throw `RangeError`.
|
||||
- [x] Non-index own properties on arrays (`arr.foo = 1`, `arr.constructor = null`). They are excluded from the JSON
|
||||
form, like `JSON.stringify`.
|
||||
- [ ] Argument coercion for `indexOf`, `lastIndexOf`, `includes`, `fill`, `flat`, `copyWithin`, and the `join`
|
||||
separator: JavaScript applies ToIntegerOrInfinity/ToString (including `valueOf`, strings, and `undefined`), the
|
||||
interpreter requires numbers and strings; `includes()`/`indexOf()` with no argument should search for
|
||||
`undefined`.
|
||||
- [x] Numeric arguments coerce as in JS (ToIntegerOrInfinity): `indexOf(x, "1")`, `slice("1", "3")`, `at(null)`,
|
||||
`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).
|
||||
|
||||
## Strings
|
||||
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`.
|
||||
- [x] Case/normalization: `toLowerCase`, `toUpperCase`, `normalize`, and the `toLocaleLowerCase`/`toLocaleUpperCase`
|
||||
aliases, which ignore their locale argument and apply the default Unicode casing.
|
||||
- [x] Trimming: `trim`, `trimStart`, and `trimEnd`, plus the Annex B `trimLeft` and `trimRight` aliases.
|
||||
- [x] Searching/tests: `includes`, `startsWith`, `endsWith`, `indexOf`, `lastIndexOf`, and `search`.
|
||||
- [x] Slicing/access: `slice`, `substring`, Annex B `substr`, `at`, `charAt`, `charCodeAt`, and `codePointAt`.
|
||||
@@ -332,7 +353,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Coercion functions: `Number`, `parseInt`, and `parseFloat`.
|
||||
- [x] Number predicates/parsers: `Number.isInteger`, `Number.isFinite`, `Number.isNaN`, `Number.isSafeInteger`,
|
||||
`Number.parseInt`, and `Number.parseFloat`.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, and `valueOf`.
|
||||
- [x] Number formatting: `toFixed`, `toPrecision`, `toExponential`, `toString`, `valueOf`, and `toLocaleString`, which
|
||||
always formats as `en-US` (`"1,234.5"`) so output does not depend on the host.
|
||||
- [x] Number constants: `MAX_SAFE_INTEGER`, `MIN_SAFE_INTEGER`, `MAX_VALUE`, `MIN_VALUE`, `EPSILON`, `NaN`,
|
||||
`POSITIVE_INFINITY`, and `NEGATIVE_INFINITY`.
|
||||
- [x] Math constants: `PI`, `E`, `LN2`, `LN10`, `LOG2E`, `LOG10E`, `SQRT2`, and `SQRT1_2`.
|
||||
@@ -345,7 +367,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
use their epoch time) and reject opaque runtime references as data errors.
|
||||
- [x] Unknown static members on global namespaces and on `Number`/`String`/the coercion functions read as `undefined`
|
||||
for feature detection. Calling any undefined value reports a native-style `TypeError` naming the callee, for
|
||||
example `Math.sum is not a function.` Unknown `Promise` statics keep their descriptive error.
|
||||
example `Math.sum is not a function.` or `search(...).catch is not a function.` Unknown `Promise` statics keep
|
||||
their descriptive error.
|
||||
- [x] `Math.sumPrecise` over finite collections and custom synchronous iterators/generators, rejecting non-number
|
||||
elements without coercion.
|
||||
- [x] Global coercing `isFinite` and `isNaN`; opaque runtime references reject as data errors, like `Number(...)`.
|
||||
@@ -359,7 +382,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `JSON.stringify` function and array replacers. Function replacers receive `(key, value)` in preorder, including
|
||||
the root, but no `this` holder. Array replacers preserve requested property order, deduplicate names, coerce
|
||||
number primitives, and ignore non-string/non-number entries. Primitive wrapper entries remain unsupported.
|
||||
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`.
|
||||
- [x] Captured `console.log`, `console.info`, `console.debug`, `console.warn`, and `console.error`. An Error prints as
|
||||
`Error.prototype.toString` would show it (`Error: boom`), wherever it appears in the logged value.
|
||||
- [x] Captured `console.dir` and `console.table`.
|
||||
|
||||
## Date
|
||||
@@ -379,6 +403,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`TimeClip` behavior.
|
||||
- [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.
|
||||
- [ ] Date setters and multi-argument construction coerce object arguments through `valueOf`/`toString` and surface
|
||||
their throws.
|
||||
@@ -400,15 +426,30 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
|
||||
- [x] `RegExp.escape`.
|
||||
|
||||
## Iterator
|
||||
|
||||
- [x] `Iterator.prototype.map`, `filter`, `take`, `drop`, and `flatMap` on any iterator or generator: lazy, one source
|
||||
step per result, closing the source when a callback throws, on early `return()`, or when `for...of` or
|
||||
destructuring finishes with it early. Once done or closed a helper stays done, and a callback that re-enters its
|
||||
own helper is a `TypeError`. `take`/`drop` coerce their count and reject `NaN` or negative counts with a
|
||||
`RangeError`; `flatMap` callbacks must return an iterable or iterator, not a string.
|
||||
- [x] `Iterator.prototype.reduce`, `toArray`, `forEach`, `some`, `every`, and `find`, closing the source on early exit.
|
||||
- [x] `Iterator.from(value)` returns iterators and generators as they are, and wraps strings, iterables, and objects
|
||||
with a `next` method. `Iterator` itself is abstract: calling or constructing it is a `TypeError`.
|
||||
- [x] Helpers and `Iterator.from` wrappers have `return()`; collection iterators (`array.values()`) do not, as in JS,
|
||||
so an early exit from them leaves them where they were.
|
||||
- [ ] `Iterator.concat`, `Iterator.zip`, and `Iterator.zipKeyed` (stage 3 proposals).
|
||||
|
||||
## Map and Set
|
||||
|
||||
- [x] Static `Map.groupBy` over finite collections and custom synchronous iterators/generators, preserving key identity.
|
||||
- [x] `new Map()` from synchronous iterables of entries.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Map `get`, `set`, `has`, `delete`, `clear`, `size`, `forEach`, `getOrInsert`, and `getOrInsertComputed`.
|
||||
`forEach` is live: entries deleted during the walk are skipped and entries added are visited, as in JS.
|
||||
- [x] `new Set()` from synchronous iterables.
|
||||
- [x] Set `add`, `has`, `delete`, `clear`, `size`, and `forEach`.
|
||||
- [x] Live `keys`, `values`, `entries`, and `[Symbol.iterator]` iterators for Map and Set; a Set-like operand's `keys()`
|
||||
may return a built-in iterator or an array.
|
||||
may return any iterator or an array.
|
||||
- [x] Spread, `for...of`, `Array.from`, and `Object.fromEntries` integration.
|
||||
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
|
||||
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
|
||||
@@ -425,7 +466,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] `new URLSearchParams()` from query strings, data objects, synchronous iterables of pairs, and URLSearchParams.
|
||||
- [x] URLSearchParams `append`, `delete`, `get`, `getAll`, `has`, `set`, `sort`, `forEach`, `keys`, `values`,
|
||||
`entries`, `[Symbol.iterator]`, `toString`, and `size`.
|
||||
- [x] URL values serialize to their href; URLSearchParams serialize to `{}`.
|
||||
- [x] URL values are their href in `JSON.stringify` and at the host boundary. URLSearchParams are `{}` in
|
||||
`JSON.stringify` and their query string at the host boundary.
|
||||
|
||||
## Uint8Array
|
||||
|
||||
@@ -470,7 +512,8 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
|
||||
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
errors cross as errors with their name, message, `cause`, and own enumerable data, and a `__proto__` key is
|
||||
dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
|
||||
@@ -495,8 +538,10 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
- [x] `AggregateError` with the `(errors, message?)` signature and an own `errors` array, constructed directly or by
|
||||
an all-rejected `Promise.any`; direct construction accepts custom synchronous iterators and generators.
|
||||
- [x] Error `name`/`message`, error inheritance through `instanceof`, and plain-data serialization. `message` is an own
|
||||
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` while the host still
|
||||
receives `{ name, message }`. Errors have no `stack`; the diagnostic carries the source location instead.
|
||||
non-enumerable property and `name` is inherited, as in JS, so `Object.keys(err)` is `[]` for a plain error. The
|
||||
result boundary still emits `{ name, message, ...own enumerable }`, so a field such as `code` crosses. `cause` is
|
||||
non-enumerable: an extension Error carries it, and this JSON form does not. Errors have no `stack`; the diagnostic
|
||||
carries a 1-based line and column in the submitted source instead.
|
||||
- [x] `instanceof` against any constructor with a `prototype`, including every built-in and `Function`.
|
||||
- [x] Catchable user throws, runtime failures raised during interpreted evaluation, awaited tool failures, and awaited
|
||||
tool-call-limit failures; parse/compile failures, cooperative timeout, and output bounding remain outside program
|
||||
@@ -505,8 +550,9 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
|
||||
short orientation to the supported subset; this matrix is the full reference.
|
||||
- [x] Model-visible host failure messages and underlying causes, including output-validation errors.
|
||||
- [x] Caught errors do not distinguish user throws, interpreter failures, and tool failures; a program sees one
|
||||
Error-shaped value with `name` and `message` in `catch`, rejection handlers, and `Promise.allSettled` reasons.
|
||||
This is deliberate: the program should handle a failure the same way regardless of where it originated.
|
||||
Error-shaped value in `catch`, rejection handlers, and `Promise.allSettled` reasons. It always has `name` and
|
||||
`message`, plus `cause` and own data when the failure carried them. This is deliberate: the program should
|
||||
handle a failure the same way regardless of where it originated.
|
||||
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
|
||||
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
|
||||
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.10",
|
||||
"version": "2.0.12",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
Obj,
|
||||
PromiseObj,
|
||||
record,
|
||||
type Value,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
@@ -23,14 +24,14 @@ import { typeofValue } from "./interpreter/references.js"
|
||||
|
||||
export type Json = Schema.Json
|
||||
|
||||
type Replacer<R> = (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
type Replacer<R> = (args: Array<Value>) => Effect.Effect<Value, unknown, R>
|
||||
|
||||
/**
|
||||
* What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and
|
||||
* `undefined` vanish, non-finite numbers become null, and everything else is copied. Two departures from JS
|
||||
* so a mistake is not a silent `{}`: an Error serializes as `{ name, message, ...own }`, and a promise throws.
|
||||
*/
|
||||
export const toJson = <R>(ctx: Interpreter<R>, value: unknown, replacer?: Replacer<R>) =>
|
||||
export const toJson = <R>(ctx: Interpreter<R>, value: Value, replacer?: Replacer<R>) =>
|
||||
walk(ctx, value, replacer, false)
|
||||
|
||||
/**
|
||||
@@ -38,11 +39,11 @@ export const toJson = <R>(ctx: Interpreter<R>, value: unknown, replacer?: Replac
|
||||
* awaited, a Set crosses as an array, a URLSearchParams as its query string, a Uint8Array asks to be encoded as
|
||||
* text first, and a `__proto__` key is dropped so host code can never receive one.
|
||||
*/
|
||||
export const toBoundary = <R>(ctx: Interpreter<R>, value: unknown) => walk(ctx, value, undefined, true)
|
||||
export const toBoundary = <R>(ctx: Interpreter<R>, value: Value) => walk(ctx, value, undefined, true)
|
||||
|
||||
const walk = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
value: unknown,
|
||||
value: Value,
|
||||
replacer: Replacer<R> | undefined,
|
||||
boundary: boolean,
|
||||
): Effect.Effect<Json | undefined, unknown, R> => {
|
||||
@@ -104,7 +105,7 @@ const walk = <R>(
|
||||
}
|
||||
|
||||
/** Host JSON as program values: objects and arrays are copied, primitives pass through. */
|
||||
export const fromJson = <R>(ctx: Interpreter<R>, value: unknown): unknown => {
|
||||
export const fromJson = <R>(ctx: Interpreter<R>, value: Json | undefined): Value => {
|
||||
if (value === null || typeof value !== "object") return value
|
||||
if (Array.isArray(value))
|
||||
return new Arr(
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
import { Effect, Exit } from "effect"
|
||||
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
|
||||
import { Cause, Effect, Exit } from "effect"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { typeError } from "./model.js"
|
||||
import { Callable, get, Native, DateObj, Obj } from "./objects.js"
|
||||
import { primitivePrototype } from "./intrinsics.js"
|
||||
import { GeneratorReturn, typeError } from "./model.js"
|
||||
import {
|
||||
Callable,
|
||||
get,
|
||||
Native,
|
||||
DateObj,
|
||||
Obj,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
type Cursor,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
|
||||
export type IteratorCursor<R> = {
|
||||
readonly next: Effect.Effect<{ readonly done: boolean; readonly value: unknown }, unknown, R>
|
||||
readonly close: Effect.Effect<void, unknown, R>
|
||||
}
|
||||
|
||||
/** 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`. */
|
||||
export const preserveConsumerError = <A, R>(
|
||||
cursor: IteratorCursor<R>,
|
||||
close: Cursor<R>["close"],
|
||||
effect: Effect.Effect<A, unknown, R>,
|
||||
): Effect.Effect<A, unknown, R> =>
|
||||
Effect.flatMap(Effect.exit(effect), (exit) =>
|
||||
Exit.isSuccess(exit)
|
||||
? Effect.succeed(exit.value)
|
||||
: Effect.andThen(Effect.exit(cursor.close), Effect.failCause(exit.cause)),
|
||||
)
|
||||
Effect.flatMap(Effect.exit(effect), (exit) => {
|
||||
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
||||
return Effect.flatMap(Effect.exit(close), (closed) => {
|
||||
if (!Exit.isSuccess(closed) && Cause.squash(exit.cause) instanceof GeneratorReturn) {
|
||||
return Effect.failCause(closed.cause)
|
||||
}
|
||||
return Effect.failCause(exit.cause)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the
|
||||
@@ -26,9 +37,9 @@ export const preserveConsumerError = <A, R>(
|
||||
*/
|
||||
export const toPrimitive = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
value: unknown,
|
||||
value: Value,
|
||||
hint: "number" | "string" | "default",
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
): Effect.Effect<Value, unknown, R> => {
|
||||
if (!(value instanceof Obj)) return Effect.succeed(value)
|
||||
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
|
||||
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
|
||||
@@ -43,24 +54,31 @@ export const toPrimitive = <R>(
|
||||
})
|
||||
}
|
||||
|
||||
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: unknown) =>
|
||||
/** Invoke(value, name): calls the method the value would find through its prototype. */
|
||||
export const invoke = <R>(ctx: Interpreter<R>, value: Value, name: string, label: string) => {
|
||||
const target = value instanceof Obj ? value : primitivePrototype(ctx.builtins, value)
|
||||
if (target === undefined) throw typeError(`${label} called on null or undefined.`)
|
||||
return ctx.call(get(target, name), value, [])
|
||||
}
|
||||
|
||||
export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
Effect.map(toPrimitive(ctx, value, "string"), coerceToString)
|
||||
|
||||
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: unknown) =>
|
||||
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
|
||||
|
||||
// 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
|
||||
// constructors pass the gate and throw a TypeError on call, like JS.
|
||||
export const isSupportedCallback = (value: unknown): value is Callable =>
|
||||
export const isSupportedCallback = (value: Value): value is Callable =>
|
||||
value instanceof Callable && !(value instanceof Native && !value.callback)
|
||||
|
||||
export const applyCollectionCallback = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
callback: unknown,
|
||||
callback: Value,
|
||||
name: string,
|
||||
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
|
||||
): ((args: Array<Value>) => Effect.Effect<Value, unknown, R>) => {
|
||||
if (!isSupportedCallback(callback)) {
|
||||
if (typeofValue(callback) === "function") {
|
||||
throw typeError(
|
||||
|
||||
@@ -6,10 +6,21 @@ import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, type
|
||||
import { containsRuntimeReference } from "./references.js"
|
||||
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
|
||||
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
|
||||
import { type Callable, define, get, has, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
|
||||
import {
|
||||
type Callable,
|
||||
define,
|
||||
get,
|
||||
has,
|
||||
hidden,
|
||||
type Native,
|
||||
Arr,
|
||||
ErrorObj,
|
||||
Obj,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { formatValue } from "../stdlib/console.js"
|
||||
import { coerceToString } from "../stdlib/value.js"
|
||||
|
||||
export const normalizeError = (error: unknown): Diagnostic => {
|
||||
if (error instanceof PendingThrow) {
|
||||
@@ -86,7 +97,7 @@ export const locate = (error: unknown, node?: AstNode): unknown => {
|
||||
}
|
||||
|
||||
/** The program value a handler receives for a failure; one failure always yields the same value. */
|
||||
export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): unknown => {
|
||||
export const materialize = <R>(ctx: Interpreter<R>, thrown: unknown): Value => {
|
||||
if (thrown instanceof Throw) return thrown.value
|
||||
const builtins = ctx.builtins
|
||||
if (thrown instanceof PendingThrow) {
|
||||
@@ -113,7 +124,7 @@ const errorToString = (self: Obj): string => {
|
||||
|
||||
export const createAggregateErrorValue = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
errors: Array<unknown>,
|
||||
errors: Array<Value>,
|
||||
message: string,
|
||||
proto: Obj = ctx.builtins.AggregateError,
|
||||
) => {
|
||||
@@ -124,13 +135,13 @@ export const createAggregateErrorValue = <R>(
|
||||
|
||||
const constructAggregateErrorValue = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
args: Array<unknown>,
|
||||
args: Array<Value>,
|
||||
proto: Obj,
|
||||
): Effect.Effect<ErrorObj, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(args[0])
|
||||
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
|
||||
const errors: Array<unknown> = []
|
||||
const errors: Array<Value> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
@@ -144,7 +155,7 @@ const constructAggregateErrorValue = <R>(
|
||||
export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const prototype = builtins[type]
|
||||
const construct = (args: Array<unknown>, newTarget: Callable) => {
|
||||
const construct = (args: Array<Value>, newTarget: Callable) => {
|
||||
const proto = prototypeFrom(newTarget, prototype)
|
||||
const created =
|
||||
type === "AggregateError"
|
||||
@@ -168,6 +179,7 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
methods(builtins, prototype, [
|
||||
["toString", 0, (thisValue) => errorToString(receiver(Obj, thisValue, "Error.prototype.toString"))],
|
||||
])
|
||||
methods(builtins, ctor, [["isError", 1, (_, args) => args[0] instanceof ErrorObj]])
|
||||
}
|
||||
return ctor
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../
|
||||
import { toBoundary } from "../data.js"
|
||||
import { ToolRuntime } from "../tool-runtime.js"
|
||||
import { normalizeError } from "./errors.js"
|
||||
import type { Value } from "./objects.js"
|
||||
import { createBuiltins } from "./intrinsics.js"
|
||||
import { Pending } from "./promises.js"
|
||||
import { Interpreter } from "./interpreter.js"
|
||||
@@ -13,7 +14,7 @@ export const executeProgram = <R>(
|
||||
prepared: ToolRuntime.Prepared<R>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
hooks: ToolRuntime.Hooks<R>,
|
||||
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
|
||||
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, Value]>,
|
||||
): Effect.Effect<Result, never, R> => {
|
||||
if (code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import type { Extension } from "../extension.js"
|
||||
import { coerceToString } from "../stdlib/value.js"
|
||||
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { createErrorValue, isErrorType } from "./intrinsics.js"
|
||||
@@ -8,9 +7,7 @@ import { MAX_VALUE_DEPTH } from "./limits.js"
|
||||
import { PendingThrow, Throw, typeError } from "./model.js"
|
||||
import { fn } from "./native.js"
|
||||
import {
|
||||
Callable,
|
||||
define,
|
||||
entries,
|
||||
get,
|
||||
has,
|
||||
hidden,
|
||||
@@ -19,18 +16,17 @@ import {
|
||||
Bytes,
|
||||
DateObj,
|
||||
ErrorObj,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
HeadersObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
RegExpObj,
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
import { describeValue, isOpaque } from "./references.js"
|
||||
|
||||
/**
|
||||
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
|
||||
@@ -40,35 +36,18 @@ import { describeValue } from "./references.js"
|
||||
export const extensionGlobals = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
extensions: ReadonlyArray<Extension>,
|
||||
): ReadonlyArray<readonly [string, unknown]> => {
|
||||
): ReadonlyArray<readonly [string, Value]> => {
|
||||
const builtins = ctx.builtins
|
||||
|
||||
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
|
||||
const toHost = (value: Value, label: string, depth = 0, seen = new Set<object>()): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
if (value === null || typeof value !== "object") {
|
||||
if (isPrimitive(value)) return value
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
}
|
||||
if (value instanceof Bytes) return new Uint8Array(value.bytes)
|
||||
if (value instanceof DateObj) return new Date(value.time)
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
if (value instanceof URLObj) return new URL(value.url.href)
|
||||
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
|
||||
if (value instanceof HeadersObj) return new Headers(value.headers)
|
||||
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
|
||||
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
|
||||
if (value instanceof SetObj) return new Set([...value.set].map(next))
|
||||
if (
|
||||
!(value instanceof Obj) ||
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof IteratorObj ||
|
||||
value instanceof PromiseObj
|
||||
) {
|
||||
if (isPrimitive(value)) return value
|
||||
if (!(value instanceof Obj) || isOpaque(value)) {
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
|
||||
seen.add(value)
|
||||
const next = (item: Value) => toHost(item, label, depth + 1, seen)
|
||||
if (value instanceof ErrorObj) {
|
||||
const name = coerceToString(get(value, "name"))
|
||||
const message = get(value, "message")
|
||||
@@ -89,19 +68,12 @@ export const extensionGlobals = <R>(
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
const copied =
|
||||
value instanceof Arr
|
||||
? value.items.map(next)
|
||||
: Object.fromEntries(
|
||||
entries(value)
|
||||
.filter(([key]) => key !== "__proto__")
|
||||
.map(([key, item]) => [key, next(item)]),
|
||||
)
|
||||
const copied = value.toHost(next)
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
|
||||
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
|
||||
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): Value => {
|
||||
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
if (isPrimitive(value)) return value
|
||||
if (typeof value === "function") return wrap(value, label)
|
||||
@@ -200,7 +172,7 @@ export const extensionGlobals = <R>(
|
||||
*/
|
||||
const uncrossed = new Set(["stack", "constructor", "toString", "__proto__"])
|
||||
const left = Symbol("left behind")
|
||||
const crossing = (convert: () => unknown): unknown => {
|
||||
const crossing = <T>(convert: () => T): T | typeof left => {
|
||||
try {
|
||||
return convert()
|
||||
} catch (reason) {
|
||||
@@ -219,7 +191,7 @@ const hostErrors = new Map<string, ErrorConstructor>([
|
||||
])
|
||||
|
||||
// The primitives the interpreter operates on; symbols and BigInts are not among them.
|
||||
const isPrimitive = (value: unknown): boolean =>
|
||||
const isPrimitive = (value: unknown): value is string | number | boolean | null | undefined =>
|
||||
value === null ||
|
||||
value === undefined ||
|
||||
typeof value === "string" ||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Effect } from "effect"
|
||||
import { fn, type Method, methods, receiver } from "./native.js"
|
||||
import { AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
|
||||
import { define, hidden, GeneratorObj } from "./objects.js"
|
||||
import { define, hidden, GeneratorObj, type Value } from "./objects.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
|
||||
/** `next`/`return`/`throw` on the generator prototypes; async generators answer with promises. */
|
||||
@@ -13,9 +13,9 @@ export const generatorGlobals = <R>(ctx: Interpreter<R>): void => {
|
||||
const request = (kind: GeneratorRequestKind): Method => [
|
||||
kind,
|
||||
1,
|
||||
(thisValue: unknown, args: Array<unknown>) => {
|
||||
(thisValue: Value, args: Array<Value>) => {
|
||||
const generator = receiver(GeneratorObj, thisValue, `${label}.prototype.${kind}`)
|
||||
const requested = generator.request(kind, args[0]) as Effect.Effect<unknown, unknown, R>
|
||||
const requested = generator.request(kind, args[0]) as Effect.Effect<Value, unknown, R>
|
||||
return generator.asynchronous ? ctx.pending.create(requested) : requested
|
||||
},
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import 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"
|
||||
@@ -51,7 +52,7 @@ const symbolGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return symbol
|
||||
}
|
||||
|
||||
type Factory = <R>(ctx: Interpreter<R>) => unknown
|
||||
type Factory = <R>(ctx: Interpreter<R>) => Value
|
||||
|
||||
// A table rather than a list so the names are known before any runtime exists.
|
||||
const table: Record<string, Factory> = {
|
||||
@@ -69,6 +70,7 @@ const table: Record<string, Factory> = {
|
||||
console: (ctx) => consoleGlobal(ctx),
|
||||
Promise: (ctx) => promiseGlobal(ctx),
|
||||
Symbol: (ctx) => symbolGlobal(ctx),
|
||||
Iterator: (ctx) => iteratorGlobals(ctx),
|
||||
Number: (ctx) => numberGlobal(ctx),
|
||||
String: (ctx) => stringGlobal(ctx),
|
||||
Boolean: (ctx) => booleanGlobal(ctx),
|
||||
@@ -100,8 +102,7 @@ const table: Record<string, Factory> = {
|
||||
export const globalNames: ReadonlySet<string> = new Set(Object.keys(table))
|
||||
|
||||
/** The immutable global bindings of every program, in declaration order. */
|
||||
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, unknown]> => {
|
||||
export const globals = <R>(ctx: Interpreter<R>): ReadonlyArray<readonly [string, Value]> => {
|
||||
generatorGlobals(ctx)
|
||||
iteratorGlobals(ctx)
|
||||
return Object.entries(table).map(([name, factory]) => [name, factory(ctx)] as const)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import type {
|
||||
Statement,
|
||||
Super,
|
||||
SwitchStatement,
|
||||
Literal,
|
||||
TemplateLiteral,
|
||||
ThrowStatement,
|
||||
TryStatement,
|
||||
@@ -62,33 +63,33 @@ import {
|
||||
} from "./model.js"
|
||||
import { checkStringLength } from "./limits.js"
|
||||
import { locate, materialize } from "./errors.js"
|
||||
import type { Builtins } from "./intrinsics.js"
|
||||
import { type Builtins, primitivePrototype } from "./intrinsics.js"
|
||||
import { globals } from "./globals.js"
|
||||
import {
|
||||
assign,
|
||||
Callable,
|
||||
define,
|
||||
get,
|
||||
hostCursor,
|
||||
IteratorObj,
|
||||
type Cursor,
|
||||
has,
|
||||
hidden,
|
||||
hasPrototype,
|
||||
keys,
|
||||
Native,
|
||||
parseArrayIndex,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
Fn,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
record,
|
||||
remove,
|
||||
set,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { preserveConsumerError } from "./callback.js"
|
||||
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
|
||||
@@ -96,7 +97,7 @@ import { containsOpaqueReference, describeValue, rejectCircularInsertion, typeof
|
||||
import { ScopeStack } from "./scope.js"
|
||||
import { constructRegExp } from "../stdlib/regexp.js"
|
||||
import { enumerableSource } from "../stdlib/object.js"
|
||||
import { coerceToNumber, coerceToString, compoundOperators } from "../stdlib/value.js"
|
||||
import { compoundOperators } from "../stdlib/value.js"
|
||||
|
||||
// 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.
|
||||
@@ -110,24 +111,36 @@ const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefin
|
||||
return undefined
|
||||
}
|
||||
|
||||
const calleeDescription = (callee: Expression | Super | undefined): string => {
|
||||
if (callee?.type === "Identifier") return callee.name
|
||||
if (callee?.type === "MemberExpression") {
|
||||
const object = callee.object
|
||||
const property = callee.property
|
||||
const key =
|
||||
!callee.computed && property.type === "Identifier"
|
||||
? property.name
|
||||
: property.type === "Literal" && typeof property.value === "string"
|
||||
? property.value
|
||||
: undefined
|
||||
if (object.type === "Identifier" && key !== undefined) return `${object.name}.${key}`
|
||||
// Native engines name the callee (`search(...).catch is not a function`), including call chains. Returns
|
||||
// undefined when a link cannot be named, so a chain is either named completely or not at all.
|
||||
const calleeDescription = (node: Expression | Super | undefined): string | undefined => {
|
||||
if (node?.type === "Identifier") return node.name
|
||||
if (node?.type === "CallExpression") {
|
||||
const target = calleeDescription(node.callee)
|
||||
return target === undefined ? undefined : `${target}(...)`
|
||||
}
|
||||
return "The called value"
|
||||
if (node?.type !== "MemberExpression") return undefined
|
||||
const property = node.property
|
||||
const key =
|
||||
!node.computed && property.type === "Identifier"
|
||||
? property.name
|
||||
: property.type === "Literal" && typeof property.value === "string"
|
||||
? property.value
|
||||
: undefined
|
||||
if (key === undefined) return undefined
|
||||
const object = calleeDescription(node.object)
|
||||
return object === undefined ? undefined : `${object}.${key}`
|
||||
}
|
||||
|
||||
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
// acorn types every literal as possibly a BigInt or RegExp; regex literals become RegExp objects before this is asked.
|
||||
const literal = (node: Literal): Value => {
|
||||
if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node)
|
||||
if (node.value instanceof RegExp) throw unsupportedSyntax("RegExpLiteral", node)
|
||||
return node.value
|
||||
}
|
||||
|
||||
const instanceofValue = (lhs: Value, rhs: Value, node: AstNode): boolean => {
|
||||
if (!(rhs instanceof Callable)) {
|
||||
throw typeError("The right-hand side of 'instanceof' is not callable.", node)
|
||||
}
|
||||
@@ -224,7 +237,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...
|
||||
|
||||
type CustomIterator = {
|
||||
iterator: Obj
|
||||
next: unknown
|
||||
next: Value
|
||||
asynchronous: boolean
|
||||
}
|
||||
|
||||
@@ -232,13 +245,13 @@ type CustomIterator = {
|
||||
type MemberReference = {
|
||||
target: Obj
|
||||
key: PropertyKey
|
||||
receiver: unknown
|
||||
receiver: Value
|
||||
}
|
||||
|
||||
type GeneratorRequest = {
|
||||
kind: GeneratorRequestKind
|
||||
value: unknown
|
||||
response: Deferred.Deferred<unknown, unknown>
|
||||
value: Value
|
||||
response: Deferred.Deferred<Value, unknown>
|
||||
}
|
||||
|
||||
type GeneratorState = {
|
||||
@@ -264,7 +277,7 @@ export class Interpreter<R> {
|
||||
readonly pending: Pending<R>
|
||||
readonly builtins: Builtins
|
||||
readonly logs?: Array<string>
|
||||
readonly globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>
|
||||
readonly globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, Value]>
|
||||
}) {
|
||||
this.tools = options.tools
|
||||
this.pending = options.pending
|
||||
@@ -278,27 +291,31 @@ export class Interpreter<R> {
|
||||
}
|
||||
}
|
||||
|
||||
run(program: Program): Effect.Effect<unknown, unknown, R> {
|
||||
run(program: Program): Effect.Effect<Value, unknown, R> {
|
||||
return this.root.run(program)
|
||||
}
|
||||
|
||||
call(callable: unknown, thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
call(callable: Value, thisValue: Value, args: Array<Value>): Effect.Effect<Value, unknown, R> {
|
||||
return this.root.call(callable, thisValue, args)
|
||||
}
|
||||
|
||||
await(promise: PromiseObj): Effect.Effect<unknown, unknown, never> {
|
||||
await(promise: PromiseObj): Effect.Effect<Value, unknown, never> {
|
||||
return this.root.await(promise)
|
||||
}
|
||||
|
||||
iterate(value: unknown) {
|
||||
iterate(value: Value) {
|
||||
return this.root.iterate(value)
|
||||
}
|
||||
|
||||
iterateDirect(value: Value) {
|
||||
return this.root.iterateDirect(value)
|
||||
}
|
||||
|
||||
/** Runs one host tool: arguments cross as JSON and the result comes back as program values. */
|
||||
tool(
|
||||
run: (args: Array<Json | undefined>) => Effect.Effect<Json | undefined, unknown, R>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
args: Array<Value>,
|
||||
): Effect.Effect<Value, unknown, R> {
|
||||
const ctx = this
|
||||
return Effect.gen(function* () {
|
||||
const json = yield* Effect.forEach(args, (arg) => toBoundary(ctx, arg))
|
||||
@@ -321,7 +338,7 @@ class Frame<R> {
|
||||
private depth = 0,
|
||||
) {}
|
||||
|
||||
run(program: Program): Effect.Effect<unknown, unknown, R> {
|
||||
run(program: Program): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
// Keep top-level declarations separate so they can shadow builtins.
|
||||
this.scopes.push()
|
||||
@@ -329,7 +346,7 @@ class Frame<R> {
|
||||
self.predeclareLexical(program.body)
|
||||
self.hoistFunctions(program.body)
|
||||
self.hoistVars(program.body)
|
||||
let value: unknown = undefined
|
||||
let value: Value = undefined
|
||||
for (const [index, statement] of program.body.entries()) {
|
||||
if (index === program.body.length - 1 && statement.type === "ExpressionStatement") {
|
||||
value = yield* self.evaluateExpression(statement.expression)
|
||||
@@ -354,15 +371,12 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
// Fork at the call site so admission and hooks occur when the call is made.
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<PromiseObj, never, R> {
|
||||
private createToolCallPromise(path: ReadonlyArray<string>, args: Array<Value>): Effect.Effect<PromiseObj, never, R> {
|
||||
return this.ctx.pending.create(this.ctx.tool((json) => this.ctx.tools.execute(path, json), args))
|
||||
}
|
||||
|
||||
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
|
||||
await(promise: PromiseObj): Effect.Effect<unknown, unknown, never> {
|
||||
await(promise: PromiseObj): Effect.Effect<Value, unknown, never> {
|
||||
const pending = this.ctx.pending
|
||||
return Effect.suspend(() => {
|
||||
pending.markObserved(promise)
|
||||
@@ -441,8 +455,9 @@ class Frame<R> {
|
||||
node: FunctionDeclaration | FunctionExpression | ArrowFunctionExpression,
|
||||
name = node.type === "ArrowFunctionExpression" ? "" : (node.id?.name ?? ""),
|
||||
): Fn {
|
||||
return new Fn(
|
||||
this.ctx.builtins.Function,
|
||||
const builtins = this.ctx.builtins
|
||||
const fn = new Fn(
|
||||
builtins.Function,
|
||||
name,
|
||||
node.params,
|
||||
node.body,
|
||||
@@ -450,20 +465,29 @@ class Frame<R> {
|
||||
node.async,
|
||||
node.generator,
|
||||
)
|
||||
// Each generator function gets its own prototype, so `g() instanceof g` holds as in JS.
|
||||
if (node.generator)
|
||||
define(fn, "prototype", new Obj(node.async ? builtins.AsyncGenerator : builtins.Generator), hidden)
|
||||
// The body of a named function expression sees its own name, read-only.
|
||||
if (node.type === "FunctionExpression" && node.id) {
|
||||
fn.capturedScopes.push(new Map([[node.id.name, { mutable: false, value: fn, initialized: true }]]))
|
||||
}
|
||||
return fn
|
||||
}
|
||||
|
||||
// NamedEvaluation: an anonymous function definition takes the name of what it is assigned to.
|
||||
private evaluateNamed(node: Expression, name: string): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateNamed(node: Expression, name: string): Effect.Effect<Value, unknown, R> {
|
||||
if (node.type === "ArrowFunctionExpression" || (node.type === "FunctionExpression" && !node.id)) {
|
||||
return Effect.sync(() => this.createFunction(node, name))
|
||||
}
|
||||
return this.evaluateExpression(node)
|
||||
}
|
||||
|
||||
// Repeated `function` declarations and `var` clashes are legal: the last declaration wins.
|
||||
private hoistFunctions(statements: ReadonlyArray<Statement | ModuleDeclaration>): void {
|
||||
for (const node of statements) {
|
||||
if (node.type !== "FunctionDeclaration") continue
|
||||
this.scopes.declare(node.id.name, this.createFunction(node), true, node)
|
||||
this.scopes.current().set(node.id.name, { mutable: true, value: this.createFunction(node), initialized: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,11 +675,11 @@ class Frame<R> {
|
||||
if (declared?.lexical) self.predeclarePattern(declared.pattern, declared.mutable, left)
|
||||
const right = yield* self.evaluateExpression(node.right)
|
||||
|
||||
const cursor = self.hostCursor(right)
|
||||
const cursor = self.builtinCursor(right)
|
||||
const iterator = cursor === undefined ? yield* self.customIterator(right, node, awaiting) : undefined
|
||||
if (iterator === undefined && cursor === undefined) {
|
||||
throw invalidData(
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
|
||||
`${awaiting ? "for await...of" : "for...of"} requires an iterable value, received ${describeValue(right)}.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
@@ -671,7 +695,7 @@ class Frame<R> {
|
||||
}
|
||||
const assignment = left.type === "VariableDeclaration" ? undefined : left
|
||||
|
||||
const evaluateBody = (value: unknown) =>
|
||||
const evaluateBody = (value: Value) =>
|
||||
Effect.gen(function* () {
|
||||
if (declared?.lexical) {
|
||||
self.scopes.push()
|
||||
@@ -700,10 +724,8 @@ class Frame<R> {
|
||||
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
|
||||
if (!Exit.isSuccess(bodyExit)) {
|
||||
// Process interruption must remain prompt; user cleanup cannot extend a timeout.
|
||||
if (!Cause.hasInterruptsOnly(bodyExit.cause)) {
|
||||
yield* Effect.exit(close())
|
||||
}
|
||||
return yield* Effect.failCause(bodyExit.cause)
|
||||
if (Cause.hasInterruptsOnly(bodyExit.cause)) return yield* Effect.failCause(bodyExit.cause)
|
||||
return yield* preserveConsumerError(close(), Effect.failCause(bodyExit.cause))
|
||||
}
|
||||
const exit = loopExit(bodyExit.value, labels)
|
||||
if (exit !== undefined) {
|
||||
@@ -720,7 +742,7 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
|
||||
private awaitValue(value: Value): Effect.Effect<Value, unknown, R> {
|
||||
return Effect.flatMap(resolvePromise(this.ctx, value), (promise) =>
|
||||
Effect.ensuring(
|
||||
this.await(promise),
|
||||
@@ -731,10 +753,10 @@ class Frame<R> {
|
||||
|
||||
private awaitAsyncFromSyncValue(
|
||||
iterator: CustomIterator,
|
||||
value: unknown,
|
||||
value: Value,
|
||||
node: AstNode | undefined,
|
||||
closeOnRejection: boolean,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const settled = yield* Effect.exit(self.awaitValue(value))
|
||||
@@ -746,54 +768,50 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
iterate(value: unknown, node?: AstNode) {
|
||||
const cursor = this.hostCursor(value)
|
||||
iterate(value: Value, node?: AstNode): Effect.Effect<Cursor<R> | undefined, unknown, R> {
|
||||
const cursor = this.builtinCursor(value)
|
||||
if (cursor !== undefined) return Effect.succeed(cursor)
|
||||
const self = this
|
||||
return Effect.map(this.customIterator(value, node, false), (iterator) =>
|
||||
iterator === undefined
|
||||
? undefined
|
||||
: {
|
||||
next: self.nextIteratorResult(iterator, node, false),
|
||||
close: Effect.suspend(() => self.closeIterator(iterator, node, false)),
|
||||
},
|
||||
iterator === undefined ? undefined : this.customCursor(iterator, node),
|
||||
)
|
||||
}
|
||||
|
||||
private hostCursor(value: unknown) {
|
||||
const iterator =
|
||||
value instanceof Arr
|
||||
? value.items[Symbol.iterator]()
|
||||
: typeof value === "string"
|
||||
? value[Symbol.iterator]()
|
||||
: value instanceof MapObj
|
||||
? value.map.entries()
|
||||
: value instanceof SetObj
|
||||
? value.set.values()
|
||||
: value instanceof URLSearchParamsObj
|
||||
? value.params.entries()
|
||||
: value instanceof HeadersObj
|
||||
? value.headers.entries()
|
||||
: value instanceof Bytes
|
||||
? value.bytes.values()
|
||||
: value instanceof IteratorObj
|
||||
? value.iterator
|
||||
: undefined
|
||||
if (iterator === undefined) return undefined
|
||||
const proto = this.ctx.builtins.Array
|
||||
/** GetIteratorDirect: drive an iterator by its own `next`, without asking for `[Symbol.iterator]`. */
|
||||
iterateDirect(value: Value, node?: AstNode): Cursor<R> {
|
||||
if (value instanceof IteratorObj) return value.cursor as Cursor<R>
|
||||
if (!(value instanceof Obj)) {
|
||||
throw typeError(`An iterator must be an object, received ${describeValue(value)}.`, node)
|
||||
}
|
||||
return this.customCursor(
|
||||
{
|
||||
iterator: value,
|
||||
next: this.requireIteratorMethod(get(value, "next"), "Iterator next", node),
|
||||
asynchronous: false,
|
||||
},
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
private customCursor(iterator: CustomIterator, node: AstNode | undefined): Cursor<R> {
|
||||
return {
|
||||
next: Effect.sync(() => {
|
||||
const step = iterator.next()
|
||||
return {
|
||||
done: Boolean(step.done),
|
||||
value: Array.isArray(step.value) ? new Arr(proto, step.value) : step.value,
|
||||
}
|
||||
}),
|
||||
close: Effect.void,
|
||||
next: this.nextIteratorResult(iterator, node, false),
|
||||
close: Effect.suspend(() => this.closeIterator(iterator, node, false)),
|
||||
}
|
||||
}
|
||||
|
||||
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
|
||||
private builtinCursor(value: Value): Cursor<R> | undefined {
|
||||
// Natives build their cursors without knowing R, like `lift` in native.ts.
|
||||
if (value instanceof IteratorObj) return value.cursor as Cursor<R>
|
||||
const iterator =
|
||||
typeof value === "string"
|
||||
? value[Symbol.iterator]()
|
||||
: value instanceof Obj
|
||||
? value.iterator(this.ctx.builtins)
|
||||
: undefined
|
||||
return iterator === undefined ? undefined : hostCursor(iterator)
|
||||
}
|
||||
|
||||
private customIterator(value: Value, node: AstNode | undefined, allowAsync = true) {
|
||||
if (!(value instanceof Obj)) return Effect.undefined
|
||||
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
|
||||
const method = asyncMethod ?? get(value, IteratorSymbol)
|
||||
@@ -883,18 +901,18 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private requireIteratorObject(value: unknown, context: string, node?: AstNode): Obj {
|
||||
private requireIteratorObject(value: Value, context: string, node?: AstNode): Obj {
|
||||
if (value instanceof Obj) return value
|
||||
throw typeError(`${context} must be an object.`, node)
|
||||
}
|
||||
|
||||
private requireIteratorMethod(value: unknown, context: string, node?: AstNode): unknown {
|
||||
private requireIteratorMethod(value: Value, context: string, node?: AstNode): Value {
|
||||
if (typeofValue(value) === "function") return value
|
||||
throw typeError(`${context} must be a function.`, node)
|
||||
}
|
||||
|
||||
// for...in over null/undefined iterates nothing, like JS.
|
||||
private enumerableKeys(value: unknown, node: AstNode): Array<string> {
|
||||
private enumerableKeys(value: Value, node: AstNode): Array<string> {
|
||||
if (value instanceof ToolReference) return [...this.ctx.tools.keys(value.path)]
|
||||
if (value === null || value === undefined) return []
|
||||
return keys(enumerableSource(this.ctx, "for...in", value, node))
|
||||
@@ -1058,7 +1076,7 @@ class Frame<R> {
|
||||
|
||||
private declarePattern(
|
||||
pattern: Pattern,
|
||||
value: unknown,
|
||||
value: Value,
|
||||
mutable: boolean,
|
||||
node: AstNode,
|
||||
initialize = false,
|
||||
@@ -1118,7 +1136,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private assignPattern(pattern: Pattern, value: unknown, node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
private assignPattern(pattern: Pattern, value: Value, node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (pattern.type === "Identifier") {
|
||||
@@ -1170,7 +1188,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateDefault(pattern: AssignmentPattern): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateDefault(pattern: AssignmentPattern): Effect.Effect<Value, unknown, R> {
|
||||
return pattern.left.type === "Identifier"
|
||||
? this.evaluateNamed(pattern.right, pattern.left.name)
|
||||
: this.evaluateExpression(pattern.right)
|
||||
@@ -1178,8 +1196,8 @@ class Frame<R> {
|
||||
|
||||
private destructureArrayPattern(
|
||||
pattern: ArrayPattern,
|
||||
value: unknown,
|
||||
consume: (target: Pattern, value: unknown, context: AstNode) => Effect.Effect<void, unknown, R>,
|
||||
value: Value,
|
||||
consume: (target: Pattern, value: Value, context: AstNode) => Effect.Effect<void, unknown, R>,
|
||||
): Effect.Effect<void, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1203,7 +1221,7 @@ class Frame<R> {
|
||||
done = step.done
|
||||
if (element === null) continue
|
||||
if (element.type === "RestElement") {
|
||||
const rest: Array<unknown> = []
|
||||
const rest: Array<Value> = []
|
||||
if (!step.done) rest.push(step.value)
|
||||
while (!done) {
|
||||
const next = yield* cursor.next
|
||||
@@ -1214,7 +1232,7 @@ class Frame<R> {
|
||||
return
|
||||
}
|
||||
const consumed = consume(element, step.done ? undefined : step.value, pattern)
|
||||
yield* step.done ? consumed : preserveConsumerError(cursor, consumed)
|
||||
yield* step.done ? consumed : preserveConsumerError(cursor.close, consumed)
|
||||
}
|
||||
if (!done) yield* cursor.close
|
||||
})
|
||||
@@ -1226,20 +1244,19 @@ class Frame<R> {
|
||||
}
|
||||
const keyNode = property.key
|
||||
if (property.computed) {
|
||||
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
|
||||
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value))
|
||||
}
|
||||
if (keyNode.type === "Identifier") return Effect.succeed(keyNode.name)
|
||||
if (keyNode.type === "Literal") return Effect.succeed(String(keyNode.value))
|
||||
throw unsupportedSyntax(keyNode.type, keyNode)
|
||||
}
|
||||
|
||||
private evaluateExpression(node: Expression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateExpression(node: Expression): Effect.Effect<Value, unknown, R> {
|
||||
switch (node.type) {
|
||||
case "Literal": {
|
||||
const regex = node.regex
|
||||
if (regex) return Effect.sync(() => constructRegExp(this.ctx.builtins, [regex.pattern, regex.flags]))
|
||||
if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node)
|
||||
return Effect.succeed(node.value)
|
||||
return Effect.succeed(literal(node))
|
||||
}
|
||||
case "Identifier":
|
||||
return Effect.sync(() => this.scopes.get(node.name, node))
|
||||
@@ -1254,7 +1271,7 @@ class Frame<R> {
|
||||
case "SequenceExpression": {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
let result: unknown
|
||||
let result: Value
|
||||
for (const expression of node.expressions) {
|
||||
result = yield* self.evaluateExpression(expression)
|
||||
}
|
||||
@@ -1295,7 +1312,7 @@ class Frame<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateNewExpression(node: NewExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateNewExpression(node: NewExpression): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const callee = yield* self.evaluateExpression(node.callee)
|
||||
@@ -1305,7 +1322,7 @@ class Frame<R> {
|
||||
// `new` itself is supported, so a non-constructible callee is a TypeError like JS rather than
|
||||
// unsupported syntax. Built-ins like Number are real constructors in JS, so do not claim
|
||||
// otherwise; say `new` is unsupported for them and point at the plain call.
|
||||
const name = calleeDescription(node.callee)
|
||||
const name = calleeDescription(node.callee) ?? "The called value"
|
||||
const message =
|
||||
callee instanceof Fn
|
||||
? `${name} cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.`
|
||||
@@ -1319,7 +1336,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateBinaryExpression(node: BinaryExpression): Effect.Effect<Value, unknown, R> {
|
||||
const operator = node.operator
|
||||
const left = node.left
|
||||
if (left.type === "PrivateIdentifier") throw unsupportedSyntax(left.type, left)
|
||||
@@ -1332,7 +1349,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
|
||||
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
|
||||
if (operator === "===") return lhs === rhs
|
||||
if (operator === "!==") return lhs !== rhs
|
||||
if (operator === "in" && rhs instanceof Obj && !containsOpaqueReference(lhs)) {
|
||||
@@ -1341,14 +1358,9 @@ class Frame<R> {
|
||||
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
|
||||
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
|
||||
const coerceOperand = (operand: unknown): unknown => {
|
||||
if (operand instanceof DateObj) {
|
||||
return operator === "+" || operator === "==" || operator === "!=" ? coerceToString(operand) : operand.time
|
||||
}
|
||||
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
}
|
||||
// Addition and loose equality use the default hint; every other operator asks for a number.
|
||||
const hint = operator === "+" || operator === "==" || operator === "!=" ? "default" : "number"
|
||||
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
|
||||
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
|
||||
const l = coerceOperand(lhs)
|
||||
const r = coerceOperand(rhs)
|
||||
@@ -1402,7 +1414,7 @@ class Frame<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
|
||||
const operator = node.operator
|
||||
return Effect.flatMap(this.evaluateExpression(node.left), (left) => {
|
||||
if (operator === "&&") return left ? this.evaluateExpression(node.right) : Effect.succeed(left)
|
||||
@@ -1413,7 +1425,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateUnaryExpression(node: UnaryExpression): Effect.Effect<Value, unknown, R> {
|
||||
const operator = node.operator
|
||||
const argument = node.argument
|
||||
if (operator === "delete") return this.evaluateDeleteExpression(argument)
|
||||
@@ -1428,13 +1440,8 @@ class Frame<R> {
|
||||
if (containsOpaqueReference(value)) {
|
||||
throw invalidData("Unary operators require data values.", node)
|
||||
}
|
||||
const operand =
|
||||
value instanceof DateObj
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
: value
|
||||
let result: unknown
|
||||
const operand = value instanceof Obj ? value.toPrimitive("number") : value
|
||||
let result: Value
|
||||
switch (operator) {
|
||||
case "+":
|
||||
result = +(operand as number)
|
||||
@@ -1452,7 +1459,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
|
||||
const left = node.left
|
||||
const operator = node.operator
|
||||
const self = this
|
||||
@@ -1492,9 +1499,9 @@ class Frame<R> {
|
||||
node: AssignmentExpression,
|
||||
left: Pattern,
|
||||
operator: string,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
const shouldAssign = (current: unknown): boolean =>
|
||||
const shouldAssign = (current: Value): boolean =>
|
||||
operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
|
||||
if (left.type === "Identifier") {
|
||||
const name = left.name
|
||||
@@ -1519,7 +1526,7 @@ class Frame<R> {
|
||||
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
}
|
||||
|
||||
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<Value, unknown, R> {
|
||||
const operator = node.operator
|
||||
const argument = node.argument
|
||||
const prefix = node.prefix
|
||||
@@ -1532,7 +1539,7 @@ class Frame<R> {
|
||||
|
||||
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
|
||||
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
|
||||
const operand = (current: unknown): number => {
|
||||
const operand = (current: Value): number => {
|
||||
if (containsOpaqueReference(current)) {
|
||||
throw invalidData(`'${operator}' requires a data value.`, argument)
|
||||
}
|
||||
@@ -1561,7 +1568,7 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
// EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined.
|
||||
private evaluateCallExpression(node: CallExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateCallExpression(node: CallExpression): Effect.Effect<Value, unknown, R> {
|
||||
const callee = node.callee
|
||||
|
||||
const self = this
|
||||
@@ -1579,7 +1586,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private readMethod(node: MemberExpression): Effect.Effect<{ callable: unknown; thisValue: unknown }, unknown, R> {
|
||||
private readMethod(node: MemberExpression): Effect.Effect<{ callable: Value; thisValue: Value }, unknown, R> {
|
||||
return Effect.map(this.getMemberReference(node), (reference) => {
|
||||
if (reference === OptionalShortCircuit) return { callable: OptionalShortCircuit, thisValue: undefined }
|
||||
if (reference instanceof ToolReference) return { callable: reference, thisValue: undefined }
|
||||
@@ -1590,12 +1597,12 @@ class Frame<R> {
|
||||
|
||||
// The single dispatch for every invocation: call expressions and callbacks share it.
|
||||
call(
|
||||
callable: unknown,
|
||||
thisValue: unknown,
|
||||
args: Array<unknown>,
|
||||
callable: Value,
|
||||
thisValue: Value,
|
||||
args: Array<Value>,
|
||||
node?: AstNode,
|
||||
callee?: Expression,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (callable instanceof ToolReference) {
|
||||
@@ -1608,12 +1615,12 @@ class Frame<R> {
|
||||
if (callable instanceof Native) {
|
||||
return yield* self.native(() => (callable as Native<R>).call(thisValue, args), node)
|
||||
}
|
||||
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
|
||||
throw typeError(`${calleeDescription(callee) ?? "The called value"} is not a function.`, callee ?? node)
|
||||
})
|
||||
}
|
||||
|
||||
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it.
|
||||
private native(body: () => Effect.Effect<unknown, unknown, R>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private native(body: () => Effect.Effect<Value, unknown, R>, node?: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
return Effect.provideService(
|
||||
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
|
||||
CallSite,
|
||||
@@ -1623,10 +1630,10 @@ class Frame<R> {
|
||||
|
||||
private evaluateCallArguments(
|
||||
argNodes: ReadonlyArray<Expression | SpreadElement>,
|
||||
): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
): Effect.Effect<Array<Value>, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const args: Array<unknown> = []
|
||||
const args: Array<Value> = []
|
||||
for (const argNode of argNodes) {
|
||||
if (argNode.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(argNode.argument)
|
||||
@@ -1646,20 +1653,20 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
|
||||
invokeFunction(fn: Fn, args: Array<unknown>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
invokeFunction(fn: Fn, args: Array<Value>, node?: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.flatMap(CallSite, (site) => {
|
||||
const depth = Math.max(self.depth, site.depth) + 1
|
||||
if (depth > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
|
||||
const invocation = new Frame(this.ctx, new ScopeStack([...fn.capturedScopes, new Map()]), depth)
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
|
||||
const paramScope = invocation.scopes.current()
|
||||
for (const parameter of fn.parameters) {
|
||||
for (const name of collectPatternNames(parameter)) {
|
||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||
}
|
||||
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
|
||||
const paramScope = invocation.scopes.current()
|
||||
for (const parameter of fn.parameters) {
|
||||
for (const name of collectPatternNames(parameter)) {
|
||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||
}
|
||||
}
|
||||
const bind = Effect.gen(function* () {
|
||||
for (const [index, parameter] of fn.parameters.entries()) {
|
||||
if (parameter.type === "RestElement") {
|
||||
yield* invocation.declarePattern(
|
||||
@@ -1673,7 +1680,8 @@ class Frame<R> {
|
||||
}
|
||||
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
|
||||
}
|
||||
|
||||
})
|
||||
const body = Effect.gen(function* () {
|
||||
if (fn.body.type === "BlockStatement") {
|
||||
invocation.scopes.push()
|
||||
invocation.hoistVars(fn.body.body, paramScope)
|
||||
@@ -1683,7 +1691,9 @@ class Frame<R> {
|
||||
|
||||
return yield* invocation.evaluateExpression(fn.body)
|
||||
})
|
||||
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
|
||||
// Generators bind parameters at the call and defer only the body to the first `next()`, as in JS.
|
||||
if (fn.generator) return Effect.map(bind, () => this.createGenerator(invocation, body, fn))
|
||||
const run = Effect.andThen(bind, body)
|
||||
if (!fn.async) return run
|
||||
return this.ctx.pending.createWithSelf((self) =>
|
||||
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.ctx, value, self)),
|
||||
@@ -1691,18 +1701,15 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private createGenerator(
|
||||
invocation: Frame<R>,
|
||||
run: Effect.Effect<unknown, unknown, R>,
|
||||
asynchronous: boolean,
|
||||
): GeneratorObj {
|
||||
private createGenerator(invocation: Frame<R>, run: Effect.Effect<Value, unknown, R>, fn: Fn): GeneratorObj {
|
||||
const asynchronous = fn.async
|
||||
const state: GeneratorState = { started: false, completed: false, draining: false, pending: [], pendingIndex: 0 }
|
||||
invocation.generatorState = state
|
||||
invocation.generatorAsync = asynchronous
|
||||
const builtins = this.ctx.builtins
|
||||
const result = (value: unknown, done: boolean) => record(builtins.Object, { value, done })
|
||||
const request = (kind: GeneratorRequestKind, value: unknown) => {
|
||||
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
|
||||
const result = (value: Value, done: boolean) => record(builtins.Object, { value, done })
|
||||
const request = (kind: GeneratorRequestKind, value: Value) => {
|
||||
const request = { kind, value, response: Deferred.makeUnsafe<Value, unknown>() }
|
||||
if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running."))
|
||||
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
|
||||
state.started = true
|
||||
@@ -1763,17 +1770,13 @@ class Frame<R> {
|
||||
}
|
||||
return Deferred.await(request.response)
|
||||
}
|
||||
const generator = new GeneratorObj(
|
||||
asynchronous ? builtins.AsyncGenerator : builtins.Generator,
|
||||
asynchronous,
|
||||
request,
|
||||
)
|
||||
return generator
|
||||
const proto = get(fn, "prototype")
|
||||
return new GeneratorObj(proto instanceof Obj ? proto : builtins.Generator, asynchronous, request)
|
||||
}
|
||||
|
||||
private completeGeneratorRequests(state: GeneratorState, asynchronous: boolean): Effect.Effect<void, never, R> {
|
||||
const self = this
|
||||
const result = (value: unknown, done: boolean) => record(self.ctx.builtins.Object, { value, done })
|
||||
const result = (value: Value, done: boolean) => record(self.ctx.builtins.Object, { value, done })
|
||||
return Effect.gen(function* () {
|
||||
while (true) {
|
||||
const pending = self.dequeueGeneratorRequest(state)
|
||||
@@ -1819,7 +1822,7 @@ class Frame<R> {
|
||||
return request
|
||||
}
|
||||
|
||||
private evaluateYieldExpression(node: YieldExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateYieldExpression(node: YieldExpression): Effect.Effect<Value, unknown, R> {
|
||||
const argument = node.argument
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -1834,7 +1837,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private suspendGenerator(value: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
const state = this.generatorState
|
||||
if (!state?.active) throw typeError("Generator has no active request.", node)
|
||||
Deferred.doneUnsafe(state.active.response, Exit.succeed(record(this.ctx.builtins.Object, { value, done: false })))
|
||||
@@ -1849,20 +1852,11 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private delegateYield(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
private delegateYield(value: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (
|
||||
value instanceof Arr ||
|
||||
typeof value === "string" ||
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
) {
|
||||
const cursor = yield* self.iterate(value, node)
|
||||
if (!cursor) throw typeError("Built-in iterator is unavailable.", node)
|
||||
const cursor = self.builtinCursor(value)
|
||||
if (cursor !== undefined) {
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return undefined
|
||||
@@ -1886,7 +1880,7 @@ class Frame<R> {
|
||||
const iterator = yield* self.customIterator(value, node, self.generatorAsync)
|
||||
if (!iterator) throw typeError("yield* requires a compatible iterable value.", node)
|
||||
let kind: GeneratorRequestKind = "next"
|
||||
let input: unknown = undefined
|
||||
let input: Value = undefined
|
||||
while (true) {
|
||||
const method = kind === "next" ? iterator.next : get(iterator.iterator, kind)
|
||||
if (method === undefined || method === null) {
|
||||
@@ -1906,7 +1900,7 @@ class Frame<R> {
|
||||
node,
|
||||
)
|
||||
const done = Boolean(get(result, "done"))
|
||||
const resultValue: unknown =
|
||||
const resultValue: Value =
|
||||
self.generatorAsync && !iterator.asynchronous
|
||||
? yield* self.awaitAsyncFromSyncValue(iterator, get(result, "value"), node, kind !== "return" && !done)
|
||||
: get(result, "value")
|
||||
@@ -1915,7 +1909,7 @@ class Frame<R> {
|
||||
return resultValue
|
||||
}
|
||||
|
||||
const resumed: Exit.Exit<unknown, unknown> = yield* Effect.exit(self.suspendGenerator(resultValue, node))
|
||||
const resumed: Exit.Exit<Value, unknown> = yield* Effect.exit(self.suspendGenerator(resultValue, node))
|
||||
if (Exit.isSuccess(resumed)) {
|
||||
kind = "next"
|
||||
input = resumed.value
|
||||
@@ -1952,11 +1946,11 @@ class Frame<R> {
|
||||
let key: PropertyKey
|
||||
|
||||
if (property.computed) {
|
||||
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
|
||||
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode))
|
||||
} else if (keyNode.type === "Identifier") {
|
||||
key = keyNode.name
|
||||
} else if (keyNode.type === "Literal") {
|
||||
key = self.toPropertyKey(keyNode.value, keyNode)
|
||||
key = self.toPropertyKey(literal(keyNode))
|
||||
} else {
|
||||
throw typeError("Unsupported object property key shape.", keyNode)
|
||||
}
|
||||
@@ -1975,7 +1969,7 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
private evaluateArrayExpression(node: ArrayExpression): Effect.Effect<Arr, unknown, R> {
|
||||
const values: Array<unknown> = []
|
||||
const values: Array<Value> = []
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
@@ -2030,13 +2024,13 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private evaluateConditionalExpression(node: ConditionalExpression): Effect.Effect<Value, unknown, R> {
|
||||
return Effect.flatMap(this.evaluateExpression(node.test), (test) =>
|
||||
this.evaluateExpression(test ? node.consequent : node.alternate),
|
||||
)
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
|
||||
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
|
||||
if (!compoundOperators.has(operator)) {
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
@@ -2045,7 +2039,7 @@ class Frame<R> {
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
): Effect.Effect<MemberReference | ToolReference | { value: unknown } | typeof OptionalShortCircuit, unknown, R> {
|
||||
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
|
||||
const objectNode = node.object
|
||||
const propertyNode = node.property
|
||||
if (objectNode.type === "Super") throw unsupportedSyntax(objectNode.type, objectNode)
|
||||
@@ -2057,10 +2051,10 @@ class Frame<R> {
|
||||
if ((objectValue === null || objectValue === undefined) && node.optional) return OptionalShortCircuit
|
||||
|
||||
const key = node.computed
|
||||
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
|
||||
: propertyNode.type === "Identifier"
|
||||
? propertyNode.name
|
||||
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
|
||||
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string") {
|
||||
@@ -2071,16 +2065,14 @@ class Frame<R> {
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Primitives read through their wrapper prototype without being boxed; strings own length and indexes.
|
||||
const builtins = self.ctx.builtins
|
||||
// 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] }
|
||||
return { target: builtins.String, key, receiver: objectValue }
|
||||
}
|
||||
if (typeof objectValue === "number") return { target: builtins.Number, key, receiver: objectValue }
|
||||
if (typeof objectValue === "boolean") return { target: builtins.Boolean, key, receiver: objectValue }
|
||||
const proto = primitivePrototype(self.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)
|
||||
@@ -2089,7 +2081,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private readReference(reference: MemberReference, node: MemberExpression): unknown {
|
||||
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)) {
|
||||
throw invalidData(
|
||||
@@ -2101,7 +2093,7 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
// Accessors throw without a location; the member or pattern that read them supplies it.
|
||||
private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: unknown = target): unknown {
|
||||
private readProperty(target: Obj, key: PropertyKey, node: AstNode, receiver: Value = target): Value {
|
||||
try {
|
||||
return get(target, key, receiver)
|
||||
} catch (error) {
|
||||
@@ -2109,7 +2101,7 @@ class Frame<R> {
|
||||
}
|
||||
}
|
||||
|
||||
private readMember(node: MemberExpression): Effect.Effect<unknown, unknown, R> {
|
||||
private readMember(node: MemberExpression): Effect.Effect<Value, unknown, R> {
|
||||
return Effect.map(this.getMemberReference(node), (reference) => {
|
||||
if (reference === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if (reference instanceof ToolReference) return reference
|
||||
@@ -2118,15 +2110,15 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private writeMember(node: MemberExpression, value: unknown): Effect.Effect<unknown, unknown, R> {
|
||||
private writeMember(node: MemberExpression, value: Value): Effect.Effect<Value, unknown, R> {
|
||||
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
|
||||
}
|
||||
|
||||
private evaluateDeleteExpression(argument: Expression): Effect.Effect<boolean, unknown, R> {
|
||||
const target = argument.type === "ChainExpression" ? argument.expression : argument
|
||||
if (target.type !== "MemberExpression") {
|
||||
throw typeError("Only data fields may be deleted.", argument)
|
||||
}
|
||||
if (target.type === "Identifier") throw typeError("Only data fields may be deleted.", argument)
|
||||
// `delete <non-reference>` evaluates the operand and is true, as in JS.
|
||||
if (target.type !== "MemberExpression") return Effect.map(this.evaluateExpression(target), () => true)
|
||||
return Effect.map(this.getMemberReference(target), (reference) => {
|
||||
if (reference === OptionalShortCircuit) return true
|
||||
if (reference instanceof ToolReference || "value" in reference || reference.receiver !== reference.target) {
|
||||
@@ -2140,8 +2132,8 @@ class Frame<R> {
|
||||
// Resolve side-effecting object and key expressions exactly once.
|
||||
private modifyMember(
|
||||
node: MemberExpression,
|
||||
compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
compute: (current: Value) => Effect.Effect<{ write: boolean; next: Value; result: Value }, unknown, R>,
|
||||
): Effect.Effect<Value, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
@@ -2161,7 +2153,7 @@ class Frame<R> {
|
||||
})
|
||||
}
|
||||
|
||||
private assignToReference(target: Obj, key: PropertyKey, next: unknown, node: AstNode): void {
|
||||
private assignToReference(target: Obj, key: PropertyKey, next: Value, node: AstNode): void {
|
||||
const written = (() => {
|
||||
try {
|
||||
rejectCircularInsertion(
|
||||
@@ -2179,12 +2171,9 @@ class Frame<R> {
|
||||
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
|
||||
}
|
||||
|
||||
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value
|
||||
}
|
||||
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
|
||||
|
||||
throw typeError("Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.", 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Effect } from "effect"
|
||||
import { define, hidden, Native, Arr, ErrorObj, Obj } from "./objects.js"
|
||||
import { define, hidden, Native, Arr, ErrorObj, Obj, type Value } from "./objects.js"
|
||||
|
||||
export const errorTypes = [
|
||||
"Error",
|
||||
@@ -35,6 +35,7 @@ const builtins = [
|
||||
"TextDecoder",
|
||||
"Promise",
|
||||
"Iterator",
|
||||
"IteratorHelper",
|
||||
"AsyncIterator",
|
||||
"Generator",
|
||||
"AsyncGenerator",
|
||||
@@ -52,6 +53,14 @@ export const createErrorValue = (prototype: Obj, message: string | undefined): E
|
||||
return value
|
||||
}
|
||||
|
||||
/** The prototype a primitive reads its methods from without being boxed; none for null, undefined, and symbols. */
|
||||
export const primitivePrototype = (builtins: Builtins, value: Value): Obj | undefined => {
|
||||
if (typeof value === "string") return builtins.String
|
||||
if (typeof value === "number") return builtins.Number
|
||||
if (typeof value === "boolean") return builtins.Boolean
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const createBuiltins = (): Builtins => {
|
||||
const object = new Obj(null)
|
||||
// Function.prototype is itself callable and returns undefined.
|
||||
@@ -87,6 +96,7 @@ export const createBuiltins = (): Builtins => {
|
||||
TextDecoder: plain(),
|
||||
Promise: plain(),
|
||||
Iterator: iterator,
|
||||
IteratorHelper: new Obj(iterator),
|
||||
AsyncIterator: asyncIterator,
|
||||
Generator: new Obj(iterator),
|
||||
AsyncGenerator: new Obj(asyncIterator),
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Node } from "acorn"
|
||||
import { Context } from "effect"
|
||||
import type { ErrorType } from "./intrinsics.js"
|
||||
import type { DiagnosticKind } from "../codemode.js"
|
||||
import type { ErrorObj } from "./objects.js"
|
||||
import type { ErrorObj, Value } from "./objects.js"
|
||||
|
||||
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
|
||||
export type AstNode = Node
|
||||
@@ -14,13 +14,13 @@ export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly de
|
||||
|
||||
export type Binding = {
|
||||
mutable: boolean
|
||||
value: unknown
|
||||
value: Value
|
||||
initialized?: boolean
|
||||
}
|
||||
|
||||
export type StatementResult =
|
||||
| { kind: "none" }
|
||||
| { kind: "return"; value: unknown }
|
||||
| { kind: "return"; value: Value }
|
||||
| { kind: "break"; label?: string }
|
||||
| { kind: "continue"; label?: string }
|
||||
|
||||
@@ -31,11 +31,11 @@ export const IteratorSymbol: unique symbol = Symbol("codemode.iterator")
|
||||
export const IteratorSymbols = [AsyncIteratorSymbol, IteratorSymbol] as const
|
||||
|
||||
export class Throw {
|
||||
constructor(readonly value: unknown) {}
|
||||
constructor(readonly value: Value) {}
|
||||
}
|
||||
|
||||
export class GeneratorReturn {
|
||||
constructor(readonly value: unknown) {}
|
||||
constructor(readonly value: Value) {}
|
||||
}
|
||||
|
||||
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||
@@ -83,12 +83,10 @@ export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null
|
||||
|
||||
// Acorn lines are 1-based and its columns are 0-based. Diagnostics use 1-based columns of the submitted source.
|
||||
export const sourceLocation = (node: AstNode): { readonly line: number; readonly column: number } => ({
|
||||
line: Math.max(1, (node.loc?.start.line ?? 2) - 1),
|
||||
column: Math.max(1, (node.loc?.start.column ?? 4) - 3),
|
||||
line: node.loc?.start.line ?? 1,
|
||||
column: (node.loc?.start.column ?? 0) + 1,
|
||||
})
|
||||
|
||||
export const formatLocation = (node?: AstNode): string => {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { Effect } from "effect"
|
||||
import type { Builtins } from "./intrinsics.js"
|
||||
import { typeError } from "./model.js"
|
||||
import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj } from "./objects.js"
|
||||
import { type Callable, define, frozen, hidden, Native, type NativeOptions, Obj, type Value } from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */
|
||||
export type Impl = (thisValue: unknown, args: Array<unknown>) => unknown
|
||||
/** A native function body: a value, a thrown `PendingThrow`, or an Effect of a value. */
|
||||
export type Impl = (thisValue: Value, args: Array<Value>) => Value | Effect.Effect<Value, unknown, unknown>
|
||||
|
||||
// The dispatch in `Frame.call` suspends every native call, so a synchronous throw here is a defect.
|
||||
const lift =
|
||||
<R>(impl: Impl) =>
|
||||
(thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
|
||||
(thisValue: Value, args: Array<Value>): Effect.Effect<Value, unknown, R> => {
|
||||
const result = impl(thisValue, args)
|
||||
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
|
||||
return Effect.isEffect(result) ? (result as Effect.Effect<Value, unknown, R>) : Effect.succeed(result)
|
||||
}
|
||||
|
||||
export const native = <R>(builtins: Builtins, options: NativeOptions<R>): Native<R> =>
|
||||
@@ -27,7 +27,7 @@ export const methods = (builtins: Builtins, target: Obj, table: ReadonlyArray<Me
|
||||
for (const [name, length, impl] of table) define(target, name, fn(builtins, name, length, impl), hidden)
|
||||
}
|
||||
|
||||
export const constants = (target: Obj, table: Record<string, unknown>): void => {
|
||||
export const constants = (target: Obj, table: Record<string, Value>): void => {
|
||||
for (const [name, value] of Object.entries(table)) define(target, name, value, frozen)
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export const prototypeFrom = (newTarget: Callable, fallback: Obj): Obj => {
|
||||
/** Narrows a method receiver to the built-in it belongs to, or throws the TypeError JS would. */
|
||||
export const receiver = <T extends Obj>(
|
||||
cls: abstract new (...args: never) => T,
|
||||
thisValue: unknown,
|
||||
thisValue: Value,
|
||||
method: string,
|
||||
): T => {
|
||||
if (thisValue instanceof cls) return thisValue
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { BlockStatement, Expression, Pattern } from "acorn"
|
||||
import type { Effect, Fiber } from "effect"
|
||||
import { Effect, type Fiber } from "effect"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import type { Builtins } from "./intrinsics.js"
|
||||
import { checkArrayLength } from "./limits.js"
|
||||
import {
|
||||
AsyncIteratorSymbol,
|
||||
@@ -16,12 +18,12 @@ export type Attributes = {
|
||||
readonly configurable: boolean
|
||||
}
|
||||
|
||||
export type Getter = (receiver: unknown) => unknown
|
||||
export type Setter = (receiver: unknown, value: unknown) => void
|
||||
export type Getter = (receiver: Value) => Value
|
||||
export type Setter = (receiver: Value, value: Value) => void
|
||||
|
||||
/** One own property: a data slot or a native accessor pair. */
|
||||
export type Slot =
|
||||
| { value: unknown; writable: boolean; enumerable: boolean; configurable: boolean }
|
||||
| { value: Value; writable: boolean; enumerable: boolean; configurable: boolean }
|
||||
| { get: Getter | undefined; set: Setter | undefined; enumerable: boolean; configurable: boolean }
|
||||
|
||||
/** Ordinary assignment: writable, enumerable, configurable. */
|
||||
@@ -33,33 +35,122 @@ export const readonly: Attributes = { writable: false, enumerable: false, config
|
||||
/** Constants such as `Math.PI` and a constructor's `prototype`. */
|
||||
export const frozen: Attributes = { writable: false, enumerable: false, configurable: false }
|
||||
|
||||
/** An object owned by the program: own properties plus a prototype link. */
|
||||
/**
|
||||
* An object owned by the program: own properties plus a prototype link. Subclasses answer, in one place, how a
|
||||
* built-in kind of object prints, coerces, iterates, and crosses to the host.
|
||||
*/
|
||||
export class Obj {
|
||||
readonly props = new Map<string | symbol, Slot>()
|
||||
constructor(public proto: Obj | null) {}
|
||||
|
||||
/** The class name `Object.prototype.toString` reports: `[object Map]`. */
|
||||
readonly tag: string = "Object"
|
||||
|
||||
/** How diagnostics refer to a value of this kind. */
|
||||
get describe(): string {
|
||||
if (this.tag === "Object") return "a data object"
|
||||
return `${/^[AEIO]/.test(this.tag) ? "an" : "a"} ${this.tag}`
|
||||
}
|
||||
|
||||
/** ToString without consulting program-defined methods. */
|
||||
toString(): string {
|
||||
return `[object ${this.tag}]`
|
||||
}
|
||||
|
||||
/** ToPrimitive without consulting program-defined methods: only a Date answers a number hint differently. */
|
||||
toPrimitive(hint: "default" | "number" | "string"): string | number {
|
||||
return this.toString()
|
||||
}
|
||||
|
||||
/** ToNumber without consulting program-defined methods. */
|
||||
toNumber(): number {
|
||||
return Number(this.toPrimitive("number"))
|
||||
}
|
||||
|
||||
/** How `console.log` shows the value; `item` formats a child with cycle and depth tracking. */
|
||||
inspect(item: (value: Value) => string): string {
|
||||
return `{${entries(this)
|
||||
.map(([key, value]) => `${JSON.stringify(key)}:${item(value)}`)
|
||||
.join(",")}}`
|
||||
}
|
||||
|
||||
/** A copy the host can hold; `item` converts a child. A `__proto__` key never reaches host code. */
|
||||
toHost(item: (value: Value) => unknown): unknown {
|
||||
return Object.fromEntries(
|
||||
entries(this)
|
||||
.filter(([key]) => key !== "__proto__")
|
||||
.map(([key, value]) => [key, item(value)]),
|
||||
)
|
||||
}
|
||||
|
||||
/** The built-in iteration `for...of` and spread use, when this kind of object has one. */
|
||||
iterator(builtins: Builtins): Iterator<Value, undefined> | undefined {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export class Arr extends Obj {
|
||||
override readonly tag = "Array"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly items: Array<unknown> = [],
|
||||
readonly items: Array<Value> = [],
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override get describe() {
|
||||
return "an array"
|
||||
}
|
||||
override toString() {
|
||||
return this.items.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
|
||||
}
|
||||
override inspect(item: (value: Value) => string) {
|
||||
return `[${this.items.map(item).join(",")}]`
|
||||
}
|
||||
override toHost(item: (value: Value) => unknown) {
|
||||
return this.items.map(item)
|
||||
}
|
||||
override iterator() {
|
||||
return this.items.values()
|
||||
}
|
||||
}
|
||||
|
||||
/** An object with the [[ErrorData]] slot: what `Error.prototype.toString` and the host boundary recognize as an error. */
|
||||
export class ErrorObj extends Obj {
|
||||
override readonly tag = "Error"
|
||||
/** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */
|
||||
host?: PendingThrow
|
||||
/** Error.prototype.toString: "name: message", or just one when the other is empty. */
|
||||
override toString() {
|
||||
const name = get(this, "name")
|
||||
const message = get(this, "message")
|
||||
const shownName = typeof name === "string" ? name : "Error"
|
||||
const shownMessage = typeof message === "string" ? message : ""
|
||||
if (shownMessage === "") return shownName
|
||||
if (shownName === "") return shownMessage
|
||||
return `${shownName}: ${shownMessage}`
|
||||
}
|
||||
override inspect() {
|
||||
return this.toString()
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Callable extends Obj {
|
||||
/** Interpreter machinery a program can hold but never inspect, serialize, or hand to the host. */
|
||||
export abstract class Opaque extends Obj {
|
||||
override inspect() {
|
||||
return "[opaque reference]"
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Callable extends Opaque {
|
||||
override readonly tag = "Function"
|
||||
constructor(proto: Obj, name: string, length: number) {
|
||||
super(proto)
|
||||
define(this, "length", length, readonly)
|
||||
define(this, "name", name, readonly)
|
||||
}
|
||||
override get describe() {
|
||||
return "a function"
|
||||
}
|
||||
}
|
||||
|
||||
export class Fn extends Callable {
|
||||
@@ -68,7 +159,7 @@ export class Fn extends Callable {
|
||||
name: string,
|
||||
readonly parameters: ReadonlyArray<Pattern>,
|
||||
readonly body: BlockStatement | Expression,
|
||||
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
||||
readonly capturedScopes: Array<Map<string, Binding>>,
|
||||
readonly async: boolean,
|
||||
readonly generator: boolean,
|
||||
) {
|
||||
@@ -77,8 +168,8 @@ export class Fn extends Callable {
|
||||
}
|
||||
}
|
||||
|
||||
export type NativeCall<R> = (thisValue: unknown, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
export type NativeConstruct<R> = (args: Array<unknown>, newTarget: Callable) => Effect.Effect<unknown, unknown, R>
|
||||
export type NativeCall<R> = (thisValue: Value, args: Array<Value>) => Effect.Effect<Value, unknown, R>
|
||||
export type NativeConstruct<R> = (args: Array<Value>, newTarget: Callable) => Effect.Effect<Value, unknown, R>
|
||||
|
||||
export type NativeOptions<R> = {
|
||||
readonly name: string
|
||||
@@ -103,79 +194,182 @@ export class Native<R = never> extends Callable {
|
||||
}
|
||||
}
|
||||
|
||||
export class PromiseObj extends Obj {
|
||||
export class PromiseObj extends Opaque {
|
||||
override readonly tag = "Promise"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly fiber: Fiber.Fiber<unknown, unknown>,
|
||||
readonly fiber: Fiber.Fiber<Value, unknown>,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override get describe() {
|
||||
return "an un-awaited Promise"
|
||||
}
|
||||
override inspect() {
|
||||
return "[Promise (await it to get its value)]"
|
||||
}
|
||||
}
|
||||
|
||||
export class GeneratorObj extends Obj {
|
||||
export class GeneratorObj extends Opaque {
|
||||
override readonly tag = "Generator"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly asynchronous: boolean,
|
||||
readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect<unknown, unknown, unknown>,
|
||||
readonly request: (kind: GeneratorRequestKind, value: Value) => Effect.Effect<Value, unknown, unknown>,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override get describe() {
|
||||
return "a generator"
|
||||
}
|
||||
}
|
||||
|
||||
/** A built-in collection iterator: live over the host collection, yielding program values. */
|
||||
export class IteratorObj extends Obj {
|
||||
/** One pull from an iterator, as `for...of` sees it. */
|
||||
export type Step = { readonly done: boolean; readonly value: Value }
|
||||
|
||||
/** How the interpreter drives any iterator: pull the next step, or close it early. */
|
||||
export type Cursor<R = unknown> = {
|
||||
readonly next: Effect.Effect<Step, unknown, R>
|
||||
readonly close: Effect.Effect<void, unknown, R>
|
||||
}
|
||||
|
||||
/** A cursor over a host iterator; there is nothing to close. */
|
||||
export const hostCursor = (iterator: Iterator<Value, undefined>): Cursor<never> => ({
|
||||
next: Effect.sync(() => {
|
||||
const step = iterator.next()
|
||||
return { done: Boolean(step.done), value: step.value }
|
||||
}),
|
||||
close: Effect.void,
|
||||
})
|
||||
|
||||
/** A built-in iterator: a live cursor over a host collection or an iterator helper, yielding program values. */
|
||||
export class IteratorObj extends Opaque {
|
||||
override readonly tag = "Iterator"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly iterator: IteratorObject<unknown>,
|
||||
readonly cursor: Cursor,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override get describe() {
|
||||
return "an iterator"
|
||||
}
|
||||
}
|
||||
|
||||
export class DateObj extends Obj {
|
||||
/** A built-in iterator over a host iterator, e.g. `array.values()`. */
|
||||
export const hostIterator = (builtins: Builtins, iterator: Iterator<Value, undefined>): IteratorObj =>
|
||||
new IteratorObj(builtins.Iterator, hostCursor(iterator))
|
||||
|
||||
/** A built-in object around a host value: data-like, so it prints as itself and crosses to extensions as a copy. */
|
||||
export abstract class Wrapper extends Obj {
|
||||
override inspect(item: (value: Value) => string) {
|
||||
return this.toString()
|
||||
}
|
||||
}
|
||||
|
||||
export class DateObj extends Wrapper {
|
||||
override readonly tag = "Date"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
public time: number,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override toString() {
|
||||
return Number.isFinite(this.time) ? new Date(this.time).toISOString() : "Invalid Date"
|
||||
}
|
||||
override toPrimitive(hint: "default" | "number" | "string") {
|
||||
return hint === "number" ? this.time : this.toString()
|
||||
}
|
||||
override toHost() {
|
||||
return new Date(this.time)
|
||||
}
|
||||
}
|
||||
|
||||
export class RegExpObj extends Obj {
|
||||
export class RegExpObj extends Wrapper {
|
||||
override readonly tag = "RegExp"
|
||||
readonly regex: RegExp
|
||||
constructor(proto: Obj, pattern: string, flags: string) {
|
||||
super(proto)
|
||||
this.regex = new RegExp(pattern, flags)
|
||||
}
|
||||
override toString() {
|
||||
return `/${this.regex.source}/${this.regex.flags}`
|
||||
}
|
||||
override toHost() {
|
||||
return new RegExp(this.regex.source, this.regex.flags)
|
||||
}
|
||||
}
|
||||
|
||||
export class MapObj extends Obj {
|
||||
readonly map = new Map<unknown, unknown>()
|
||||
export class MapObj extends Wrapper {
|
||||
override readonly tag = "Map"
|
||||
readonly map = new Map<Value, Value>()
|
||||
override inspect(item: (value: Value) => string) {
|
||||
return `Map(${this.map.size}) [${[...this.map].map(([key, value]) => `[${item(key)},${item(value)}]`).join(",")}]`
|
||||
}
|
||||
override toHost(item: (value: Value) => unknown) {
|
||||
return new Map([...this.map].map(([key, value]) => [item(key), item(value)]))
|
||||
}
|
||||
override iterator(builtins: Builtins) {
|
||||
return this.map.entries().map((entry) => new Arr(builtins.Array, entry))
|
||||
}
|
||||
}
|
||||
|
||||
export class SetObj extends Obj {
|
||||
readonly set = new Set<unknown>()
|
||||
export class SetObj extends Wrapper {
|
||||
override readonly tag = "Set"
|
||||
readonly set = new Set<Value>()
|
||||
override inspect(item: (value: Value) => string) {
|
||||
return `Set(${this.set.size}) [${[...this.set].map(item).join(",")}]`
|
||||
}
|
||||
override toHost(item: (value: Value) => unknown) {
|
||||
return new Set([...this.set].map(item))
|
||||
}
|
||||
override iterator() {
|
||||
return this.set.values()
|
||||
}
|
||||
}
|
||||
|
||||
export class URLSearchParamsObj extends Obj {
|
||||
export class URLSearchParamsObj extends Wrapper {
|
||||
override readonly tag = "URLSearchParams"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly params: URLSearchParams,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override toString() {
|
||||
return this.params.toString()
|
||||
}
|
||||
override toHost() {
|
||||
return new URLSearchParams(this.params)
|
||||
}
|
||||
override iterator(builtins: Builtins) {
|
||||
return this.params.entries().map((entry) => new Arr(builtins.Array, entry))
|
||||
}
|
||||
}
|
||||
|
||||
export class HeadersObj extends Obj {
|
||||
export class HeadersObj extends Wrapper {
|
||||
override readonly tag = "Headers"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly headers: Headers,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override inspect() {
|
||||
return `Headers ${JSON.stringify(Object.fromEntries(this.headers))}`
|
||||
}
|
||||
override toHost() {
|
||||
return new Headers(this.headers)
|
||||
}
|
||||
override iterator(builtins: Builtins) {
|
||||
// Bun's Headers typings lack the iterator helpers, so the host iterator is lifted first.
|
||||
return Iterator.from(this.headers.entries()).map((entry) => new Arr(builtins.Array, entry))
|
||||
}
|
||||
}
|
||||
|
||||
export class URLObj extends Obj {
|
||||
export class URLObj extends Wrapper {
|
||||
override readonly tag = "URL"
|
||||
readonly searchParams: URLSearchParamsObj
|
||||
constructor(
|
||||
proto: Obj,
|
||||
@@ -185,30 +379,58 @@ export class URLObj extends Obj {
|
||||
super(proto)
|
||||
this.searchParams = new URLSearchParamsObj(searchParamsProto, url.searchParams)
|
||||
}
|
||||
override toString() {
|
||||
return this.url.href
|
||||
}
|
||||
override toHost() {
|
||||
return new URL(this.url.href)
|
||||
}
|
||||
}
|
||||
|
||||
/** A `Uint8Array`: the host array does the byte clamping and ignores out-of-range writes, as JS does. */
|
||||
export class Bytes extends Obj {
|
||||
export class Bytes extends Wrapper {
|
||||
override readonly tag = "Uint8Array"
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly bytes: Uint8Array,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
override toString() {
|
||||
return this.bytes.join(",")
|
||||
}
|
||||
override inspect() {
|
||||
return `Uint8Array(${this.bytes.length}) [${this.bytes.join(",")}]`
|
||||
}
|
||||
override toHost() {
|
||||
return new Uint8Array(this.bytes)
|
||||
}
|
||||
override iterator() {
|
||||
return this.bytes.values()
|
||||
}
|
||||
}
|
||||
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
|
||||
value instanceof DateObj ||
|
||||
value instanceof RegExpObj ||
|
||||
value instanceof MapObj ||
|
||||
value instanceof SetObj ||
|
||||
value instanceof URLObj ||
|
||||
value instanceof URLSearchParamsObj ||
|
||||
value instanceof HeadersObj ||
|
||||
value instanceof Bytes
|
||||
/** Every value a program can hold. Host values never appear here; they are copied in at the boundaries. */
|
||||
export type Value = string | number | boolean | null | undefined | symbol | Obj | ToolReference
|
||||
|
||||
/** ToString without consulting program-defined methods. */
|
||||
export const coerceToString = (value: Value): string => (value instanceof Obj ? value.toString() : String(value))
|
||||
|
||||
/** ToNumber without consulting program-defined methods; tool references are not numbers. */
|
||||
export const coerceToNumber = (value: Value): number => {
|
||||
if (value instanceof Obj) return value.toNumber()
|
||||
return value instanceof ToolReference ? Number.NaN : Number(value)
|
||||
}
|
||||
|
||||
/** ToIntegerOrInfinity: NaN is 0, fractions truncate. */
|
||||
export const coerceToInteger = (value: Value): number => {
|
||||
const number = coerceToNumber(value)
|
||||
return Number.isNaN(number) ? 0 : Math.trunc(number)
|
||||
}
|
||||
|
||||
/** Values that cannot cross the data boundary: opaque machinery and host-backed wrappers. */
|
||||
export const isRuntimeReference = (value: Value): boolean =>
|
||||
value instanceof Opaque || value instanceof Wrapper || value instanceof ToolReference
|
||||
|
||||
const MAX_ARRAY_INDEX = 4_294_967_295
|
||||
|
||||
@@ -226,7 +448,7 @@ type Indexed = Arr | Bytes
|
||||
|
||||
const isIndexed = (target: Obj): target is Indexed => target instanceof Arr || target instanceof Bytes
|
||||
|
||||
const elements = (target: Indexed): Array<unknown> | Uint8Array => (target instanceof Arr ? target.items : target.bytes)
|
||||
const elements = (target: Indexed): Array<Value> | Uint8Array => (target instanceof Arr ? target.items : target.bytes)
|
||||
|
||||
const index = (target: Obj, key: string | symbol): number | undefined =>
|
||||
isIndexed(target) && typeof key === "string" ? parseArrayIndex(key) : undefined
|
||||
@@ -247,18 +469,18 @@ export const own = (target: Obj, key: PropertyKey): Slot | undefined => {
|
||||
return target.props.get(name)
|
||||
}
|
||||
|
||||
const read = (slot: Slot, receiver: unknown): unknown =>
|
||||
const read = (slot: Slot, receiver: Value): Value =>
|
||||
"value" in slot ? slot.value : slot.get === undefined ? undefined : slot.get(receiver)
|
||||
|
||||
export const hasOwn = (target: Obj, key: PropertyKey): boolean => own(target, key) !== undefined
|
||||
|
||||
export const getOwn = (target: Obj, key: PropertyKey): unknown => {
|
||||
export const getOwn = (target: Obj, key: PropertyKey): Value => {
|
||||
const slot = own(target, key)
|
||||
return slot === undefined ? undefined : read(slot, target)
|
||||
}
|
||||
|
||||
/** [[Get]]: walks the prototype chain; accessors see `receiver`, which is the primitive for wrapper prototypes. */
|
||||
export const get = (target: Obj, key: PropertyKey, receiver: unknown = target): unknown => {
|
||||
export const get = (target: Obj, key: PropertyKey, receiver: Value = target): Value => {
|
||||
for (let current: Obj | null = target; current !== null; current = current.proto) {
|
||||
const slot = own(current, key)
|
||||
if (slot !== undefined) return read(slot, receiver)
|
||||
@@ -273,14 +495,14 @@ export const has = (target: Obj, key: PropertyKey): boolean => {
|
||||
return false
|
||||
}
|
||||
|
||||
export const hasPrototype = (value: unknown, proto: Obj): boolean => {
|
||||
export const hasPrototype = (value: Value, proto: Obj): boolean => {
|
||||
for (let current = value instanceof Obj ? value.proto : null; current !== null; current = current.proto) {
|
||||
if (current === proto) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const writeElement = (target: Indexed, name: string | symbol, value: unknown): boolean | undefined => {
|
||||
const writeElement = (target: Indexed, name: string | symbol, value: Value): boolean | undefined => {
|
||||
const at = index(target, name)
|
||||
if (at !== undefined) {
|
||||
if (target instanceof Bytes) target.bytes[at] = typeof value === "number" ? value : Number(value)
|
||||
@@ -296,7 +518,7 @@ const writeElement = (target: Indexed, name: string | symbol, value: unknown): b
|
||||
}
|
||||
|
||||
/** [[Set]]: an inherited setter or read-only property decides before an own data property is created. */
|
||||
export const set = (target: Obj, key: PropertyKey, value: unknown): boolean => {
|
||||
export const set = (target: Obj, key: PropertyKey, value: Value): boolean => {
|
||||
const name = canonical(key)
|
||||
for (let current: Obj | null = target; current !== null; current = current.proto) {
|
||||
const slot = own(current, name)
|
||||
@@ -324,7 +546,7 @@ export const set = (target: Obj, key: PropertyKey, value: unknown): boolean => {
|
||||
}
|
||||
|
||||
/** [[DefineOwnProperty]] for a data property, ignoring the chain. */
|
||||
export const define = (target: Obj, key: PropertyKey, value: unknown, attrs: Attributes = data): void => {
|
||||
export const define = (target: Obj, key: PropertyKey, value: Value, attrs: Attributes = data): void => {
|
||||
const name = canonical(key)
|
||||
if (isIndexed(target) && writeElement(target, name, value) !== undefined) return
|
||||
target.props.set(name, { value, ...attrs })
|
||||
@@ -375,9 +597,9 @@ export const keys = (target: Obj): Array<string> =>
|
||||
ownKeys(target).filter((key): key is string => typeof key === "string" && enumerable(target, key))
|
||||
|
||||
/** Own enumerable string entries: `Object.entries` and serialization. */
|
||||
export const entries = (target: Obj): Array<[string, unknown]> => keys(target).map((key) => [key, getOwn(target, key)])
|
||||
export const entries = (target: Obj): Array<[string, Value]> => keys(target).map((key) => [key, getOwn(target, key)])
|
||||
|
||||
export const record = (proto: Obj, fields: Record<string, unknown>): Obj => {
|
||||
export const record = (proto: Obj, fields: Record<string, Value>): Obj => {
|
||||
const target = new Obj(proto)
|
||||
for (const [key, value] of Object.entries(fields)) define(target, key, value)
|
||||
return target
|
||||
|
||||
@@ -2,15 +2,15 @@ import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
|
||||
import type { Diagnostic } from "../codemode.js"
|
||||
import { MAX_PENDING_PROMISES } from "./limits.js"
|
||||
import { CallSite, Throw, rangeError, typeError } from "./model.js"
|
||||
import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record } from "./objects.js"
|
||||
import { Callable, define, get, hidden, Arr, Fn, Obj, PromiseObj, record, type Value } from "./objects.js"
|
||||
import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js"
|
||||
import { createAggregateErrorValue, locate, materialize, normalizeError } from "./errors.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { describeValue, typeofValue } from "./references.js"
|
||||
import { applyCollectionCallback, isSupportedCallback } from "./callback.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
|
||||
// A `resolve`/`reject` handed to an executor or thenable: calling it settles the capability.
|
||||
const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: unknown) => void) =>
|
||||
const capability = <R>(ctx: Interpreter<R>, name: string, settle: (value: Value) => void) =>
|
||||
fn(ctx.builtins, name, 1, (_, args) => {
|
||||
settle(args[0])
|
||||
return undefined
|
||||
@@ -31,7 +31,7 @@ export class Pending<R> {
|
||||
|
||||
// Resolution bodies need the promise's own identity to reject `resolve(promise)` self-resolution.
|
||||
createWithSelf(
|
||||
body: (self: { promise?: PromiseObj }) => Effect.Effect<unknown, unknown, R>,
|
||||
body: (self: { promise?: PromiseObj }) => Effect.Effect<Value, unknown, R>,
|
||||
): Effect.Effect<PromiseObj, never, R> {
|
||||
const self: { promise?: PromiseObj } = {}
|
||||
return Effect.map(this.create(body(self)), (promise) => {
|
||||
@@ -40,7 +40,7 @@ export class Pending<R> {
|
||||
})
|
||||
}
|
||||
|
||||
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<PromiseObj, never, R> {
|
||||
create(effect: Effect.Effect<Value, unknown, R>): Effect.Effect<PromiseObj, never, R> {
|
||||
return Effect.flatMap(CallSite, (site) => {
|
||||
if (this.active.size >= MAX_PENDING_PROMISES) {
|
||||
throw rangeError(
|
||||
@@ -79,7 +79,7 @@ export class Pending<R> {
|
||||
if (id !== undefined) this.failures.delete(id)
|
||||
}
|
||||
|
||||
await(promise: PromiseObj): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
await(promise: PromiseObj): Effect.Effect<Exit.Exit<Value, unknown>> {
|
||||
return Fiber.await(promise.fiber)
|
||||
}
|
||||
|
||||
@@ -105,9 +105,9 @@ export class Pending<R> {
|
||||
|
||||
export const resolvePromiseValue = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
value: unknown,
|
||||
value: Value,
|
||||
own?: { promise?: PromiseObj },
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
): Effect.Effect<Value, unknown, R> => {
|
||||
if (own?.promise !== undefined && value === own.promise) {
|
||||
return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself."))
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export const resolvePromiseValue = <R>(
|
||||
return Effect.gen(function* () {
|
||||
// Promise resolution invokes a thenable's method in a later job.
|
||||
yield* Effect.yieldNow
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
const deferred = Deferred.makeUnsafe<Value, unknown>()
|
||||
const resolve = capability(ctx, "resolve", (result) => Deferred.doneUnsafe(deferred, Exit.succeed(result)))
|
||||
const reject = capability(ctx, "reject", (reason) => Deferred.doneUnsafe(deferred, Exit.fail(new Throw(reason))))
|
||||
const executed = yield* Effect.exit(ctx.call(then, value, [resolve, reject]))
|
||||
@@ -131,29 +131,46 @@ export const resolvePromiseValue = <R>(
|
||||
})
|
||||
}
|
||||
|
||||
export const resolvePromise = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<PromiseObj, never, R> => {
|
||||
export const resolvePromise = <R>(ctx: Interpreter<R>, value: Value): Effect.Effect<PromiseObj, never, R> => {
|
||||
if (value instanceof PromiseObj) return Effect.succeed(value)
|
||||
return ctx.pending.createWithSelf((self) => resolvePromiseValue(ctx, value, self))
|
||||
}
|
||||
|
||||
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
|
||||
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject", "withResolvers", "try"] as const
|
||||
|
||||
const invokePromiseMethod = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
name: (typeof promiseStatics)[number],
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<unknown, unknown, R> => {
|
||||
args: Array<Value>,
|
||||
): Effect.Effect<Value, unknown, R> => {
|
||||
if (name === "resolve") {
|
||||
return resolvePromise(ctx, args[0])
|
||||
}
|
||||
if (name === "reject") {
|
||||
return ctx.pending.create(Effect.fail(new Throw(args[0])))
|
||||
}
|
||||
if (name === "withResolvers") {
|
||||
return Effect.map(promiseCapability(ctx), (made) =>
|
||||
record(ctx.builtins.Object, { promise: made.promise, resolve: made.resolve, reject: made.reject }),
|
||||
)
|
||||
}
|
||||
if (name === "try") {
|
||||
if (typeofValue(args[0]) !== "function") {
|
||||
throw typeError(`Promise.try expects a function, received ${describeValue(args[0])}.`)
|
||||
}
|
||||
return Effect.flatMap(Effect.exit(ctx.call(args[0], undefined, args.slice(1))), (called) => {
|
||||
if (Exit.isSuccess(called)) return resolvePromise(ctx, called.value)
|
||||
if (Cause.hasInterruptsOnly(called.cause)) return Effect.failCause(called.cause)
|
||||
return ctx.pending.create(Effect.fail(Cause.squash(called.cause)))
|
||||
})
|
||||
}
|
||||
|
||||
return ctx.pending.create(
|
||||
Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(args[0])
|
||||
if (cursor === undefined) throw typeError(`Promise.${name} expects an array or other synchronous iterable.`)
|
||||
if (cursor === undefined) {
|
||||
throw typeError(`Promise.${name} expects a synchronous iterable, received ${describeValue(args[0])}.`)
|
||||
}
|
||||
const items: Array<PromiseObj> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -175,7 +192,7 @@ const invokePromiseMethod = <R>(
|
||||
)
|
||||
}
|
||||
if (name === "allSettled") {
|
||||
const outcomes: Array<unknown> = []
|
||||
const outcomes: Array<Value> = []
|
||||
for (const item of items) {
|
||||
const exit = yield* ctx.pending.await(item)
|
||||
if (Exit.isSuccess(exit)) {
|
||||
@@ -223,8 +240,8 @@ const invokePromiseMethod = <R>(
|
||||
const instanceMethod = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
name: "then" | "catch" | "finally",
|
||||
thisValue: unknown,
|
||||
args: Array<unknown>,
|
||||
thisValue: Value,
|
||||
args: Array<Value>,
|
||||
): Effect.Effect<PromiseObj, unknown, R> => {
|
||||
const method = `Promise.prototype.${name}`
|
||||
const promise = receiver(PromiseObj, thisValue, method)
|
||||
@@ -237,23 +254,30 @@ const instanceMethod = <R>(
|
||||
return chainReaction(ctx, promise, onFulfilled, onRejected, method)
|
||||
}
|
||||
|
||||
const constructPromise = <R>(ctx: Interpreter<R>, executor: unknown): Effect.Effect<PromiseObj, unknown, R> => {
|
||||
if (!(executor instanceof Fn)) {
|
||||
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<unknown, unknown>()
|
||||
/** NewPromiseCapability: a pending promise with the resolve/reject callables that settle it exactly once. */
|
||||
const promiseCapability = <R>(ctx: Interpreter<R>) =>
|
||||
Effect.gen(function* () {
|
||||
const deferred = Deferred.makeUnsafe<Value, unknown>()
|
||||
const promise = yield* ctx.pending.createWithSelf((self) =>
|
||||
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(ctx, value, self)),
|
||||
)
|
||||
const resolve = capability(ctx, "resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
|
||||
const reject = capability(ctx, "reject", (value) => Deferred.doneUnsafe(deferred, Exit.fail(new Throw(value))))
|
||||
const executed = yield* Effect.exit(ctx.call(executor, undefined, [resolve, reject]))
|
||||
return { promise, resolve, reject, deferred }
|
||||
})
|
||||
|
||||
const constructPromise = <R>(ctx: Interpreter<R>, executor: Value): Effect.Effect<PromiseObj, unknown, R> => {
|
||||
if (!(executor instanceof Fn)) {
|
||||
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
|
||||
}
|
||||
return Effect.gen(function* () {
|
||||
const made = yield* promiseCapability(ctx)
|
||||
const executed = yield* Effect.exit(ctx.call(executor, undefined, [made.resolve, made.reject]))
|
||||
if (!Exit.isSuccess(executed)) {
|
||||
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
|
||||
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
|
||||
Deferred.doneUnsafe(made.deferred, Exit.fail(Cause.squash(executed.cause)))
|
||||
}
|
||||
return promise
|
||||
return made.promise
|
||||
})
|
||||
}
|
||||
|
||||
@@ -262,10 +286,10 @@ const settleAfterTurn = <A, E, R>(body: Effect.Effect<A, E, R>): Effect.Effect<A
|
||||
Effect.flatMap(Effect.exit(body), (exit) => Effect.andThen(Effect.yieldNow, exit))
|
||||
|
||||
class PromiseAnyFulfilled {
|
||||
constructor(readonly value: unknown) {}
|
||||
constructor(readonly value: Value) {}
|
||||
}
|
||||
|
||||
const reactionHandler = (value: unknown, method: string): Callable | undefined => {
|
||||
const reactionHandler = (value: Value, method: string): Callable | undefined => {
|
||||
if (isSupportedCallback(value)) return value
|
||||
if (typeofValue(value) === "function") {
|
||||
throw typeError(
|
||||
@@ -279,7 +303,7 @@ const reactionHandler = (value: unknown, method: string): Callable | undefined =
|
||||
const reactionExit = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
source: PromiseObj,
|
||||
): Effect.Effect<Exit.Exit<unknown, unknown>, unknown, R> =>
|
||||
): Effect.Effect<Exit.Exit<Value, unknown>, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* ctx.pending.await(source)
|
||||
if (!Exit.isSuccess(exit) && Cause.hasInterruptsOnly(exit.cause)) return yield* Effect.failCause(exit.cause)
|
||||
|
||||
@@ -1,47 +1,18 @@
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { invalidData } from "./model.js"
|
||||
import {
|
||||
Callable,
|
||||
getOwn,
|
||||
isWrapper,
|
||||
ownKeys,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
GeneratorObj,
|
||||
IteratorObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
RegExpObj,
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "./objects.js"
|
||||
import { Callable, getOwn, isRuntimeReference, Obj, Opaque, ownKeys, type Value } from "./objects.js"
|
||||
|
||||
/** Values that cannot cross the data boundary. */
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof IteratorObj ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof PromiseObj ||
|
||||
isWrapper(value)
|
||||
|
||||
function* childValues(value: object): Generator {
|
||||
if (!(value instanceof Obj)) return
|
||||
for (const key of ownKeys(value)) yield getOwn(value, key)
|
||||
}
|
||||
/** Interpreter machinery that is never data, unlike a Date or Map, which cross some boundaries as copies. */
|
||||
export const isOpaque = (value: Value): boolean => value instanceof Opaque || value instanceof ToolReference
|
||||
|
||||
// Depth-first search over a value tree. `match` stops the walk; `skip` prunes a subtree without matching it.
|
||||
const find = (
|
||||
value: unknown,
|
||||
match: (current: unknown) => boolean,
|
||||
skip: (current: unknown) => boolean,
|
||||
value: Value,
|
||||
match: (current: Value) => boolean,
|
||||
skip: (current: Value) => boolean,
|
||||
seen: Set<object>,
|
||||
): boolean => {
|
||||
const pending: Array<Iterator<unknown>> = [[value].values()]
|
||||
const pending: Array<Iterator<Value>> = [[value].values()]
|
||||
while (pending.length > 0) {
|
||||
const next = pending.at(-1)!.next()
|
||||
if (next.done) {
|
||||
@@ -50,25 +21,28 @@ const find = (
|
||||
}
|
||||
const current = next.value
|
||||
if (match(current)) return true
|
||||
if (current === null || typeof current !== "object" || skip(current) || seen.has(current)) continue
|
||||
if (!(current instanceof Obj) || skip(current) || seen.has(current)) continue
|
||||
seen.add(current)
|
||||
pending.push(childValues(current))
|
||||
pending.push(
|
||||
ownKeys(current)
|
||||
.map((key) => getOwn(current, key))
|
||||
.values(),
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const never = () => false
|
||||
|
||||
export const containsRuntimeReference = (value: unknown): boolean => find(value, isRuntimeReference, never, new Set())
|
||||
export const containsRuntimeReference = (value: Value): boolean => find(value, isRuntimeReference, never, new Set())
|
||||
|
||||
// Wrapper values are data here, not opaque interpreter references.
|
||||
export const containsOpaqueReference = (value: unknown): boolean =>
|
||||
find(value, (current) => !isWrapper(current) && isRuntimeReference(current), isWrapper, new Set())
|
||||
export const containsOpaqueReference = (value: Value): boolean =>
|
||||
find(value, isOpaque, (current) => isRuntimeReference(current) && !isOpaque(current), new Set())
|
||||
|
||||
// Reject cycles before mutation so later boundary walks remain safe.
|
||||
export const rejectCircularInsertion = (
|
||||
container: object,
|
||||
value: unknown,
|
||||
container: Obj,
|
||||
value: Value,
|
||||
label: string,
|
||||
seen = new Set<object>(),
|
||||
): void => {
|
||||
@@ -77,27 +51,14 @@ export const rejectCircularInsertion = (
|
||||
}
|
||||
}
|
||||
|
||||
export const describeValue = (value: unknown): string => {
|
||||
export const describeValue = (value: Value): string => {
|
||||
if (value === null || value === undefined) return String(value)
|
||||
if (value instanceof Arr) return "an array"
|
||||
if (value instanceof PromiseObj) return "an un-awaited Promise"
|
||||
if (value instanceof Obj) return value.describe
|
||||
if (value instanceof ToolReference) return "a tool reference"
|
||||
if (value instanceof DateObj) return "a Date"
|
||||
if (value instanceof RegExpObj) return "a RegExp"
|
||||
if (value instanceof MapObj) return "a Map"
|
||||
if (value instanceof SetObj) return "a Set"
|
||||
if (value instanceof URLObj) return "a URL"
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof HeadersObj) return "a Headers"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (value instanceof IteratorObj) return "an iterator"
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
if (typeof value === "object") return "a data object"
|
||||
return `a ${typeof value}`
|
||||
}
|
||||
|
||||
export const typeofValue = (value: unknown): string => {
|
||||
export const typeofValue = (value: Value): string => {
|
||||
if (value instanceof Callable) return "function"
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
return typeof value
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type AstNode, type Binding, referenceError, typeError } from "./model.js"
|
||||
import type { Value } from "./objects.js"
|
||||
|
||||
export class ScopeStack {
|
||||
private readonly scopes: Array<Map<string, Binding>>
|
||||
@@ -15,7 +16,7 @@ export class ScopeStack {
|
||||
scope.set(name, { mutable, value: undefined, initialized: false })
|
||||
}
|
||||
|
||||
initialize(name: string, value: unknown, node: AstNode): void {
|
||||
initialize(name: string, value: Value, node: AstNode): void {
|
||||
const binding = this.current().get(name)
|
||||
if (!binding || binding.initialized !== false) {
|
||||
throw typeError(`Identifier '${name}' has not been reserved for initialization.`, node)
|
||||
@@ -24,7 +25,7 @@ export class ScopeStack {
|
||||
binding.initialized = true
|
||||
}
|
||||
|
||||
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
||||
declare(name: string, value: Value, mutable: boolean, node: AstNode): void {
|
||||
const scope = this.current()
|
||||
if (scope.has(name)) {
|
||||
throw typeError(`Identifier '${name}' has already been declared.`, node)
|
||||
@@ -32,7 +33,7 @@ export class ScopeStack {
|
||||
scope.set(name, { mutable, value, initialized: true })
|
||||
}
|
||||
|
||||
get(name: string, node: AstNode): unknown {
|
||||
get(name: string, node: AstNode): Value {
|
||||
const binding = this.resolve(name)
|
||||
|
||||
if (!binding) {
|
||||
@@ -46,7 +47,7 @@ export class ScopeStack {
|
||||
return binding.value
|
||||
}
|
||||
|
||||
set(name: string, value: unknown, node: AstNode): unknown {
|
||||
set(name: string, value: Value, node: AstNode): Value {
|
||||
const binding = this.resolve(name)
|
||||
|
||||
if (!binding) {
|
||||
|
||||
@@ -2,26 +2,36 @@ import { Effect } from "effect"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { checkArrayLength, checkStringLength, MAX_ARRAY_LENGTH } from "../interpreter/limits.js"
|
||||
import { invalidData, IteratorSymbol, rangeError, typeError } from "../interpreter/model.js"
|
||||
import { define, get, hidden, Arr, GeneratorObj, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import {
|
||||
define,
|
||||
get,
|
||||
hidden,
|
||||
Arr,
|
||||
GeneratorObj,
|
||||
hostIterator,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
coerceToInteger,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
const arrayLikeSource = (source: unknown): { readonly length: number; readonly source: Obj } => {
|
||||
if (source instanceof Obj && typeof get(source, "length") === "number") {
|
||||
const length = get(source, "length") as number
|
||||
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
|
||||
checkArrayLength(normalized)
|
||||
return { length: normalized, source }
|
||||
const arrayLikeSource = (source: Value): { readonly length: number; readonly source: Obj } => {
|
||||
// JS would treat a promise as an empty array-like; that would hide a missing `await`.
|
||||
if (source instanceof Obj && !(source instanceof PromiseObj)) {
|
||||
const length = Math.max(0, coerceToInteger(get(source, "length")))
|
||||
checkArrayLength(length)
|
||||
return { length, source }
|
||||
}
|
||||
throw invalidData(
|
||||
`Array.from expects an array, string, Map, Set, or array-like value, received ${describeValue(source)}.`,
|
||||
)
|
||||
throw invalidData(`Array.from expects an iterable or array-like value, received ${describeValue(source)}.`)
|
||||
}
|
||||
|
||||
const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
|
||||
const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Value, unknown, R> => {
|
||||
const source = args[0]
|
||||
const proto = ctx.builtins.Array
|
||||
const apply =
|
||||
@@ -30,22 +40,26 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<
|
||||
const cursor = yield* ctx.iterate(source)
|
||||
if (cursor === undefined) {
|
||||
if (source instanceof GeneratorObj) {
|
||||
throw typeError("Array.from expects a synchronous iterable or array-like value.")
|
||||
throw typeError(
|
||||
`Array.from expects a synchronous iterable or array-like value, received ${describeValue(source)}.`,
|
||||
)
|
||||
}
|
||||
const arrayLike = arrayLikeSource(source)
|
||||
const values: Array<unknown> = []
|
||||
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]))
|
||||
}
|
||||
return new Arr(proto, values)
|
||||
}
|
||||
const values: Array<unknown> = []
|
||||
const values: Array<Value> = []
|
||||
let index = 0
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return new Arr(proto, values)
|
||||
values.push(apply === undefined ? step.value : yield* preserveConsumerError(cursor, apply([step.value, index])))
|
||||
values.push(
|
||||
apply === undefined ? step.value : yield* preserveConsumerError(cursor.close, apply([step.value, index])),
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
})
|
||||
@@ -53,21 +67,21 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<
|
||||
|
||||
export const sortArray = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
target: Array<unknown>,
|
||||
comparator: unknown,
|
||||
target: Array<Value>,
|
||||
comparator: Value,
|
||||
name: string,
|
||||
): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
): Effect.Effect<Array<Value>, unknown, R> => {
|
||||
if (comparator === undefined) {
|
||||
return Effect.sync(() => [...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b))))
|
||||
}
|
||||
const apply = applyCollectionCallback(ctx, comparator, name)
|
||||
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
|
||||
const mergeSort = (items: Array<Value>): Effect.Effect<Array<Value>, unknown, R> => {
|
||||
if (items.length <= 1) return Effect.succeed(items)
|
||||
const midpoint = Math.floor(items.length / 2)
|
||||
return Effect.gen(function* () {
|
||||
const left = yield* mergeSort(items.slice(0, midpoint))
|
||||
const right = yield* mergeSort(items.slice(midpoint))
|
||||
const merged: Array<unknown> = []
|
||||
const merged: Array<Value> = []
|
||||
let leftIndex = 0
|
||||
let rightIndex = 0
|
||||
while (leftIndex < left.length && rightIndex < right.length) {
|
||||
@@ -88,8 +102,8 @@ export const sortArray = <R>(
|
||||
export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.Array
|
||||
const wrap = (items: Array<unknown>) => new Arr(proto, items)
|
||||
const construct = (args: Array<unknown>, into: Obj): Arr => {
|
||||
const wrap = (items: Array<Value>) => new Arr(proto, items)
|
||||
const construct = (args: Array<Value>, into: Obj): Arr => {
|
||||
if (args.length !== 1) return new Arr(into, [...args])
|
||||
const first = args[0]
|
||||
if (typeof first !== "number") return new Arr(into, [first])
|
||||
@@ -109,24 +123,18 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["from", 1, (_, args) => arrayFrom(ctx, args)],
|
||||
])
|
||||
|
||||
const self = (thisValue: unknown, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`)
|
||||
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number") {
|
||||
throw typeError(`Array.${name} expects ${label} to be a number.`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
const self = (thisValue: Value, name: string) => receiver(Arr, thisValue, `Array.prototype.${name}`)
|
||||
const optNumber = (value: Value): number | undefined => (value === undefined ? undefined : coerceToInteger(value))
|
||||
// Callback methods fix the iteration length while reading existing elements live.
|
||||
const iterate = (
|
||||
name: string,
|
||||
length: number,
|
||||
body: (
|
||||
target: Array<unknown>,
|
||||
target: Array<Value>,
|
||||
receiver: Arr,
|
||||
apply: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
args: Array<unknown>,
|
||||
) => Effect.Effect<unknown, unknown, R>,
|
||||
apply: (args: Array<Value>) => Effect.Effect<Value, unknown, R>,
|
||||
args: Array<Value>,
|
||||
) => Effect.Effect<Value, unknown, R>,
|
||||
): Method => [
|
||||
name,
|
||||
length,
|
||||
@@ -141,13 +149,9 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"join",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "join").items
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw typeError("Array.join expects zero arguments or one string separator.")
|
||||
}
|
||||
const joined = target
|
||||
.map((item) => coerceToString(item ?? ""))
|
||||
.join(args.length === 0 ? "," : (args[0] as string))
|
||||
const joined = self(thisValue, "join")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(args[0] === undefined ? "," : coerceToString(args[0]))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
},
|
||||
@@ -164,40 +168,23 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"includes",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "includes").items
|
||||
if (args.length === 0 || args.length > 2) {
|
||||
throw typeError("Array.includes expects a value and optional start index.")
|
||||
}
|
||||
return target.includes(args[0], optNumber("includes", args[1], "start index"))
|
||||
return self(thisValue, "includes").items.includes(args[0], optNumber(args[1]))
|
||||
},
|
||||
],
|
||||
[
|
||||
"indexOf",
|
||||
1,
|
||||
(thisValue, args) =>
|
||||
self(thisValue, "indexOf").items.indexOf(args[0], optNumber("indexOf", args[1], "start index")),
|
||||
],
|
||||
["indexOf", 1, (thisValue, args) => self(thisValue, "indexOf").items.indexOf(args[0], optNumber(args[1]))],
|
||||
[
|
||||
"lastIndexOf",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "lastIndexOf").items
|
||||
return args[1] === undefined
|
||||
? target.lastIndexOf(args[0])
|
||||
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index"))
|
||||
return args[1] === undefined ? target.lastIndexOf(args[0]) : target.lastIndexOf(args[0], optNumber(args[1]))
|
||||
},
|
||||
],
|
||||
["at", 1, (thisValue, args) => self(thisValue, "at").items.at(optNumber("at", args[0], "index") ?? 0)],
|
||||
["at", 1, (thisValue, args) => self(thisValue, "at").items.at(optNumber(args[0]) ?? 0)],
|
||||
[
|
||||
"slice",
|
||||
2,
|
||||
(thisValue, args) =>
|
||||
wrap(
|
||||
self(thisValue, "slice").items.slice(
|
||||
optNumber("slice", args[0], "start"),
|
||||
optNumber("slice", args[1], "end"),
|
||||
),
|
||||
),
|
||||
(thisValue, args) => wrap(self(thisValue, "slice").items.slice(optNumber(args[0]), optNumber(args[1]))),
|
||||
],
|
||||
[
|
||||
"concat",
|
||||
@@ -214,9 +201,9 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"flat",
|
||||
0,
|
||||
(thisValue, args) => {
|
||||
const flatten = (items: Array<unknown>, depth: number): Array<unknown> =>
|
||||
const flatten = (items: Array<Value>, depth: number): Array<Value> =>
|
||||
items.flatMap((item) => (item instanceof Arr && depth > 0 ? flatten(item.items, depth - 1) : [item]))
|
||||
const flattened = flatten(self(thisValue, "flat").items, optNumber("flat", args[0], "depth") ?? 1)
|
||||
const flattened = flatten(self(thisValue, "flat").items, optNumber(args[0]) ?? 1)
|
||||
checkArrayLength(flattened.length)
|
||||
return wrap(flattened)
|
||||
},
|
||||
@@ -262,7 +249,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "with").items
|
||||
const index = optNumber("with", args[0], "index") ?? 0
|
||||
const index = optNumber(args[0]) ?? 0
|
||||
const resolved = index < 0 ? target.length + index : index
|
||||
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.")
|
||||
const copied = [...target]
|
||||
@@ -297,9 +284,9 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "splice")
|
||||
if (args.length === 0) return wrap(target.items.splice(0, 0))
|
||||
const start = optNumber("splice", args[0], "start") ?? 0
|
||||
const start = optNumber(args[0]) ?? 0
|
||||
if (args.length === 1) return wrap(target.items.splice(start))
|
||||
const deleteCount = optNumber("splice", args[1], "delete count") ?? 0
|
||||
const deleteCount = optNumber(args[1]) ?? 0
|
||||
const inserted = args.slice(2)
|
||||
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
|
||||
return wrap(target.items.splice(start, deleteCount, ...inserted))
|
||||
@@ -311,9 +298,9 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
(thisValue, args) => {
|
||||
const copied = [...self(thisValue, "toSpliced").items]
|
||||
if (args.length === 0) return wrap(copied)
|
||||
const start = optNumber("toSpliced", args[0], "start") ?? 0
|
||||
const start = optNumber(args[0]) ?? 0
|
||||
if (args.length === 1) copied.splice(start)
|
||||
else copied.splice(start, optNumber("toSpliced", args[1], "delete count") ?? 0, ...args.slice(2))
|
||||
else copied.splice(start, optNumber(args[1]) ?? 0, ...args.slice(2))
|
||||
return wrap(copied)
|
||||
},
|
||||
],
|
||||
@@ -323,7 +310,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "fill")
|
||||
rejectCircularInsertion(target, args[0], "Array.fill result")
|
||||
target.items.fill(args[0], optNumber("fill", args[1], "start"), optNumber("fill", args[2], "end"))
|
||||
target.items.fill(args[0], optNumber(args[1]), optNumber(args[2]))
|
||||
return target
|
||||
},
|
||||
],
|
||||
@@ -332,22 +319,31 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "copyWithin")
|
||||
target.items.copyWithin(
|
||||
optNumber("copyWithin", args[0], "target index") ?? 0,
|
||||
optNumber("copyWithin", args[1], "start") ?? 0,
|
||||
optNumber("copyWithin", args[2], "end"),
|
||||
)
|
||||
target.items.copyWithin(optNumber(args[0]) ?? 0, optNumber(args[1]) ?? 0, optNumber(args[2]))
|
||||
return target
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").items.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").items.values())],
|
||||
[
|
||||
"toLocaleString",
|
||||
0,
|
||||
(thisValue) =>
|
||||
Effect.map(
|
||||
Effect.forEach(self(thisValue, "toLocaleString").items, (item) =>
|
||||
item === null || item === undefined
|
||||
? Effect.succeed("")
|
||||
: Effect.map(invoke(ctx, item, "toLocaleString", "Array.prototype.toLocaleString"), coerceToString),
|
||||
),
|
||||
(parts) => parts.join(","),
|
||||
),
|
||||
],
|
||||
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").items.keys())],
|
||||
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").items.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
hostIterator(
|
||||
builtins,
|
||||
self(thisValue, "entries")
|
||||
.items.entries()
|
||||
.map(([index, item]) => wrap([index, item])),
|
||||
@@ -356,7 +352,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
iterate("map", 1, (target, receiver, apply) =>
|
||||
Effect.gen(function* () {
|
||||
const length = target.length
|
||||
const values: Array<unknown> = []
|
||||
const values: Array<Value> = []
|
||||
values.length = length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
@@ -368,7 +364,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
iterate("flatMap", 1, (target, receiver, apply) =>
|
||||
Effect.gen(function* () {
|
||||
const length = target.length
|
||||
const values: Array<unknown> = []
|
||||
const values: Array<Value> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const mapped = yield* apply([target[index], index, receiver])
|
||||
@@ -381,7 +377,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
iterate("filter", 1, (target, receiver, apply) =>
|
||||
Effect.gen(function* () {
|
||||
const length = target.length
|
||||
const values: Array<unknown> = []
|
||||
const values: Array<Value> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const item = target[index]
|
||||
|
||||
@@ -2,13 +2,25 @@ import { Effect } from "effect"
|
||||
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
|
||||
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
|
||||
import { IteratorSymbol, rangeError, syntaxError, typeError } from "../interpreter/model.js"
|
||||
import { define, defineAccessor, get, hidden, Arr, Bytes, IteratorObj, Obj } from "../interpreter/objects.js"
|
||||
import {
|
||||
define,
|
||||
defineAccessor,
|
||||
get,
|
||||
hidden,
|
||||
Arr,
|
||||
Bytes,
|
||||
hostIterator,
|
||||
Obj,
|
||||
coerceToInteger,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
/** The bytes a Uint8Array, array, or other iterable of numbers describes; the host array clamps each value. */
|
||||
const collectBytes = <R>(ctx: Interpreter<R>, source: unknown, name: string): Effect.Effect<Uint8Array, unknown, R> => {
|
||||
const collectBytes = <R>(ctx: Interpreter<R>, source: Value, name: string): Effect.Effect<Uint8Array, unknown, R> => {
|
||||
if (source instanceof Bytes) return Effect.succeed(new Uint8Array(source.bytes))
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(source)
|
||||
@@ -27,7 +39,7 @@ const collectBytes = <R>(ctx: Interpreter<R>, source: unknown, name: string): Ef
|
||||
})
|
||||
}
|
||||
|
||||
const constructBytes = <R>(ctx: Interpreter<R>, args: Array<unknown>, proto: Obj) => {
|
||||
const constructBytes = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) => {
|
||||
const source = args[0]
|
||||
if (source !== null && typeof source === "object") {
|
||||
return Effect.map(collectBytes(ctx, source, "new Uint8Array(...)"), (bytes) => new Bytes(proto, bytes))
|
||||
@@ -48,7 +60,7 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
call: requiresNew("Uint8Array"),
|
||||
construct: (args, newTarget) => constructBytes(ctx, args, prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const decode = (name: string, args: Array<unknown>, from: (text: string) => Uint8Array) => {
|
||||
const decode = (name: string, args: Array<Value>, from: (text: string) => Uint8Array) => {
|
||||
if (typeof args[0] !== "string") throw typeError(`Uint8Array.${name} expects a string.`)
|
||||
try {
|
||||
return wrap(from(args[0]))
|
||||
@@ -63,45 +75,29 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["fromHex", 1, (_, args) => decode("fromHex", args, (text) => Uint8Array.fromHex(text))],
|
||||
])
|
||||
|
||||
const self = (thisValue: unknown, name: string) => receiver(Bytes, thisValue, `Uint8Array.prototype.${name}`)
|
||||
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number") throw typeError(`Uint8Array.${name} expects ${label} to be a number.`)
|
||||
return value
|
||||
}
|
||||
const wrapAll = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const self = (thisValue: Value, name: string) => receiver(Bytes, thisValue, `Uint8Array.prototype.${name}`)
|
||||
const optNumber = (value: Value): number | undefined => (value === undefined ? undefined : coerceToInteger(value))
|
||||
const wrapAll = (items: Array<Value>) => new Arr(builtins.Array, items)
|
||||
defineAccessor(proto, "length", (thisValue) => self(thisValue, "length").bytes.length)
|
||||
methods(builtins, proto, [
|
||||
["at", 1, (thisValue, args) => self(thisValue, "at").bytes.at(optNumber("at", args[0], "index") ?? 0)],
|
||||
["at", 1, (thisValue, args) => self(thisValue, "at").bytes.at(optNumber(args[0]) ?? 0)],
|
||||
[
|
||||
"slice",
|
||||
2,
|
||||
(thisValue, args) =>
|
||||
wrap(
|
||||
self(thisValue, "slice").bytes.slice(
|
||||
optNumber("slice", args[0], "start"),
|
||||
optNumber("slice", args[1], "end"),
|
||||
),
|
||||
),
|
||||
(thisValue, args) => wrap(self(thisValue, "slice").bytes.slice(optNumber(args[0]), optNumber(args[1]))),
|
||||
],
|
||||
// A view on the same bytes, as in JS: writes through one are visible through the other.
|
||||
[
|
||||
"subarray",
|
||||
2,
|
||||
(thisValue, args) =>
|
||||
wrap(
|
||||
self(thisValue, "subarray").bytes.subarray(
|
||||
optNumber("subarray", args[0], "start"),
|
||||
optNumber("subarray", args[1], "end"),
|
||||
),
|
||||
),
|
||||
(thisValue, args) => wrap(self(thisValue, "subarray").bytes.subarray(optNumber(args[0]), optNumber(args[1]))),
|
||||
],
|
||||
[
|
||||
"set",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "set")
|
||||
const offset = optNumber("set", args[1], "offset") ?? 0
|
||||
const offset = optNumber(args[1]) ?? 0
|
||||
return Effect.map(collectBytes(ctx, args[0], "Uint8Array.set"), (source) => {
|
||||
if (!Number.isInteger(offset) || offset < 0 || source.length + offset > target.bytes.length) {
|
||||
throw rangeError("Uint8Array.set: the source does not fit at that offset.")
|
||||
@@ -116,11 +112,7 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "fill")
|
||||
target.bytes.fill(
|
||||
coerceToNumber(args[0]),
|
||||
optNumber("fill", args[1], "start"),
|
||||
optNumber("fill", args[2], "end"),
|
||||
)
|
||||
target.bytes.fill(coerceToNumber(args[0]), optNumber(args[1]), optNumber(args[2]))
|
||||
return target
|
||||
},
|
||||
],
|
||||
@@ -136,8 +128,7 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
[
|
||||
"indexOf",
|
||||
1,
|
||||
(thisValue, args) =>
|
||||
self(thisValue, "indexOf").bytes.indexOf(coerceToNumber(args[0]), optNumber("indexOf", args[1], "start index")),
|
||||
(thisValue, args) => self(thisValue, "indexOf").bytes.indexOf(coerceToNumber(args[0]), optNumber(args[1])),
|
||||
],
|
||||
[
|
||||
"lastIndexOf",
|
||||
@@ -146,26 +137,19 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "lastIndexOf").bytes
|
||||
return args[1] === undefined
|
||||
? target.lastIndexOf(coerceToNumber(args[0]))
|
||||
: target.lastIndexOf(coerceToNumber(args[0]), optNumber("lastIndexOf", args[1], "start index"))
|
||||
: target.lastIndexOf(coerceToNumber(args[0]), optNumber(args[1]))
|
||||
},
|
||||
],
|
||||
[
|
||||
"includes",
|
||||
1,
|
||||
(thisValue, args) =>
|
||||
self(thisValue, "includes").bytes.includes(
|
||||
coerceToNumber(args[0]),
|
||||
optNumber("includes", args[1], "start index"),
|
||||
),
|
||||
(thisValue, args) => self(thisValue, "includes").bytes.includes(coerceToNumber(args[0]), optNumber(args[1])),
|
||||
],
|
||||
[
|
||||
"join",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw typeError("Uint8Array.join expects zero arguments or one string separator.")
|
||||
}
|
||||
const joined = self(thisValue, "join").bytes.join(args.length === 0 ? "," : (args[0] as string))
|
||||
const joined = self(thisValue, "join").bytes.join(args[0] === undefined ? "," : coerceToString(args[0]))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
},
|
||||
@@ -173,14 +157,14 @@ export const uint8ArrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["toString", 0, (thisValue) => self(thisValue, "toString").bytes.join(",")],
|
||||
["toBase64", 0, (thisValue) => self(thisValue, "toBase64").bytes.toBase64()],
|
||||
["toHex", 0, (thisValue) => self(thisValue, "toHex").bytes.toHex()],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").bytes.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").bytes.values())],
|
||||
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").bytes.keys())],
|
||||
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").bytes.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
hostIterator(
|
||||
builtins,
|
||||
self(thisValue, "entries")
|
||||
.bytes.entries()
|
||||
.map(([index, byte]) => wrapAll([index, byte])),
|
||||
@@ -222,8 +206,7 @@ const utf8Labels = new Set(["unicode-1-1-utf-8", "unicode11utf8", "unicode20utf8
|
||||
export const textDecoderGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.TextDecoder
|
||||
const self = (thisValue: unknown, name: string) =>
|
||||
receiver(TextDecoderObj, thisValue, `TextDecoder.prototype.${name}`)
|
||||
const self = (thisValue: Value, name: string) => receiver(TextDecoderObj, thisValue, `TextDecoder.prototype.${name}`)
|
||||
defineAccessor(proto, "encoding", (thisValue) => self(thisValue, "encoding").decoder.encoding)
|
||||
defineAccessor(proto, "fatal", (thisValue) => self(thisValue, "fatal").decoder.fatal)
|
||||
defineAccessor(proto, "ignoreBOM", (thisValue) => self(thisValue, "ignoreBOM").decoder.ignoreBOM)
|
||||
|
||||
@@ -7,15 +7,17 @@ import {
|
||||
get,
|
||||
getOwn,
|
||||
hidden,
|
||||
isWrapper,
|
||||
Arr,
|
||||
IteratorObj,
|
||||
coerceToString,
|
||||
hostCursor,
|
||||
hostIterator,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { describeValue, isOpaque } from "../interpreter/references.js"
|
||||
import {
|
||||
applyCollectionCallback,
|
||||
isSupportedCallback,
|
||||
@@ -25,9 +27,9 @@ import {
|
||||
} from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
const coerceGroupByPropertyKey = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<string, unknown, R> => {
|
||||
if (value instanceof PromiseObj) return Effect.succeed("[object Promise]")
|
||||
if (!isWrapper(value) && isRuntimeReference(value)) {
|
||||
const coerceGroupByPropertyKey = <R>(ctx: Interpreter<R>, value: Value): Effect.Effect<string, unknown, R> => {
|
||||
if (value instanceof PromiseObj) return Effect.succeed(coerceToString(value))
|
||||
if (isOpaque(value)) {
|
||||
throw invalidData(`Object.groupBy callback must return a data value, received ${describeValue(value)}.`)
|
||||
}
|
||||
return toPrimitiveString(ctx, value)
|
||||
@@ -54,7 +56,7 @@ export const groupBy = <R>(ctx: Interpreter<R>, namespace: "Map" | "Object") =>
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return result
|
||||
const item = step.value
|
||||
const key = yield* preserveConsumerError(cursor, apply([item, index]))
|
||||
const key = yield* preserveConsumerError(cursor.close, apply([item, index]))
|
||||
const group = result.map.get(key)
|
||||
if (group === undefined) result.map.set(key, new Arr(builtins.Array, [item]))
|
||||
else (group as Arr).items.push(item)
|
||||
@@ -70,7 +72,7 @@ export const groupBy = <R>(ctx: Interpreter<R>, namespace: "Map" | "Object") =>
|
||||
if (step.done) return result
|
||||
const item = step.value
|
||||
const key = yield* preserveConsumerError(
|
||||
cursor,
|
||||
cursor.close,
|
||||
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(ctx, value)),
|
||||
)
|
||||
const group = getOwn(result, key)
|
||||
@@ -81,19 +83,19 @@ export const groupBy = <R>(ctx: Interpreter<R>, namespace: "Map" | "Object") =>
|
||||
})
|
||||
})
|
||||
|
||||
const constructMap = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj) => {
|
||||
const constructMap = <R>(ctx: Interpreter<R>, init: Value, proto: Obj) => {
|
||||
const target = new MapObj(proto)
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new Map(...) expects an iterable of [key, value] pairs or no argument.")
|
||||
throw typeError(`new Map(...) expects an iterable of [key, value] pairs, received ${describeValue(init)}.`)
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return target
|
||||
yield* preserveConsumerError(
|
||||
cursor,
|
||||
cursor.close,
|
||||
Effect.sync(() => {
|
||||
if (!(step.value instanceof Obj)) {
|
||||
throw typeError("new Map(...) expects [key, value] pairs as entry objects.")
|
||||
@@ -105,13 +107,13 @@ const constructMap = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj) => {
|
||||
})
|
||||
}
|
||||
|
||||
const constructSet = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj) => {
|
||||
const constructSet = <R>(ctx: Interpreter<R>, init: Value, proto: Obj) => {
|
||||
const target = new SetObj(proto)
|
||||
if (init === undefined || init === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(init)
|
||||
if (cursor === undefined) {
|
||||
throw typeError("new Set(...) expects a synchronous iterable or no argument.")
|
||||
throw typeError(`new Set(...) expects a synchronous iterable, received ${describeValue(init)}.`)
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
@@ -130,8 +132,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
construct: (args, newTarget) => constructMap(ctx, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
define(map, "groupBy", groupBy(ctx, "Map"), hidden)
|
||||
const self = (thisValue: unknown, name: string) => receiver(MapObj, thisValue, `Map.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const self = (thisValue: Value, name: string) => receiver(MapObj, thisValue, `Map.prototype.${name}`)
|
||||
defineAccessor(proto, "size", (thisValue) => receiver(MapObj, thisValue, "Map.prototype.size").map.size)
|
||||
methods(builtins, proto, [
|
||||
["get", 1, (thisValue, args) => self(thisValue, "get").map.get(args[0])],
|
||||
@@ -145,6 +146,29 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return target
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsert",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsert").map
|
||||
if (!target.has(args[0])) target.set(args[0], args[1])
|
||||
return target.get(args[0])
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsertComputed",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsertComputed").map
|
||||
const apply = applyCollectionCallback(ctx, args[1], "Map.getOrInsertComputed")
|
||||
if (target.has(args[0])) return target.get(args[0])
|
||||
// The callback sees the stored key (-0 is +0) and its result wins over anything it inserted itself.
|
||||
return Effect.map(apply([args[0] === 0 ? 0 : args[0]]), (value) => {
|
||||
target.set(args[0], value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
|
||||
[
|
||||
"clear",
|
||||
@@ -154,19 +178,9 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").map.keys())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").map.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
self(thisValue, "entries")
|
||||
.map.entries()
|
||||
.map(([key, item]) => wrap([key, item])),
|
||||
),
|
||||
],
|
||||
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").map.keys())],
|
||||
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").map.values())],
|
||||
["entries", 0, (thisValue) => hostIterator(builtins, self(thisValue, "entries").iterator(builtins))],
|
||||
[
|
||||
"forEach",
|
||||
1,
|
||||
@@ -174,7 +188,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 Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
for (const [key, item] of target.map.entries()) yield* apply([item, key, target])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
@@ -186,26 +200,26 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
|
||||
type SetRecord<R> = {
|
||||
readonly size: number
|
||||
readonly has: (item: unknown) => Effect.Effect<boolean, unknown, R>
|
||||
readonly keys: () => Effect.Effect<Iterable<unknown>, unknown, R>
|
||||
readonly has: (item: Value) => Effect.Effect<boolean, unknown, R>
|
||||
readonly keys: () => Effect.Effect<Iterable<Value>, unknown, R>
|
||||
}
|
||||
|
||||
const loadSetRecord = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
source: unknown,
|
||||
source: Value,
|
||||
name: string,
|
||||
): Effect.Effect<SetRecord<R>, unknown, R> => {
|
||||
if (source instanceof SetObj) {
|
||||
return Effect.succeed({
|
||||
size: source.set.size,
|
||||
has: (item: unknown) => Effect.succeed(source.set.has(item)),
|
||||
has: (item: Value) => Effect.succeed(source.set.has(item)),
|
||||
keys: () => Effect.succeed(source.set.values()),
|
||||
})
|
||||
}
|
||||
if (source instanceof MapObj) {
|
||||
return Effect.succeed({
|
||||
size: source.map.size,
|
||||
has: (item: unknown) => Effect.succeed(source.map.has(item)),
|
||||
has: (item: Value) => Effect.succeed(source.map.has(item)),
|
||||
keys: () => Effect.succeed(source.map.keys()),
|
||||
})
|
||||
}
|
||||
@@ -224,12 +238,17 @@ const loadSetRecord = <R>(
|
||||
}
|
||||
return {
|
||||
size: Math.max(Math.trunc(size), 0),
|
||||
has: (item: unknown) => Effect.map(ctx.call(has, source, [item]), Boolean),
|
||||
has: (item: Value) => Effect.map(ctx.call(has, source, [item]), Boolean),
|
||||
keys: () =>
|
||||
Effect.flatMap(ctx.call(keys, source, []), (result): Effect.Effect<Iterable<unknown>> => {
|
||||
if (result instanceof IteratorObj) return Effect.succeed(result.iterator)
|
||||
if (result instanceof Arr) return Effect.succeed(result.items)
|
||||
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
|
||||
Effect.gen(function* () {
|
||||
const result = yield* ctx.call(keys, source, [])
|
||||
const cursor = result instanceof Arr ? hostCursor(result.items.values()) : ctx.iterateDirect(result)
|
||||
const items: Array<Value> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return items
|
||||
items.push(step.value)
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
@@ -239,8 +258,8 @@ const setOperation = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
target: SetObj,
|
||||
name: string,
|
||||
source: unknown,
|
||||
): Effect.Effect<unknown, unknown, R> =>
|
||||
source: Value,
|
||||
): Effect.Effect<Value, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const other = yield* loadSetRecord(ctx, source, name)
|
||||
const copy = () => {
|
||||
@@ -319,8 +338,8 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
call: requiresNew("Set"),
|
||||
construct: (args, newTarget) => constructSet(ctx, args[0], prototypeFrom(newTarget, proto)),
|
||||
})
|
||||
const self = (thisValue: unknown, name: string) => receiver(SetObj, thisValue, `Set.prototype.${name}`)
|
||||
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
|
||||
const self = (thisValue: Value, name: string) => receiver(SetObj, thisValue, `Set.prototype.${name}`)
|
||||
const wrap = (items: Array<Value>) => new Arr(builtins.Array, items)
|
||||
const operation = (name: string): Method => [
|
||||
name,
|
||||
1,
|
||||
@@ -347,14 +366,14 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
return undefined
|
||||
},
|
||||
],
|
||||
["keys", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "keys").set.values())],
|
||||
["values", 0, (thisValue) => new IteratorObj(builtins.Iterator, self(thisValue, "values").set.values())],
|
||||
["keys", 0, (thisValue) => hostIterator(builtins, self(thisValue, "keys").set.values())],
|
||||
["values", 0, (thisValue) => hostIterator(builtins, self(thisValue, "values").set.values())],
|
||||
[
|
||||
"entries",
|
||||
0,
|
||||
(thisValue) =>
|
||||
new IteratorObj(
|
||||
builtins.Iterator,
|
||||
hostIterator(
|
||||
builtins,
|
||||
self(thisValue, "entries")
|
||||
.set.values()
|
||||
.map((item) => wrap([item, item])),
|
||||
@@ -367,7 +386,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 Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
for (const item of target.set.values()) yield* apply([item, item, target])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
import { type Method, methods } from "../interpreter/native.js"
|
||||
import {
|
||||
entries,
|
||||
get,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
RegExpObj,
|
||||
SetObj,
|
||||
URLObj,
|
||||
URLSearchParamsObj,
|
||||
HeadersObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
|
||||
import { entries, get, Arr, Obj, type Value } from "../interpreter/objects.js"
|
||||
import { ToolReference } from "../tool-runtime.js"
|
||||
import { containsOpaqueReference } from "../interpreter/references.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
|
||||
const consoleMethods = ["log", "info", "debug", "warn", "error", "dir", "table"]
|
||||
|
||||
@@ -43,7 +29,7 @@ export const consoleGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
|
||||
const MAX_CONSOLE_DEPTH = 32
|
||||
|
||||
const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
|
||||
const formatConsoleMessage = (name: string, args: Array<Value>): string => {
|
||||
if (name === "dir") return args.length === 0 ? "undefined" : formatValue(args[0])
|
||||
if (name === "table") return formatConsoleTable(args[0], args[1])
|
||||
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
|
||||
@@ -51,60 +37,27 @@ const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
|
||||
}
|
||||
|
||||
/** One value as `console.log` shows it. */
|
||||
export const formatValue = (value: unknown): string => {
|
||||
export const formatValue = (value: Value): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (typeof value === "string") return value
|
||||
return formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): string => {
|
||||
const formatConsoleValue = (value: Value, seen: Set<object>, depth: number): string => {
|
||||
if (value === null || value === undefined) return "null"
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof PromiseObj) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof DateObj) return coerceToString(value)
|
||||
if (value instanceof RegExpObj) return coerceToString(value)
|
||||
if (value instanceof URLObj) return coerceToString(value)
|
||||
if (value instanceof URLSearchParamsObj) return coerceToString(value)
|
||||
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
|
||||
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
|
||||
if (!(value instanceof Obj)) return value instanceof ToolReference ? "[opaque reference]" : String(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof MapObj) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const items = Array.from(value.map.entries(), ([key, item]) => `[${formatItems([key, item], seen, depth + 1)}]`)
|
||||
return `Map(${value.map.size}) [${items.join(",")}]`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof SetObj) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) [${formatItems([...value.set.values()], seen, depth + 1)}]`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (isRuntimeReference(value)) return "[opaque reference]"
|
||||
seen.add(value)
|
||||
try {
|
||||
if (value instanceof Arr) return `[${formatItems(value.items, seen, depth + 1)}]`
|
||||
if (!(value instanceof Obj)) return "[object Object]"
|
||||
return `{${entries(value)
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${formatConsoleValue(item, seen, depth + 1)}`)
|
||||
.join(",")}}`
|
||||
return value.inspect((item) => formatConsoleValue(item, seen, depth + 1))
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
const formatItems = (items: Array<unknown>, seen: Set<object>, depth: number): string =>
|
||||
items.map((item) => formatConsoleValue(item, seen, depth)).join(",")
|
||||
|
||||
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
|
||||
const formatConsoleTable = (value: Value, columnsArgument: Value): string => {
|
||||
if (value === undefined) return "undefined"
|
||||
if (containsOpaqueReference(value)) return "[opaque reference]"
|
||||
const columns = columnsArgument instanceof Arr ? columnsArgument.items.map(String) : undefined
|
||||
@@ -118,9 +71,9 @@ const formatConsoleTable = (value: unknown, columnsArgument: unknown): string =>
|
||||
}
|
||||
|
||||
const consoleTableRows = (
|
||||
data: unknown,
|
||||
data: Value,
|
||||
columns: ReadonlyArray<string> | undefined,
|
||||
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> => {
|
||||
): Array<{ readonly index: string; readonly values: Record<string, Value> }> => {
|
||||
if (data instanceof Arr) {
|
||||
return data.items.map((item, index) => ({ index: String(index), values: consoleTableValues(item, columns) }))
|
||||
}
|
||||
@@ -130,7 +83,7 @@ const consoleTableRows = (
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> => {
|
||||
const consoleTableValues = (value: Value, columns: ReadonlyArray<string> | undefined): Record<string, Value> => {
|
||||
if (value instanceof Obj && !(value instanceof Arr)) {
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, get(value, column)]))
|
||||
return Object.fromEntries(entries(value))
|
||||
@@ -138,7 +91,7 @@ const consoleTableValues = (value: unknown, columns: ReadonlyArray<string> | und
|
||||
return { Value: value }
|
||||
}
|
||||
|
||||
const formatConsoleTableCell = (value: unknown): string => {
|
||||
const formatConsoleTableCell = (value: Value): string => {
|
||||
if (value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
return formatConsoleValue(value, new Set(), 0)
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { Effect } from "effect"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { rangeError } from "../interpreter/model.js"
|
||||
import { DateObj, Obj } from "../interpreter/objects.js"
|
||||
import { DateObj, Obj, coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js"
|
||||
import { toPrimitive, toPrimitiveNumber } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
const constructDate = <R>(ctx: Interpreter<R>, args: Array<unknown>, proto: Obj) => {
|
||||
const constructDate = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) => {
|
||||
if (args.length === 0) return Effect.succeed(new DateObj(proto, Date.now()))
|
||||
if (args.length === 1) {
|
||||
const arg = args[0]
|
||||
@@ -83,7 +82,7 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
|
||||
])
|
||||
|
||||
const self = (thisValue: unknown, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
|
||||
const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
|
||||
const iso = (value: DateObj) => {
|
||||
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.")
|
||||
return new Date(value.time).toISOString()
|
||||
@@ -105,6 +104,13 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
|
||||
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
|
||||
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
|
||||
...(["toLocaleString", "toLocaleDateString", "toLocaleTimeString"] as const).map(
|
||||
(name): Method => [
|
||||
name,
|
||||
0,
|
||||
(thisValue) => new Date(self(thisValue, name).time)[name]("en-US", { timeZone: "UTC" }),
|
||||
],
|
||||
),
|
||||
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
|
||||
...setters.map(
|
||||
([name, length]): Method => [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user