Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f55a0c904f | |||
| 203885d241 | |||
| 728e86b287 | |||
| d5aa79c73a | |||
| eb6ff0c1e0 | |||
| 0df44c4e91 | |||
| 561070fbc2 | |||
| b0e41ff2c4 | |||
| 977a40af68 | |||
| dffecb6478 | |||
| 7f57d2a9ac | |||
| 377d5d2287 | |||
| 38bb38ecb2 | |||
| 3a149ba71c | |||
| 14df88eab5 | |||
| e12cb7fb6b | |||
| d4f7039932 | |||
| e0ec9be238 | |||
| 2b34df94fa | |||
| 68f225a11d | |||
| e9f5d34096 | |||
| d3459eb740 | |||
| be73f465df | |||
| f14eafe9db | |||
| b7e4f1ef74 | |||
| efd5f0a80f | |||
| 7135bc4fb5 | |||
| 7cf8d1ca6d | |||
| 78bca5c707 | |||
| 82db86e2a1 | |||
| 476e3fc382 | |||
| a12d50e15a | |||
| 1b9b260458 | |||
| a8983bd2c7 | |||
| 709af58612 | |||
| bcbbf32569 | |||
| 7a8e7c88f4 | |||
| a22676720d | |||
| f63a451b6f | |||
| 8b6a2b173b | |||
| 933dbfdb36 | |||
| b44bc0ad07 | |||
| 436cd39d14 | |||
| ed6dc879be | |||
| 96d53c6716 | |||
| 911fb7063a |
+1
-1
@@ -125,4 +125,4 @@ OpenCode 内置两种 Agent,可用 `Tab` 键快速切换:
|
||||
|
||||
---
|
||||
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我们的社区** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
+1
-1
@@ -125,4 +125,4 @@ OpenCode 內建了兩種 Agent,您可以使用 `Tab` 鍵快速切換。
|
||||
|
||||
---
|
||||
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=738j8655-cd59-4633-a30a-1124e0096789&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
**加入我們的社群** [飞书](https://applink.feishu.cn/client/chat/chatter/add_by_link?link_token=52ao9352-5623-4fa0-b7dd-3407c392c1af&qr_code=true) | [X.com](https://x.com/opencode)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
"@dnd-kit/dom": "0.5.0",
|
||||
@@ -93,7 +93,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"bin": {
|
||||
"lildax": "./bin/lildax.cjs",
|
||||
},
|
||||
@@ -141,7 +141,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "0.0.1",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -155,7 +155,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode-ai/console-app",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -191,7 +191,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode-ai/console-core",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -218,7 +218,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode-ai/console-function",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
"@ai-sdk/openai": "3.0.48",
|
||||
@@ -240,7 +240,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode-ai/console-mail",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -264,7 +264,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode-ai/console-support",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode-ai/console-core": "workspace:*",
|
||||
@@ -284,7 +284,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode-ai/core",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -378,7 +378,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode-ai/desktop",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"effect": "catalog:",
|
||||
@@ -432,7 +432,7 @@
|
||||
},
|
||||
"packages/effect-drizzle-sqlite": {
|
||||
"name": "@opencode-ai/effect-drizzle-sqlite",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"drizzle-orm": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -446,7 +446,7 @@
|
||||
},
|
||||
"packages/effect-sqlite-node": {
|
||||
"name": "@opencode-ai/effect-sqlite-node",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
},
|
||||
@@ -458,7 +458,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode-ai/enterprise",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -490,7 +490,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode-ai/function",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -506,7 +506,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode-ai/http-recorder",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "4.0.0-beta.83",
|
||||
"@effect/platform-node-shared": "4.0.0-beta.83",
|
||||
@@ -537,7 +537,7 @@
|
||||
},
|
||||
"packages/llm": {
|
||||
"name": "@opencode-ai/llm",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/schema": "workspace:*",
|
||||
"@smithy/eventstream-codec": "4.2.14",
|
||||
@@ -556,7 +556,7 @@
|
||||
},
|
||||
"packages/opencode": {
|
||||
"name": "opencode",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"bin": {
|
||||
"opencode": "./bin/opencode",
|
||||
},
|
||||
@@ -687,7 +687,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode-ai/plugin",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
@@ -704,9 +704,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.3.4",
|
||||
"@opentui/keymap": ">=0.3.4",
|
||||
"@opentui/solid": ">=0.3.4",
|
||||
"@opentui/core": ">=0.4.3",
|
||||
"@opentui/keymap": ">=0.4.3",
|
||||
"@opentui/solid": ">=0.4.3",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -763,7 +763,7 @@
|
||||
},
|
||||
"packages/sdk/js": {
|
||||
"name": "@opencode-ai/sdk",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"cross-spawn": "catalog:",
|
||||
},
|
||||
@@ -778,7 +778,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode-ai/server",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/protocol": "workspace:*",
|
||||
@@ -793,7 +793,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode-ai/session-ui",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
@@ -837,7 +837,7 @@
|
||||
},
|
||||
"packages/slack": {
|
||||
"name": "@opencode-ai/slack",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@slack/bolt": "^3.17.1",
|
||||
@@ -850,7 +850,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode-ai/stats-app",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@opencode-ai/stats-core": "workspace:*",
|
||||
@@ -883,7 +883,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode-ai/stats-core",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -902,7 +902,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode-ai/stats-server",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -943,7 +943,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode-ai/tui",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@opencode-ai/core": "workspace:*",
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
@@ -970,7 +970,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -1021,7 +1021,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode-ai/web",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1096,9 +1096,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/keymap": "0.4.3",
|
||||
"@opentui/solid": "0.4.3",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -2031,27 +2031,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.3.4", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.3.4", "@opentui/core-darwin-x64": "0.3.4", "@opentui/core-linux-arm64": "0.3.4", "@opentui/core-linux-arm64-musl": "0.3.4", "@opentui/core-linux-x64": "0.3.4", "@opentui/core-linux-x64-musl": "0.3.4", "@opentui/core-win32-arm64": "0.3.4", "@opentui/core-win32-x64": "0.3.4" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-y0DlrChP9lcJ4jC5z/1wMS34+ygfSTW7gD5OJHwJaAScfmlFvuJOZbwmCGrJURZ+5wFBxuOi9LatZsmeAUIKAA=="],
|
||||
"@opentui/core": ["@opentui/core@0.4.3", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.3", "@opentui/core-darwin-x64": "0.4.3", "@opentui/core-linux-arm64": "0.4.3", "@opentui/core-linux-arm64-musl": "0.4.3", "@opentui/core-linux-x64": "0.4.3", "@opentui/core-linux-x64-musl": "0.4.3", "@opentui/core-win32-arm64": "0.4.3", "@opentui/core-win32-x64": "0.4.3" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-rrJfAk13tALDqldYjhc78eWQ+aKq1iknJgffIOg3OwyZoqQo+p6gtuqyhmWvXIfQzlNUbpgpCPcxbXlhMnlaHQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4A7JYXUsZqhu9PPCe07E30ourSJYkitkwMujUyNKjM5e/dHNDVnz+5r5cO3M5snofLafc1DN7+9jEPn4UQzchQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p5+7AAxpxGuDGagyQfewKtmTFnN7THvTVY4FyKqUtJomNaHdQXPHztapNNzMx0DGWbwOUbVKzpL+yc3CZY3chQ=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-Jvm9E8n2sPhKEyKSXn9GlmJcj8WoJXJTooXb3djwjVaiimjihIj0XxHzCWhdqbDtQp+VxDFyCKoQagOOz20qhA=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-+fh0vEUE0lwVC7RW5ijYLRlTLp5NfvCRj8SzxDVd7IL2j2ssB6YXcfIbXq2EW7UGnrejwPRXf1tgUrIXW9KmOw=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-0uPuHCeZxm/O7+L+iNQl8zRAfehiwYstKkT9J0uTZO64/byBCLvy5lvn1DiE/72s/nTJ5nwpLN+pQs2/WYVKLQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-gl6qA5QJy6u8Cbt7gOtHbhhfMZ4qQDb0kEwFXHcMGmbnKzz4OHoq74D6tNjyvSQB9saoC7C6C0tvn2DcJOuNog=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.3.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-sJYUzYcSOb5PCXRlhwsse/fdsMiVomNvIwq/2TDhAANef+YPO3Br+OH9kQRbuj0bjVDmUS36SGYWSTFu2lUO+A=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-8p8g8/AEq/xFGpQ7XcIFKcAqjc0QwsZcv+Ll9RbCDpUA56FGH6jfLDir0KYTNTgYXJTIrBIENI9K46VuxMUMQA=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-btYIQeNdPbN4JCrCjVB/RwMGrnRY7qWB2piNEfALSByuULKNjPKQ33PYIj38Yd01zCvCV7FotIeXEGSHx3tgCA=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-dXpJitiZdYE3hq2Pvx6e9I0uPQSOcnaLLp1pDgWAHv+3kvKSHEX//9Yr/pV/Ua6qqT7p+2D/K4vXNap/NKVo2w=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.3.4", "", { "os": "linux", "cpu": "x64" }, "sha512-fhmUey4oJJ2+N62xlIgAPxAl36Fa7wYffqDOT4QLpm0jfyD5xzo+wL/hr2zUqaEI439R8Iq6jHNxf/Nsx1WuuQ=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.3", "", { "os": "linux", "cpu": "x64" }, "sha512-/QiFpCrpU2O7vy8QYmLIQYbvAtKDgmqcVjR7dGtqSzkiQk3ktNJoo5RozG7ueXnjung1Wp0nKldKxo2Csg/OrA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.3.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-sh432vPU+eLp8eA4I0KWKKn7D0VHbk01YTg6mA9/ihCNYHntc6LZ8/sLvsPv8CvKscMotfIkh3M5YhdS36BuXw=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-Mx2zuOjrhm/z2SDS6RExIyjP/SnN/8QhhagxURUw0jQi/NssGSeAllu1cBAFFnhobJL5QLTE4FU4CRhUK9svgg=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.3.4", "", { "os": "win32", "cpu": "x64" }, "sha512-dw8FcjUZaLAjw25P3/7BarobCh/QOHn3srYaWYQdysoqyvSlPkQumpI8kV/KgpJtdITU1GW02MQC4EeLIFFalA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.3", "", { "os": "win32", "cpu": "x64" }, "sha512-NuoqvWKGXaYnmlqvu7Gg2lLI6yVMnS9OfWBvxp+7Q+McSgHFSTQmYBXaPpvQ8HikpQXE1nCeMPtuSG4PdZHe2w=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.3.4", "", { "dependencies": { "@opentui/core": "0.3.4" }, "peerDependencies": { "@opentui/react": "0.3.4", "@opentui/solid": "0.3.4", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8fo6BZWQgCjANfbKkzPo0ghAzS1E7TlHjDDS+SUhrX01qEUO1clFTRssKluHbXd2UJY1Ehle01TV5bFmY78f8w=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.4.3", "", { "dependencies": { "@opentui/core": "0.4.3" }, "peerDependencies": { "@opentui/react": "0.4.3", "@opentui/solid": "0.4.3", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-sinX0pyQBRrEvo89PSSUbSUDIYpL3xWo81VEfec58VFoVRB5FG48/deAtvRTQfJ8w1kgbzN8hzdOXdSm61zBmw=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.3.4", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.3.4", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-gin1VnsVBahX0nrU3mpgh5U1qvyJBIZu4NE5mc0YnObWOEf9HVNxKY4/BpUvQPh91kT6zeOzTBvAvYK4R7g9MQ=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.4.3", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.3", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-RcV0+S8HMdXOASyr7HmJUBuTUIaFPzAxMDa44VftS5C2JUgrmAuWo0Njv1q3TWRB1owjHnyKhEfWGKq7A82wxw=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -3243,7 +3243,7 @@
|
||||
|
||||
"builder-util-runtime": ["builder-util-runtime@9.7.0", "", { "dependencies": { "debug": "^4.3.4", "sax": "^1.2.4" } }, "sha512-g/kR520giAFYkSXTzcmF3kqQq7wi8F6N6SzeDgZrqTBN+VHdmgWOyTdD1yD7AATDId/yXLvuP34CxW46/BwCdw=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-N/ZWtyN0piZlrXQT7TO0V+q952orYqkfhXRXM1Hcbb+R3QSiBH4vLnib187Mrs1H7pWIYECAmPeapGYDOMCl+w=="],
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
|
||||
|
||||
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
|
||||
|
||||
@@ -5591,8 +5591,6 @@
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="],
|
||||
|
||||
"youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="],
|
||||
|
||||
"youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-ovrz0pALxRek5VEqSYkVpzb9YeiiVQWxfAHZQ3kX0/0=",
|
||||
"aarch64-linux": "sha256-+sqBt11Nl1fDrL1JvFAcN8JMWahspfbBPuMeM7YdagU=",
|
||||
"aarch64-darwin": "sha256-PsZfcmMpz/Xzudjv4CQZu+wPfnyijrlVYeP2x9tRffk=",
|
||||
"x86_64-darwin": "sha256-4TpIc3Gh9nDaA5Y1zkCq0KraMdWu+PWtYVY3p7/CsJ0="
|
||||
"x86_64-linux": "sha256-KTyd2ISQ4n1E3tm1LMEHtz+rKRZga+SONn5J6H0pheQ=",
|
||||
"aarch64-linux": "sha256-ZIeaaqRr4JorEmmwFYuitxfYEAtrP8C6IlD+pmFRko0=",
|
||||
"aarch64-darwin": "sha256-Po8FISoBzUMwJSn6nw243/hLT/gAQCrm5HbwXe2uA0g=",
|
||||
"x86_64-darwin": "sha256-5TZzrCnvg1z00YP6O9U0SXjL04r+VmF7LVrqQ9BbSn4="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -39,9 +39,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.3.4",
|
||||
"@opentui/keymap": "0.3.4",
|
||||
"@opentui/solid": "0.3.4",
|
||||
"@opentui/core": "0.4.3",
|
||||
"@opentui/keymap": "0.4.3",
|
||||
"@opentui/solid": "0.4.3",
|
||||
"@tanstack/solid-virtual": "3.13.28",
|
||||
"@shikijs/stream": "4.2.0",
|
||||
"ulid": "3.0.1",
|
||||
|
||||
@@ -48,6 +48,40 @@ test("does not reverse visible rows when the user wheels during shell remeasurem
|
||||
await reportVisualStability(testInfo, "wheel-during-resize", trace, rowPairPlan(regions, 1))
|
||||
})
|
||||
|
||||
test("keeps moving upward while drag-selecting above the timeline", async ({ page }) => {
|
||||
await setupTimeline(page, {
|
||||
messages: history(80),
|
||||
viewport: { width: 1400, height: 700 },
|
||||
reducedMotion: true,
|
||||
})
|
||||
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
|
||||
const text = page.getByText("History 79.", { exact: false })
|
||||
await expect(text).toBeVisible()
|
||||
await scroller.evaluate((element) => {
|
||||
element.dataset.selectionLength = "0"
|
||||
document.addEventListener("selectionchange", () => {
|
||||
element.dataset.selectionLength = String(
|
||||
Math.max(Number(element.dataset.selectionLength), window.getSelection()?.toString().length ?? 0),
|
||||
)
|
||||
})
|
||||
})
|
||||
const textBox = await text.boundingBox()
|
||||
const scrollBox = await scroller.boundingBox()
|
||||
expect(textBox).not.toBeNull()
|
||||
expect(scrollBox).not.toBeNull()
|
||||
if (!textBox || !scrollBox) return
|
||||
|
||||
await page.mouse.move(textBox.x + textBox.width - 10, textBox.y + textBox.height / 2)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(textBox.x + 20, scrollBox.y - 120, { steps: 30 })
|
||||
|
||||
await expect.poll(() => scroller.evaluate((element) => Number(element.dataset.selectionLength))).toBeGreaterThan(0)
|
||||
await expect
|
||||
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeGreaterThan(500)
|
||||
await page.mouse.up()
|
||||
})
|
||||
|
||||
test("does not pull a keyboard-scrolled user during shell remeasurement", async ({ page }, testInfo) => {
|
||||
const shellID = "prt_keyboard_01_shell"
|
||||
const followingID = "prt_keyboard_02_following"
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { benchmark, expect } from "../benchmark"
|
||||
import { setupTimelineBenchmark } from "./session-timeline-benchmark.fixture"
|
||||
|
||||
const changedLinesPerFile = 100
|
||||
const linesPerSide = changedLinesPerFile / 2
|
||||
const fileCounts = [1, 10, 100, 1_000, 10_000]
|
||||
const filesPerDirectory = 100
|
||||
const readyFrames = 3
|
||||
const completionTimeoutMs = Number(process.env.REVIEW_PANE_COMPLETION_TIMEOUT_MS ?? 900_000)
|
||||
|
||||
type ReviewPaneScalingSample = {
|
||||
observedAtMs: number
|
||||
logicalRows: number
|
||||
treeRows: number
|
||||
fileRows: number
|
||||
diffLines: number
|
||||
header: string
|
||||
ready: boolean
|
||||
}
|
||||
|
||||
type ReviewPaneScalingProbe = {
|
||||
startedAt?: number
|
||||
firstTreeRowMs?: number
|
||||
logicalTreeReadyMs?: number
|
||||
firstDiffRenderMs?: number
|
||||
stableReadyMs?: number
|
||||
samples: ReviewPaneScalingSample[]
|
||||
frameTimesMs: number[]
|
||||
longTasks: { startTime: number; duration: number }[]
|
||||
stop: () => void
|
||||
}
|
||||
|
||||
benchmark.describe("performance: review pane scaling", () => {
|
||||
for (const fileCount of fileCounts) {
|
||||
const changedLines = fileCount * changedLinesPerFile
|
||||
|
||||
benchmark(
|
||||
`${changedLines} changed lines across ${fileCount} ${fileCount === 1 ? "file" : "files"}`,
|
||||
async ({ page, report }) => {
|
||||
benchmark.setTimeout(1_200_000)
|
||||
await page.emulateMedia({ reducedMotion: "reduce" })
|
||||
|
||||
const patchByteLimit = Number(process.env.REVIEW_PANE_PATCH_BYTE_LIMIT ?? Number.POSITIVE_INFINITY)
|
||||
if (Number.isNaN(patchByteLimit) || patchByteLimit < 0)
|
||||
throw new Error(`Invalid REVIEW_PANE_PATCH_BYTE_LIMIT: ${process.env.REVIEW_PANE_PATCH_BYTE_LIMIT}`)
|
||||
const responseBody = JSON.stringify(createScalingDiffs(fileCount, patchByteLimit))
|
||||
await setupTimelineBenchmark(page, {
|
||||
historyTurns: 0,
|
||||
eventBatch: 1,
|
||||
newLayoutDesigns: true,
|
||||
})
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: responseBody,
|
||||
}),
|
||||
)
|
||||
|
||||
const expectedRows = fileCount + 2 + Math.ceil(fileCount / filesPerDirectory)
|
||||
const metrics = await measureReviewPaneLoad(page, {
|
||||
expectedFile: reviewFile(0),
|
||||
expectedRows,
|
||||
})
|
||||
const search = await measureBroadReviewSearch(page, fileCount)
|
||||
|
||||
expect(metrics.logicalRows).toBe(expectedRows)
|
||||
expect(metrics.fileRows).toBeGreaterThan(0)
|
||||
expect(metrics.treeRows).toBeGreaterThan(0)
|
||||
expect(metrics.diffLines).toBeGreaterThan(0)
|
||||
expect(search.logicalRows).toBe(fileCount)
|
||||
expect(search.renderedRows).toBeGreaterThan(0)
|
||||
report(
|
||||
{ ...metrics, search },
|
||||
{
|
||||
fileCount,
|
||||
changedLinesPerFile,
|
||||
changedLines,
|
||||
additions: changedLines / 2,
|
||||
deletions: changedLines / 2,
|
||||
patchLines: changedLines,
|
||||
patchByteLimit: Number.isFinite(patchByteLimit) ? patchByteLimit : null,
|
||||
payloadBytes: new TextEncoder().encode(responseBody).byteLength,
|
||||
expectedRows,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
async function measureBroadReviewSearch(page: Page, expectedRows: number) {
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.evaluate((element) => {
|
||||
element.addEventListener(
|
||||
"input",
|
||||
() => {
|
||||
;(window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt = performance.now()
|
||||
},
|
||||
{ once: true, capture: true },
|
||||
)
|
||||
})
|
||||
await filter.fill("file-")
|
||||
|
||||
return page.evaluate((expectedRows) => {
|
||||
const startedAt = (window as Window & { __reviewSearchStartedAt?: number }).__reviewSearchStartedAt!
|
||||
return new Promise<{ stableMs: number; logicalRows: number; renderedRows: number }>((resolve) => {
|
||||
let previous = -1
|
||||
let streak = 0
|
||||
const sample = () => {
|
||||
const tree = document.querySelector<HTMLElement>('#review-panel [data-component="file-tree-v2"]')
|
||||
const rows = [...document.querySelectorAll<HTMLElement>('#review-panel [data-slot="file-tree-v2-row"]')]
|
||||
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
||||
const ready =
|
||||
logicalRows === expectedRows && rows.length > 0 && rows.every((row) => row.textContent?.includes("file-"))
|
||||
streak = ready && rows.length === previous ? streak + 1 : ready ? 1 : 0
|
||||
previous = rows.length
|
||||
if (streak >= 3) {
|
||||
resolve({ stableMs: performance.now() - startedAt, logicalRows, renderedRows: rows.length })
|
||||
return
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
})
|
||||
}, expectedRows)
|
||||
}
|
||||
|
||||
function createScalingDiffs(fileCount: number, patchByteLimit: number) {
|
||||
const changes = Array.from({ length: linesPerSide }, (_, index) => {
|
||||
const line = String(index).padStart(3, "0")
|
||||
return `-export const value_${line} = "before"\n+export const value_${line} = "after"`
|
||||
}).join("\n")
|
||||
let patchBytes = 0
|
||||
let capped = false
|
||||
|
||||
return Array.from({ length: fileCount }, (_, index) => {
|
||||
const file = reviewFile(index)
|
||||
const fullPatch = [
|
||||
`diff --git a/${file} b/${file}`,
|
||||
`--- a/${file}`,
|
||||
`+++ b/${file}`,
|
||||
`@@ -1,${linesPerSide} +1,${linesPerSide} @@`,
|
||||
changes,
|
||||
].join("\n")
|
||||
if (index === 0 && fullPatch.length > patchByteLimit)
|
||||
throw new Error(`REVIEW_PANE_PATCH_BYTE_LIMIT must include the active patch (${fullPatch.length} bytes)`)
|
||||
const patch = !capped && patchBytes + fullPatch.length <= patchByteLimit ? fullPatch : emptyReviewPatch(file)
|
||||
if (patch === fullPatch) patchBytes += fullPatch.length
|
||||
else capped = true
|
||||
return {
|
||||
file,
|
||||
patch,
|
||||
additions: linesPerSide,
|
||||
deletions: linesPerSide,
|
||||
status: "modified" as const,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function emptyReviewPatch(file: string) {
|
||||
return [`diff --git a/${file} b/${file}`, `--- a/${file}`, `+++ b/${file}`].join("\n")
|
||||
}
|
||||
|
||||
function reviewFile(index: number) {
|
||||
return `src/review/d${String(Math.floor(index / filesPerDirectory)).padStart(5, "0")}/file-${String(index).padStart(5, "0")}.ts`
|
||||
}
|
||||
|
||||
async function measureReviewPaneLoad(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
||||
const toggle = page.getByRole("button", { name: "Toggle review" })
|
||||
await expect(toggle).toBeVisible()
|
||||
await toggle.evaluate((element) => element.setAttribute("data-review-pane-scaling-toggle", ""))
|
||||
await installReviewPaneScalingProbe(page, input)
|
||||
await toggle.click()
|
||||
await page.waitForFunction(
|
||||
() =>
|
||||
(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe
|
||||
?.stableReadyMs !== undefined,
|
||||
undefined,
|
||||
{ timeout: completionTimeoutMs },
|
||||
)
|
||||
|
||||
return page.evaluate(() => {
|
||||
const probe = (window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe!
|
||||
probe.stop()
|
||||
const startedAt = probe.startedAt!
|
||||
const final = probe.samples.at(-1)!
|
||||
const resources = performance
|
||||
.getEntriesByType("resource")
|
||||
.filter((entry) => entry.name.includes("/vcs/diff")) as PerformanceResourceTiming[]
|
||||
const resource = resources.at(-1)
|
||||
const longTasks = probe.longTasks.filter(
|
||||
(entry) => entry.startTime >= startedAt && entry.startTime <= startedAt + probe.stableReadyMs!,
|
||||
)
|
||||
const frameGaps = probe.frameTimesMs.map((time, index) => time - (probe.frameTimesMs[index - 1] ?? 0))
|
||||
|
||||
return {
|
||||
firstTreeRowMs: probe.firstTreeRowMs ?? null,
|
||||
logicalTreeReadyMs: probe.logicalTreeReadyMs ?? null,
|
||||
firstDiffRenderMs: probe.firstDiffRenderMs ?? null,
|
||||
stableReadyMs: probe.stableReadyMs ?? null,
|
||||
responseStartMs: resource ? resource.responseStart - startedAt : null,
|
||||
responseEndMs: resource ? resource.responseEnd - startedAt : null,
|
||||
responseToStableMs: resource ? probe.stableReadyMs! - (resource.responseEnd - startedAt) : null,
|
||||
treeRows: final.treeRows,
|
||||
logicalRows: final.logicalRows,
|
||||
fileRows: final.fileRows,
|
||||
diffLines: final.diffLines,
|
||||
samples: probe.samples.length,
|
||||
maxFrameGapMs: Math.max(0, ...frameGaps),
|
||||
longTaskCount: longTasks.length,
|
||||
longTaskTotalMs: longTasks.reduce((sum, entry) => sum + entry.duration, 0),
|
||||
maxLongTaskMs: Math.max(0, ...longTasks.map((entry) => entry.duration)),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function installReviewPaneScalingProbe(page: Page, input: { expectedFile: string; expectedRows: number }) {
|
||||
await page.evaluate(
|
||||
({ expectedFile, expectedRows, stableFrames }) => {
|
||||
let running = true
|
||||
let readyStreak = 0
|
||||
const basename = expectedFile.split("/").at(-1)!
|
||||
const longTaskObserver = PerformanceObserver.supportedEntryTypes.includes("longtask")
|
||||
? new PerformanceObserver((list) => {
|
||||
probe.longTasks.push(
|
||||
...list.getEntries().map((entry) => ({ startTime: entry.startTime, duration: entry.duration })),
|
||||
)
|
||||
})
|
||||
: undefined
|
||||
const probe: ReviewPaneScalingProbe = {
|
||||
samples: [],
|
||||
frameTimesMs: [],
|
||||
longTasks: [],
|
||||
stop: () => {
|
||||
running = false
|
||||
longTaskObserver?.disconnect()
|
||||
},
|
||||
}
|
||||
|
||||
const sample = (time: number) => {
|
||||
if (!running || probe.startedAt === undefined) return
|
||||
const panel = document.querySelector<HTMLElement>("#review-panel")
|
||||
const tree = panel?.querySelector<HTMLElement>('[data-component="file-tree-v2"]')
|
||||
const rows = panel?.querySelectorAll('[data-slot="file-tree-v2-row"]') ?? []
|
||||
const fileRows = panel?.querySelectorAll('button[data-slot="file-tree-v2-row"]') ?? []
|
||||
const header =
|
||||
panel?.querySelector<HTMLElement>('[data-slot="session-review-v2-file-header"]')?.textContent?.trim() ?? ""
|
||||
const viewers = panel
|
||||
? [...panel.querySelectorAll<HTMLElement>('[data-component="file"][data-mode="diff"]')]
|
||||
: []
|
||||
const diffLines = viewers.reduce(
|
||||
(sum, viewer) =>
|
||||
sum + (viewer.querySelector("diffs-container")?.shadowRoot?.querySelectorAll("[data-line]").length ?? 0),
|
||||
0,
|
||||
)
|
||||
const observedAtMs = time - probe.startedAt
|
||||
const logicalRows = Number(tree?.dataset.totalRows ?? rows.length)
|
||||
const ready =
|
||||
logicalRows === expectedRows &&
|
||||
fileRows.length > 0 &&
|
||||
header.includes(basename) &&
|
||||
viewers.length === 1 &&
|
||||
diffLines > 0
|
||||
const previous = probe.samples.at(-1)
|
||||
const stable =
|
||||
ready &&
|
||||
previous?.ready === true &&
|
||||
previous.logicalRows === logicalRows &&
|
||||
previous.treeRows === rows.length &&
|
||||
previous.fileRows === fileRows.length &&
|
||||
previous.diffLines === diffLines &&
|
||||
previous.header === header
|
||||
|
||||
probe.frameTimesMs.push(observedAtMs)
|
||||
probe.samples.push({
|
||||
observedAtMs,
|
||||
logicalRows,
|
||||
treeRows: rows.length,
|
||||
fileRows: fileRows.length,
|
||||
diffLines,
|
||||
header,
|
||||
ready,
|
||||
})
|
||||
if (probe.firstTreeRowMs === undefined && rows.length > 0) probe.firstTreeRowMs = observedAtMs
|
||||
if (probe.logicalTreeReadyMs === undefined && logicalRows === expectedRows)
|
||||
probe.logicalTreeReadyMs = observedAtMs
|
||||
if (probe.firstDiffRenderMs === undefined && diffLines > 0) probe.firstDiffRenderMs = observedAtMs
|
||||
readyStreak = !ready ? 0 : stable ? readyStreak + 1 : 1
|
||||
if (readyStreak === stableFrames) probe.stableReadyMs = observedAtMs
|
||||
if (probe.stableReadyMs === undefined) requestAnimationFrame(sample)
|
||||
}
|
||||
|
||||
longTaskObserver?.observe({ type: "longtask", buffered: true })
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(event) => {
|
||||
const toggle = event.target instanceof Element ? event.target.closest("button") : undefined
|
||||
if (!toggle?.hasAttribute("data-review-pane-scaling-toggle")) return
|
||||
probe.startedAt = performance.now()
|
||||
performance.mark("opencode.review-pane-scaling.click")
|
||||
requestAnimationFrame(sample)
|
||||
},
|
||||
{ capture: true, once: true },
|
||||
)
|
||||
;(window as Window & { __reviewPaneScalingProbe?: ReviewPaneScalingProbe }).__reviewPaneScalingProbe = probe
|
||||
},
|
||||
{ ...input, stableFrames: readyFrames },
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { Page } from "@playwright/test"
|
||||
import { expectSessionTitle } from "../../utils/waits"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
import { benchmark, expect, withBenchmarkPage } from "../benchmark"
|
||||
import { fixture } from "./session-timeline-stress.fixture"
|
||||
import { installStressSessionTabs, stressSessionHref } from "./timeline-test-helpers"
|
||||
import { measureSessionSwitch, waitForStableTimeline } from "./session-tab-switch-probe"
|
||||
|
||||
type ParentHydrationBenchmarkMode = "natural" | "candidate"
|
||||
|
||||
const mode = process.env.SESSION_PARENT_HYDRATION_BENCHMARK_MODE ?? "natural"
|
||||
if (mode !== "natural" && mode !== "candidate") throw new Error(`Unknown parent hydration benchmark mode: ${mode}`)
|
||||
const userID = "msg_parent_hydration_user"
|
||||
const user = {
|
||||
...fixture.messages[fixture.targetID][0]!,
|
||||
info: { ...fixture.messages[fixture.targetID][0]!.info, id: userID, time: { created: 1700001000000 } },
|
||||
parts: fixture.messages[fixture.targetID][0]!.parts.map((part, index) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_user_${index}`,
|
||||
messageID: userID,
|
||||
})),
|
||||
}
|
||||
const assistantSeed = fixture.messages[fixture.targetID][3]!
|
||||
const assistants = Array.from({ length: 14 }, (_, index) => {
|
||||
const messageID = `msg_parent_hydration_${String(index).padStart(2, "0")}`
|
||||
return {
|
||||
...assistantSeed,
|
||||
info: {
|
||||
...assistantSeed.info,
|
||||
id: messageID,
|
||||
parentID: userID,
|
||||
time: { created: 1700001001000 + index * 1_000, completed: 1700001001500 + index * 1_000 },
|
||||
},
|
||||
parts: assistantSeed.parts.map((part, partIndex) => ({
|
||||
...part,
|
||||
id: `prt_parent_hydration_${String(index).padStart(2, "0")}_${partIndex}`,
|
||||
messageID,
|
||||
})),
|
||||
}
|
||||
})
|
||||
const messages = [user, ...assistants]
|
||||
const target = fixture.sessions.find((session) => session.id === fixture.targetID)!
|
||||
const lastID = userID
|
||||
const lastPartID = assistants.at(-1)!.parts.at(-1)!.id
|
||||
|
||||
benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => {
|
||||
benchmark.setTimeout(180_000)
|
||||
const results = [] as Awaited<ReturnType<typeof trial>>[]
|
||||
for (let run = 0; run < 5; run++) {
|
||||
results.push(
|
||||
await withBenchmarkPage(
|
||||
browser,
|
||||
`session-parent-hydration-${mode}-${run}`,
|
||||
(page) => trial(page, mode),
|
||||
testInfo,
|
||||
),
|
||||
)
|
||||
}
|
||||
const timing = results.map((result) => result.metrics.firstCorrectObservedMs!).sort((a, b) => a - b)
|
||||
report(
|
||||
{
|
||||
results: results.map((result) => ({ ...result.metrics, historyGateCount: result.historyGateCount })),
|
||||
summary: {
|
||||
firstCorrectObservedMs: { min: timing[0], median: timing[2], max: timing.at(-1) },
|
||||
blankSamples: results.map((result) => result.metrics.blankSamples),
|
||||
requestCounts: {
|
||||
list: results.map((result) => result.requestCounts.list),
|
||||
parent: results.map((result) => result.requestCounts.parent),
|
||||
},
|
||||
historyGateCount: results.map((result) => result.historyGateCount),
|
||||
},
|
||||
},
|
||||
{ mode },
|
||||
)
|
||||
})
|
||||
|
||||
async function trial(page: Page, mode: ParentHydrationBenchmarkMode) {
|
||||
const requests: { type: "list" | "parent"; before?: string }[] = []
|
||||
const history = mode === "candidate" ? Promise.withResolvers<void>() : undefined
|
||||
let historyGates = 0
|
||||
await mockOpenCodeServer(page, {
|
||||
sessions: fixture.sessions.filter((session) => session.id === fixture.sourceID),
|
||||
provider: fixture.provider,
|
||||
directory: fixture.directory,
|
||||
project: fixture.project,
|
||||
messageDelay: 50,
|
||||
onMessages: (request) => {
|
||||
if (request.sessionID === fixture.targetID && request.phase === "start")
|
||||
requests.push({ type: "list", before: request.before })
|
||||
},
|
||||
beforeMessagesResponse: (request) => {
|
||||
if (mode !== "candidate" || request.sessionID !== fixture.targetID || !request.before) return Promise.resolve()
|
||||
historyGates++
|
||||
return history!.promise
|
||||
},
|
||||
onMessage: (request) => {
|
||||
if (request.sessionID === fixture.targetID && request.messageID === userID) requests.push({ type: "parent" })
|
||||
},
|
||||
message: (sessionID, messageID) => {
|
||||
if (sessionID !== fixture.targetID || messageID !== userID) return
|
||||
return user
|
||||
},
|
||||
pageMessages: (sessionID, limit, before) => {
|
||||
const items = sessionID === fixture.targetID ? messages : fixture.messages[fixture.sourceID]
|
||||
const end = before ? items.findIndex((message) => message.info.id === before) : items.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined }
|
||||
},
|
||||
})
|
||||
await page.route(`**/session/${fixture.targetID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }),
|
||||
)
|
||||
await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] })
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
await expectSessionTitle(page, fixture.expected.sourceTitle)
|
||||
await waitForStableTimeline(page, fixture.expected.sourceMessageIDs.at(-1)!)
|
||||
|
||||
const href = stressSessionHref(fixture.targetID)
|
||||
await page.evaluate(
|
||||
({ href, title }) => {
|
||||
const link = document.createElement("a")
|
||||
link.id = "parent-hydration-target"
|
||||
link.href = href
|
||||
link.textContent = title
|
||||
document.body.append(link)
|
||||
},
|
||||
{ href, title: target.title },
|
||||
)
|
||||
const metrics = await measureSessionSwitch(page, {
|
||||
destinationIDs: messages.map((message) => message.info.id),
|
||||
sourceIDs: fixture.messages[fixture.sourceID].map((message) => message.info.id),
|
||||
lastID,
|
||||
requiredPartID: lastPartID,
|
||||
requireBottomAnchor: false,
|
||||
href,
|
||||
switch: async () => {
|
||||
await page.locator("#parent-hydration-target").click()
|
||||
await expectSessionTitle(page, target.title)
|
||||
},
|
||||
}).finally(() => history?.resolve())
|
||||
expect(metrics.firstCorrectObservedMs).not.toBeNull()
|
||||
const requestCounts = {
|
||||
list: requests.filter((request) => request.type === "list").length,
|
||||
parent: requests.filter((request) => request.type === "parent").length,
|
||||
}
|
||||
if (mode === "candidate") {
|
||||
expect(requestCounts.parent).toBe(1)
|
||||
expect(historyGates).toBe(1)
|
||||
}
|
||||
return { metrics, requestCounts, historyGateCount: historyGates }
|
||||
}
|
||||
@@ -4,6 +4,8 @@ export type SessionSwitchSample = {
|
||||
source: string[]
|
||||
hasVisibleRows: boolean
|
||||
last: boolean
|
||||
requiredPartVisible?: boolean
|
||||
bottomAnchorRequired?: boolean
|
||||
bottomErrorPx?: number
|
||||
review?: {
|
||||
fileHost: boolean
|
||||
@@ -41,7 +43,8 @@ export function isCorrectDestination(sample: SessionSwitchSample) {
|
||||
sample.destination.length > 0 &&
|
||||
sample.source.length === 0 &&
|
||||
sample.last &&
|
||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1
|
||||
sample.requiredPartVisible !== false &&
|
||||
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,9 +8,16 @@ type SessionSwitchProbe = {
|
||||
|
||||
async function installSessionSwitchProbe(
|
||||
page: Page,
|
||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string },
|
||||
input: {
|
||||
destinationIDs: string[]
|
||||
sourceIDs: string[]
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
href: string
|
||||
},
|
||||
) {
|
||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, href }) => {
|
||||
await page.evaluate(({ destinationIDs, sourceIDs, lastID, requiredPartID, requireBottomAnchor, href }) => {
|
||||
const destination = new Set(destinationIDs)
|
||||
const source = new Set(sourceIDs)
|
||||
const samples: SessionSwitchSample[] = []
|
||||
@@ -66,6 +73,13 @@ async function installSessionSwitchProbe(
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
const requiredPartVisible = requiredPartID
|
||||
? [...root.querySelectorAll<HTMLElement>("[data-timeline-part-id]")].some((element) => {
|
||||
if (element.dataset.timelinePartId !== requiredPartID) return false
|
||||
const rect = element.getBoundingClientRect()
|
||||
return rect.width > 0 && rect.height > 0 && rect.bottom > view.top && rect.top < view.bottom
|
||||
})
|
||||
: undefined
|
||||
const spacer = root.querySelector<HTMLElement>('[data-timeline-row="bottom-spacer"]')?.getBoundingClientRect()
|
||||
samples.push({
|
||||
observedAtMs,
|
||||
@@ -73,11 +87,22 @@ async function installSessionSwitchProbe(
|
||||
source: visible.filter((id) => source.has(id)),
|
||||
hasVisibleRows,
|
||||
last: visible.includes(lastID),
|
||||
requiredPartVisible,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
bottomErrorPx: spacer ? spacer.bottom - view.bottom : undefined,
|
||||
review,
|
||||
})
|
||||
} else {
|
||||
samples.push({ observedAtMs, destination: [], source: [], hasVisibleRows: false, last: false, review })
|
||||
samples.push({
|
||||
observedAtMs,
|
||||
destination: [],
|
||||
source: [],
|
||||
hasVisibleRows: false,
|
||||
last: false,
|
||||
requiredPartVisible: requiredPartID ? false : undefined,
|
||||
bottomAnchorRequired: requireBottomAnchor !== false,
|
||||
review,
|
||||
})
|
||||
}
|
||||
requestAnimationFrame(sample)
|
||||
}, 0)
|
||||
@@ -117,7 +142,8 @@ async function waitForStableSessionSwitch(page: Page) {
|
||||
sample.destination.length > 0 &&
|
||||
sample.source.length === 0 &&
|
||||
sample.last &&
|
||||
Math.abs(sample.bottomErrorPx ?? Infinity) <= 1,
|
||||
sample.requiredPartVisible !== false &&
|
||||
(sample.bottomAnchorRequired === false || Math.abs(sample.bottomErrorPx ?? Infinity) <= 1),
|
||||
)
|
||||
)
|
||||
})
|
||||
@@ -135,13 +161,27 @@ async function collectSessionSwitchResult(page: Page) {
|
||||
|
||||
export async function measureSessionSwitch(
|
||||
page: Page,
|
||||
input: { destinationIDs: string[]; sourceIDs: string[]; lastID: string; href: string; switch: () => Promise<void> },
|
||||
input: {
|
||||
destinationIDs: string[]
|
||||
sourceIDs: string[]
|
||||
lastID: string
|
||||
requiredPartID?: string
|
||||
requireBottomAnchor?: boolean
|
||||
href: string
|
||||
switch: () => Promise<void>
|
||||
},
|
||||
) {
|
||||
const { switch: run, ...probe } = input
|
||||
await installSessionSwitchProbe(page, probe)
|
||||
await run()
|
||||
await waitForStableSessionSwitch(page)
|
||||
return collectSessionSwitchResult(page)
|
||||
try {
|
||||
await run()
|
||||
await waitForStableSessionSwitch(page)
|
||||
return await collectSessionSwitchResult(page)
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as Window & { __sessionSwitchProbe?: SessionSwitchProbe }).__sessionSwitchProbe?.stop()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export async function waitForStableTimeline(page: Page, lastID: string) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../../utils/mock-server"
|
||||
|
||||
test("applies message latency after a list response gate is released", async () => {
|
||||
const events: string[] = []
|
||||
const gate = Promise.withResolvers<void>()
|
||||
let handler: ((route: Route) => Promise<void>) | undefined
|
||||
const page = {
|
||||
route: (_url: string, callback: (route: Route) => Promise<void>) => {
|
||||
handler = callback
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Page
|
||||
await mockOpenCodeServer(page, {
|
||||
provider: {},
|
||||
directory: "C:/OpenCode",
|
||||
project: {},
|
||||
sessions: [{ id: "session" }],
|
||||
messageDelay: 25,
|
||||
beforeMessagesResponse: () => {
|
||||
events.push("before")
|
||||
return gate.promise
|
||||
},
|
||||
onMessages: (request) => events.push(request.phase),
|
||||
pageMessages: () => {
|
||||
events.push("page")
|
||||
return { items: [] }
|
||||
},
|
||||
})
|
||||
|
||||
const response = handler!({
|
||||
request: () => ({ url: () => "http://127.0.0.1:4096/session/session/message" }),
|
||||
fulfill: () => {
|
||||
events.push("fulfill")
|
||||
return Promise.resolve()
|
||||
},
|
||||
} as unknown as Route)
|
||||
expect(events).toEqual(["start", "before"])
|
||||
|
||||
const released = performance.now()
|
||||
gate.resolve()
|
||||
await response
|
||||
expect(performance.now() - released).toBeGreaterThanOrEqual(20)
|
||||
expect(events).toEqual(["start", "before", "page", "end", "fulfill"])
|
||||
})
|
||||
@@ -52,3 +52,35 @@ test("reports missing correctness without throwing", () => {
|
||||
expect(result.firstCorrectObservedMs).toBeNull()
|
||||
expect(result.stableObservedMs).toBeNull()
|
||||
})
|
||||
|
||||
test("requires an explicitly tracked part to be visible", () => {
|
||||
const result = classifySessionSwitch([
|
||||
{
|
||||
observedAtMs: 16,
|
||||
destination: ["destination"],
|
||||
source: [],
|
||||
hasVisibleRows: true,
|
||||
last: true,
|
||||
requiredPartVisible: false,
|
||||
bottomErrorPx: 0,
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.firstCorrectObservedMs).toBeNull()
|
||||
})
|
||||
|
||||
test("can measure content correctness without requiring a bottom anchor", () => {
|
||||
const result = classifySessionSwitch([
|
||||
{
|
||||
observedAtMs: 16,
|
||||
destination: ["destination"],
|
||||
source: [],
|
||||
hasVisibleRows: true,
|
||||
last: true,
|
||||
requiredPartVisible: true,
|
||||
bottomAnchorRequired: false,
|
||||
},
|
||||
])
|
||||
|
||||
expect(result.firstCorrectObservedMs).toBe(16)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import type { Page } from "@playwright/test"
|
||||
import { measureSessionSwitch } from "../timeline/session-tab-switch-probe"
|
||||
|
||||
function testPage(waitFailure?: Error) {
|
||||
const stops: unknown[] = []
|
||||
const page = {
|
||||
evaluate: async (_callback: unknown, input?: unknown) => {
|
||||
if (input) return
|
||||
stops.push(undefined)
|
||||
},
|
||||
waitForFunction: async () => {
|
||||
if (waitFailure) throw waitFailure
|
||||
},
|
||||
} as unknown as Page
|
||||
return { page, stops }
|
||||
}
|
||||
|
||||
function input(run: () => Promise<void>) {
|
||||
return {
|
||||
destinationIDs: ["destination"],
|
||||
sourceIDs: ["source"],
|
||||
lastID: "destination",
|
||||
href: "/session/destination",
|
||||
switch: run,
|
||||
}
|
||||
}
|
||||
|
||||
test("stops sampling when the session switch fails", async () => {
|
||||
const failure = new Error("switch failed")
|
||||
const context = testPage()
|
||||
|
||||
await expect(
|
||||
measureSessionSwitch(
|
||||
context.page,
|
||||
input(async () => Promise.reject(failure)),
|
||||
),
|
||||
).rejects.toBe(failure)
|
||||
|
||||
expect(context.stops).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("stops sampling when the stable wait fails", async () => {
|
||||
const failure = new Error("stable wait failed")
|
||||
const context = testPage(failure)
|
||||
|
||||
await expect(
|
||||
measureSessionSwitch(
|
||||
context.page,
|
||||
input(async () => {}),
|
||||
),
|
||||
).rejects.toBe(failure)
|
||||
|
||||
expect(context.stops).toHaveLength(1)
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectAppVisible } from "../utils/waits"
|
||||
|
||||
const draftID = "draft_new_session_panel_corner"
|
||||
const directory = "C:/OpenCode/NewSessionPanelCorner"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
|
||||
test.use({
|
||||
viewport: { width: 935, height: 522 },
|
||||
deviceScaleFactor: 1,
|
||||
})
|
||||
|
||||
test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: "proj_new_session_panel_corner",
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "new-session-panel-corner",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: { all: [], connected: [], default: {} },
|
||||
sessions: [],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ directory, draftID, server }) => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem("opencode-theme-id", "oc-2")
|
||||
localStorage.setItem("opencode-color-scheme", "dark")
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:server",
|
||||
JSON.stringify({
|
||||
projects: { local: [{ worktree: directory, expanded: true }] },
|
||||
lastProject: { local: directory },
|
||||
}),
|
||||
)
|
||||
localStorage.setItem(
|
||||
"opencode.window.browser.dat:tabs",
|
||||
JSON.stringify([{ type: "draft", draftID, server, directory }]),
|
||||
)
|
||||
},
|
||||
{ directory, draftID, server },
|
||||
)
|
||||
|
||||
await page.goto(`/new-session?draftId=${draftID}`)
|
||||
await expectAppVisible(page.locator('[data-component="prompt-input"]'))
|
||||
await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark")
|
||||
const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]')
|
||||
await expect(panel).toHaveCount(1)
|
||||
const box = await panel.boundingBox()
|
||||
if (!box) throw new Error("New-session panel bounds are unavailable")
|
||||
|
||||
const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") })
|
||||
const corners = await page.evaluate(
|
||||
async ({ source, points }) => {
|
||||
const image = new Image()
|
||||
image.src = source
|
||||
await image.decode()
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = image.naturalWidth
|
||||
canvas.height = image.naturalHeight
|
||||
const context = canvas.getContext("2d")
|
||||
if (!context) throw new Error("2D canvas is unavailable")
|
||||
context.drawImage(image, 0, 0)
|
||||
return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data))
|
||||
},
|
||||
{
|
||||
source: `data:image/png;base64,${screenshot.toString("base64")}`,
|
||||
points: [
|
||||
{ x: Math.floor(box.x), y: Math.floor(box.y) },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) },
|
||||
{ x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 },
|
||||
{ x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 },
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true)
|
||||
})
|
||||
@@ -10,6 +10,9 @@ const sessionB = "ses_review_tab_b"
|
||||
const titleA = "Alpha session"
|
||||
const titleB = "Beta session"
|
||||
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
|
||||
const diffs = Array.from({ length: 2_740 }, (_, index) =>
|
||||
fileDiff(`src/generated-${String(index).padStart(4, "0")}.ts`),
|
||||
)
|
||||
// Marks the review pane DOM node so a remount (fresh node) is detectable.
|
||||
const PROBE = "original"
|
||||
|
||||
@@ -25,20 +28,34 @@ test("keeps the v2 review pane mounted when switching session tabs in a workspac
|
||||
await expectSessionTitle(page, titleA)
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
const reviewTab = page.getByRole("tab", { name: /Review/ })
|
||||
const reviewTabPanel = page.getByRole("tabpanel", { name: /Review/ })
|
||||
await expect(reviewTab).toHaveAttribute("aria-controls", "session-side-panel-review-tabpanel")
|
||||
await expect(reviewTabPanel).toHaveAttribute("id", "session-side-panel-review-tabpanel")
|
||||
const review = page.locator('#review-panel [data-component="session-review-v2"]')
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: /example\.ts/ }))
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
await writeProbe(page)
|
||||
|
||||
await switchTab(page, titleB)
|
||||
await expectSessionTitle(page, titleB)
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
await switchTab(page, titleA)
|
||||
await expectSessionTitle(page, titleA)
|
||||
await expectAppVisible(review)
|
||||
await expectAppVisible(page.getByRole("button", { name: "generated-0000.ts" }))
|
||||
expect(await readProbe(page)).toBe(PROBE)
|
||||
|
||||
const viewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await viewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
await expect
|
||||
.poll(() => viewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
await expect(page.getByRole("button", { name: "generated-2739.ts" })).toBeVisible()
|
||||
})
|
||||
|
||||
type Probed = HTMLElement & { __e2eProbe?: string }
|
||||
@@ -80,16 +97,7 @@ async function setup(page: Page) {
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)],
|
||||
vcsDiff: [
|
||||
{
|
||||
file: "src/example.ts",
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch:
|
||||
"diff --git a/src/example.ts b/src/example.ts\n--- a/src/example.ts\n+++ b/src/example.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n",
|
||||
},
|
||||
],
|
||||
vcsDiff: diffs,
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
|
||||
@@ -127,3 +135,13 @@ function session(id: string, title: string, created: number) {
|
||||
function sessionHref(sessionID: string) {
|
||||
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||
}
|
||||
|
||||
function fileDiff(file: string) {
|
||||
return {
|
||||
file,
|
||||
additions: 1,
|
||||
deletions: 1,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/ReviewTerminalStacked"
|
||||
const projectID = "proj_review_terminal_stacked"
|
||||
const sessionID = "ses_review_terminal_stacked"
|
||||
const title = "Review terminal stacked"
|
||||
const branchDiffs = [
|
||||
fileDiff(".github/actions/setup-bun/action.yml", 7),
|
||||
...Array.from({ length: 2_739 }, (_, index) =>
|
||||
fileDiff(`src/branch/generated-${String(index).padStart(4, "0")}.ts`, 100),
|
||||
),
|
||||
]
|
||||
|
||||
test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => {
|
||||
test.setTimeout(120_000)
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "review-terminal-stacked",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "review-terminal-stacked",
|
||||
projectID,
|
||||
directory,
|
||||
title,
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route(/\/vcs(?:\?.*)?$/, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/vcs/diff**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(
|
||||
new URL(route.request().url()).searchParams.get("mode") === "branch"
|
||||
? branchDiffs
|
||||
: Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route("**/pty/pty_review_terminal", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
localStorage.setItem(
|
||||
"opencode.global.dat:layout",
|
||||
JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }),
|
||||
)
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator("#review-panel")).toBeVisible()
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expect(page.getByRole("tab", { name: "Review 2740" })).toBeVisible()
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
|
||||
const treeViewport = page.locator('#review-panel [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport')
|
||||
await treeViewport.hover()
|
||||
await page.mouse.wheel(0, 100_000)
|
||||
await expect
|
||||
.poll(() => treeViewport.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
|
||||
.toBeLessThanOrEqual(1)
|
||||
const lastFile = page.getByRole("button", { name: "generated-2738.ts" })
|
||||
await expect(lastFile).toBeVisible()
|
||||
const bottomGap = await lastFile.evaluate((element) => {
|
||||
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect()
|
||||
return viewport.bottom - element.getBoundingClientRect().bottom
|
||||
})
|
||||
expect(bottomGap).toBeGreaterThanOrEqual(0)
|
||||
expect(bottomGap).toBeLessThanOrEqual(16)
|
||||
await selectMode(page, "Branch changes", "Git changes")
|
||||
await expectTree(page, 8, "git-0.ts")
|
||||
await selectMode(page, "Git changes", "Branch changes")
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
|
||||
const filter = page.getByRole("searchbox", { name: "Filter files" })
|
||||
await filter.fill("generated-2738")
|
||||
await expectTree(page, 1, "generated-2738.ts")
|
||||
await filter.fill("")
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expect(page.locator('[data-slot="session-review-v2-sidebar"]')).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
|
||||
await page.getByRole("button", { name: "Toggle file tree" }).click()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toHaveCount(0)
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(page.locator("#terminal-panel")).toBeVisible()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expect(page.locator("#review-panel")).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(page.locator('#review-panel [data-component="file-tree-v2"]')).toHaveCount(1)
|
||||
await page.getByRole("button", { name: "Toggle review" }).click()
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await page.setViewportSize({ width: 1_000, height: 700 })
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
await page.setViewportSize({ width: 1_000, height: 120 })
|
||||
await page.setViewportSize({ width: 1_400, height: 900 })
|
||||
await expectTree(page, 2_745, "action.yml")
|
||||
await expectStackGeometry(page)
|
||||
})
|
||||
|
||||
async function selectMode(page: Page, current: string, next: string) {
|
||||
await page.getByRole("button", { name: current }).click()
|
||||
const option = page.getByRole("option", { name: next })
|
||||
await expect(option).toBeVisible()
|
||||
await option.click()
|
||||
}
|
||||
|
||||
async function expectTree(page: Page, total: number, file: string) {
|
||||
await expectMountedTree(page, total)
|
||||
await expect(page.getByRole("button", { name: file })).toBeVisible()
|
||||
}
|
||||
|
||||
async function expectMountedTree(page: Page, total: number) {
|
||||
const tree = page.locator('#review-panel [data-component="file-tree-v2"]')
|
||||
await expect(tree).toHaveAttribute("data-total-rows", String(total))
|
||||
await expect
|
||||
.poll(() => tree.evaluate((element) => element.querySelectorAll('[data-slot="file-tree-v2-row"]').length))
|
||||
.toBeGreaterThan(0)
|
||||
const state = await tree.evaluate((element) => ({
|
||||
root: element.getBoundingClientRect().height,
|
||||
viewport: element.closest<HTMLElement>(".scroll-view__viewport")!.getBoundingClientRect().height,
|
||||
rows: element.querySelectorAll('[data-slot="file-tree-v2-row"]').length,
|
||||
}))
|
||||
expect(state.viewport).toBeGreaterThan(0)
|
||||
expect(state.root).toBeGreaterThan(0)
|
||||
expect(state.rows).toBeGreaterThan(0)
|
||||
expect(state.rows).toBeLessThanOrEqual(60)
|
||||
}
|
||||
|
||||
async function expectStackGeometry(page: Page) {
|
||||
const geometry = await page.evaluate(() => {
|
||||
const review = document.querySelector<HTMLElement>("#review-panel")!
|
||||
const terminal = document.querySelector<HTMLElement>("#terminal-panel")!
|
||||
const reviewParent = review.parentElement!.getBoundingClientRect()
|
||||
const terminalParent = terminal.parentElement!.getBoundingClientRect()
|
||||
return {
|
||||
review: review.getBoundingClientRect().height,
|
||||
reviewParent: reviewParent.height,
|
||||
terminal: terminal.getBoundingClientRect().height,
|
||||
terminalParent: terminalParent.height,
|
||||
}
|
||||
})
|
||||
expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1)
|
||||
expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1)
|
||||
}
|
||||
|
||||
function base64Encode(value: string) {
|
||||
return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "")
|
||||
}
|
||||
|
||||
function fileDiff(file: string, additions: number) {
|
||||
return {
|
||||
file,
|
||||
additions,
|
||||
deletions: 0,
|
||||
status: "modified",
|
||||
patch: `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test, type Page } from "@playwright/test"
|
||||
import {
|
||||
assistantMessage,
|
||||
directory,
|
||||
messageUpdated,
|
||||
project,
|
||||
session,
|
||||
sessionID,
|
||||
status,
|
||||
textPart,
|
||||
title,
|
||||
userID,
|
||||
userMessage,
|
||||
} from "../performance/timeline-stability/fixture"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { installSseTransport } from "../utils/sse-transport"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const assistants = Array.from({ length: 14 }, (_, index) =>
|
||||
assistantMessage([textPart(`prt_history_root_${index}`, `Assistant response ${index}`)], {
|
||||
id: `msg_${String(index + 1001).padStart(4, "0")}_history_root_assistant`,
|
||||
parentID: userID,
|
||||
created: 1700000001000 + index * 1_000,
|
||||
completed: index < 13,
|
||||
}),
|
||||
)
|
||||
const messages = [userMessage(), ...assistants]
|
||||
const lastAssistant = assistants.at(-1)!
|
||||
const lastPartID = assistants.at(-1)!.parts[0]!.id
|
||||
const userPartID = `prt_${userID}_text`
|
||||
const completed = {
|
||||
...lastAssistant.info,
|
||||
time: { ...lastAssistant.info.time, completed: lastAssistant.info.time.created + 15_000 },
|
||||
}
|
||||
const scenarios = [
|
||||
{ name: "completion", info: completed, idleFirst: false, interrupted: false },
|
||||
{
|
||||
name: "interruption",
|
||||
info: { ...completed, error: { name: "MessageAbortedError", data: { message: "Stopped" } } },
|
||||
idleFirst: true,
|
||||
interrupted: true,
|
||||
},
|
||||
] as const
|
||||
|
||||
test.use({ viewport: { width: 646, height: 1385 } })
|
||||
|
||||
for (const scenario of scenarios) {
|
||||
test(`keeps the latest user turn visible through ${scenario.name}`, async ({ page }) => {
|
||||
const requests: { before?: string; phase: "start" | "end" }[] = []
|
||||
const pages: { before?: string; limit: number }[] = []
|
||||
const roots: { sessionID: string; messageID: string }[] = []
|
||||
const sequence: string[] = []
|
||||
const history = Promise.withResolvers<void>()
|
||||
const transport = await installSseTransport<{ directory: string; payload: Record<string, unknown> }>(page, {
|
||||
server: `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`,
|
||||
retry: 20,
|
||||
})
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: project(),
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: {
|
||||
"claude-opus-4-6": {
|
||||
id: "claude-opus-4-6",
|
||||
name: "Claude Opus 4.6",
|
||||
limit: { context: 200_000 },
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "claude-opus-4-6" },
|
||||
},
|
||||
sessions: [session()],
|
||||
sessionStatus: { [sessionID]: { type: "busy" } },
|
||||
beforeMessagesResponse: (request) => (request.before ? history.promise : Promise.resolve()),
|
||||
onMessages: (request) => {
|
||||
requests.push(request)
|
||||
sequence.push(`messages:${request.phase}:${request.before ?? "latest"}`)
|
||||
},
|
||||
onMessage: (request) => {
|
||||
roots.push(request)
|
||||
sequence.push(`message:${request.messageID}`)
|
||||
},
|
||||
message: (requestedSessionID, messageID) => {
|
||||
if (requestedSessionID !== sessionID) return
|
||||
return messages.find((item) => item.info.id === messageID)
|
||||
},
|
||||
pageMessages: (_, limit, before) => {
|
||||
pages.push({ before, limit })
|
||||
const end = before ? messages.findIndex((message) => message.info.id === before) : messages.length
|
||||
const start = Math.max(0, end - limit)
|
||||
return {
|
||||
items: messages.slice(start, end),
|
||||
cursor: start > 0 ? messages[start]!.info.id : undefined,
|
||||
}
|
||||
},
|
||||
})
|
||||
await page.addInitScript(
|
||||
({ userPartID, lastPartID }) => {
|
||||
const state = { armed: false, hidden: false, samples: 0, stop: false }
|
||||
;(window as Window & { __historyRootProbe?: typeof state }).__historyRootProbe = state
|
||||
const sample = () => {
|
||||
if (state.armed) {
|
||||
const virtual = document.querySelector<HTMLElement>("[data-timeline-virtual-content]")
|
||||
const viewport = virtual?.closest<HTMLElement>(".scroll-view__viewport")
|
||||
const view = viewport?.getBoundingClientRect()
|
||||
const visible = (partID: string) => {
|
||||
const part = viewport?.querySelector<HTMLElement>(`[data-timeline-part-id="${partID}"]`)
|
||||
const rect = part?.getBoundingClientRect()
|
||||
return (
|
||||
!!rect &&
|
||||
!!view &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0 &&
|
||||
rect.bottom > view.top &&
|
||||
rect.top < view.bottom
|
||||
)
|
||||
}
|
||||
if (!virtual || !visible(userPartID) || !visible(lastPartID)) state.hidden = true
|
||||
state.samples++
|
||||
}
|
||||
if (!state.stop) requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
}
|
||||
requestAnimationFrame(() => setTimeout(sample, 0))
|
||||
},
|
||||
{ userPartID, lastPartID },
|
||||
)
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await transport.waitForConnection()
|
||||
await expectSessionTitle(page, title)
|
||||
await expect(page.locator(`[data-timeline-part-id="${lastPartID}"]`)).toBeVisible()
|
||||
await expect(page.locator(`[data-timeline-part-id="${userPartID}"]`)).toBeVisible()
|
||||
await expect.poll(() => requests.filter((request) => request.phase === "start").length).toBe(2)
|
||||
expect(requests.filter((request) => request.phase === "end")).toHaveLength(1)
|
||||
expect(sequence.slice(0, 4)).toEqual([
|
||||
"messages:start:latest",
|
||||
"messages:end:latest",
|
||||
`message:${userID}`,
|
||||
`messages:start:${messages.at(-2)!.info.id}`,
|
||||
])
|
||||
await page.evaluate(() => {
|
||||
;(
|
||||
window as Window & {
|
||||
__historyRootProbe?: { armed: boolean }
|
||||
}
|
||||
).__historyRootProbe!.armed = true
|
||||
})
|
||||
await waitForProbeSamples(page, 0)
|
||||
expect(await historyRootHidden(page)).toBe(false)
|
||||
const beforeHistory = await probeSamples(page)
|
||||
history.resolve()
|
||||
await expect(page.locator('[data-timeline-part-id^="prt_history_root_"]')).toHaveCount(14)
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeHistory)
|
||||
expect(pages[0]).toEqual({ before: undefined, limit: 2 })
|
||||
expect(roots).toEqual([{ sessionID, messageID: userID }])
|
||||
|
||||
const message = messageUpdated(scenario.info)
|
||||
const idle = status("idle")
|
||||
for (const event of scenario.idleFirst ? [idle, message] : [message, idle]) {
|
||||
const beforeEvent = await probeSamples(page)
|
||||
await transport.send(event)
|
||||
if (event === idle) await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||
if (event === message && scenario.interrupted)
|
||||
await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
await waitForProbeSamples(page, beforeEvent)
|
||||
const current = await timelineState(page)
|
||||
expect(current, JSON.stringify(current)).toMatchObject({ virtual: true })
|
||||
expect(current.rows, JSON.stringify(current)).toBeGreaterThan(0)
|
||||
}
|
||||
|
||||
expect(requests[0]).toEqual({ before: undefined, phase: "start", sessionID })
|
||||
expect(requests[1]).toEqual({ before: undefined, phase: "end", sessionID })
|
||||
await expect(page.getByRole("button", { name: "Stop" })).toHaveCount(0)
|
||||
await expect(page.locator('[data-timeline-row="bottom-spacer"]')).toBeVisible()
|
||||
if (scenario.interrupted) await expect(page.getByText("Interrupted", { exact: true })).toBeVisible()
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
const state = (window as Window & { __historyRootProbe?: { hidden: boolean; stop: boolean } })
|
||||
.__historyRootProbe!
|
||||
state.stop = true
|
||||
return state.hidden
|
||||
}),
|
||||
).toBe(false)
|
||||
})
|
||||
}
|
||||
|
||||
function timelineState(page: Page) {
|
||||
return page.evaluate(() => ({
|
||||
virtual: !!document.querySelector("[data-timeline-virtual-content]"),
|
||||
rows: document.querySelectorAll("[data-timeline-key]").length,
|
||||
}))
|
||||
}
|
||||
|
||||
function probeSamples(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples,
|
||||
)
|
||||
}
|
||||
|
||||
async function waitForProbeSamples(page: Page, after: number) {
|
||||
await page.waitForFunction(
|
||||
(after) =>
|
||||
(window as Window & { __historyRootProbe?: { samples: number } }).__historyRootProbe!.samples >= after + 3,
|
||||
after,
|
||||
)
|
||||
}
|
||||
|
||||
function historyRootHidden(page: Page) {
|
||||
return page.evaluate(
|
||||
() => (window as Window & { __historyRootProbe?: { hidden: boolean } }).__historyRootProbe!.hidden,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { expect, test } from "@playwright/test"
|
||||
import { mockOpenCodeServer } from "../utils/mock-server"
|
||||
import { expectSessionTitle } from "../utils/waits"
|
||||
|
||||
const directory = "C:/OpenCode/TerminalComposerFocus"
|
||||
const projectID = "proj_terminal_composer_focus"
|
||||
const sessionID = "ses_terminal_composer_focus"
|
||||
const ptyID = "pty_terminal_composer_focus"
|
||||
|
||||
test.use({ viewport: { width: 1440, height: 900 } })
|
||||
|
||||
test("routes typing to the composer unless the open terminal is focused", async ({ page }) => {
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
project: {
|
||||
id: projectID,
|
||||
worktree: directory,
|
||||
vcs: "git",
|
||||
name: "terminal-composer-focus",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
sandboxes: [],
|
||||
},
|
||||
provider: {
|
||||
all: [
|
||||
{
|
||||
id: "opencode",
|
||||
name: "OpenCode",
|
||||
models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } },
|
||||
},
|
||||
],
|
||||
connected: ["opencode"],
|
||||
default: { providerID: "opencode", modelID: "test" },
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: sessionID,
|
||||
slug: "terminal-composer-focus",
|
||||
projectID,
|
||||
directory,
|
||||
title: "Terminal composer focus",
|
||||
version: "dev",
|
||||
time: { created: 1700000000000, updated: 1700000000000 },
|
||||
},
|
||||
],
|
||||
pageMessages: () => ({ items: [] }),
|
||||
})
|
||||
await page.route("**/pty", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ id: ptyID, title: "Terminal 1" }),
|
||||
}),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}`, (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" }),
|
||||
)
|
||||
await page.route(`**/pty/${ptyID}/connect-token*`, (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: { "access-control-allow-origin": "*" },
|
||||
body: JSON.stringify({ ticket: "e2e-ticket" }),
|
||||
}),
|
||||
)
|
||||
await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined)
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
})
|
||||
|
||||
await page.goto(`/${base64Encode(directory)}/session/${sessionID}`)
|
||||
await expectSessionTitle(page, "Terminal composer focus")
|
||||
|
||||
const composer = page.locator('[data-component="prompt-input"]')
|
||||
const terminal = page.locator('[data-component="terminal"]')
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(terminal).toBeVisible()
|
||||
await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true)
|
||||
|
||||
await page.keyboard.type("x")
|
||||
await expect(composer).toHaveText("")
|
||||
|
||||
await page.waitForTimeout(300)
|
||||
await page.evaluate(() => (document.activeElement as HTMLElement | null)?.blur())
|
||||
await page.keyboard.type("a")
|
||||
|
||||
await expect(composer).toBeFocused()
|
||||
await expect(composer).toHaveText("a")
|
||||
})
|
||||
@@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
|
||||
const sessionID = "ses_hidden_terminal_regression"
|
||||
const title = "Hidden terminal regression"
|
||||
|
||||
test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
|
||||
test("unmounts the terminal panel while it is hidden", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1400, height: 900 })
|
||||
await mockOpenCodeServer(page, {
|
||||
directory,
|
||||
@@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toHaveAttribute("aria-hidden", "true")
|
||||
await expect(panel).toHaveCount(0)
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.setViewportSize({ width: 1200, height: 700 })
|
||||
await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
|
||||
|
||||
await page.keyboard.press("Control+Backquote")
|
||||
await expect(panel).toBeVisible()
|
||||
await expect(page.locator('[data-component="terminal"]')).toBeVisible()
|
||||
})
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"./performance/timeline-stability/fixture.test.ts",
|
||||
"./performance/timeline-stability/fixture.ts",
|
||||
"./performance/unit/visual-stability.test.ts",
|
||||
"./regression/new-session-panel-corner.spec.ts",
|
||||
"./regression/session-timeline-context-resize.spec.ts",
|
||||
"./utils/**/*.ts"
|
||||
]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Page, Route } from "@playwright/test"
|
||||
|
||||
const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
|
||||
const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
|
||||
|
||||
export interface MockServerConfig {
|
||||
provider: unknown
|
||||
@@ -11,7 +11,10 @@ export interface MockServerConfig {
|
||||
pageMessages: (sessionId: string, limit: number, before?: string) => { items: unknown[]; cursor?: string }
|
||||
vcsDiff?: unknown[]
|
||||
messageDelay?: number
|
||||
beforeMessagesResponse?: (input: { sessionID: string; before?: string }) => Promise<void>
|
||||
onMessages?: (input: { sessionID: string; before?: string; phase: "start" | "end" }) => void
|
||||
message?: (sessionID: string, messageID: string) => unknown
|
||||
onMessage?: (input: { sessionID: string; messageID: string }) => void
|
||||
events?: () => unknown[]
|
||||
eventRetry?: number
|
||||
todos?: (sessionID: string) => unknown[]
|
||||
@@ -52,6 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const path = url.pathname
|
||||
if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
|
||||
if (path === "/global/health") return json(route, { healthy: true })
|
||||
if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
|
||||
if (path === "/permission")
|
||||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
@@ -62,6 +66,14 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/file/content" && config.fileContent)
|
||||
return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
|
||||
if (path === "/api/reference")
|
||||
return json(route, {
|
||||
location: {
|
||||
directory: config.directory,
|
||||
project: { id: (config.project as { id?: string }).id, directory: config.directory },
|
||||
},
|
||||
data: [],
|
||||
})
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
if (path in staticRoutes) return json(route, staticRoutes[path])
|
||||
@@ -72,6 +84,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
return json(route, session ?? {})
|
||||
}
|
||||
|
||||
const projectMatch = path.match(/^\/project\/([^/]+)$/)
|
||||
if (projectMatch) return json(route, config.project)
|
||||
|
||||
const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
|
||||
if (messageMatch) {
|
||||
config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const message = config.message?.(messageMatch[1]!, messageMatch[2]!)
|
||||
if (message === undefined) return json(route, { error: "Message not found" }, undefined, 404)
|
||||
return json(route, message)
|
||||
}
|
||||
|
||||
const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/)
|
||||
if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? [])
|
||||
if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, [])
|
||||
@@ -82,7 +106,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
const before = token ? cursors.get(token) : undefined
|
||||
if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "start" })
|
||||
if (config.messageDelay) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
await config.beforeMessagesResponse?.({ sessionID: messagesMatch[1]!, before })
|
||||
if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay))
|
||||
const limit = Number(url.searchParams.get("limit") ?? 80)
|
||||
const pageData = config.pageMessages(messagesMatch[1], limit, before)
|
||||
config.onMessages?.({ sessionID: messagesMatch[1], before, phase: "end" })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
+53
-34
@@ -126,10 +126,10 @@ function SelectedServerProviders(props: ParentProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function LegacyServerLayout(props: ParentProps) {
|
||||
function LegacyServerLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
||||
<LegacyServerScopedShell serverScoped={props.serverScoped}>{props.children}</LegacyServerScopedShell>
|
||||
</SelectedServerProviders>
|
||||
)
|
||||
}
|
||||
@@ -263,12 +263,14 @@ function DesktopCommands() {
|
||||
type ServerScopedShellProps = ParentProps<{
|
||||
directory?: () => string | undefined
|
||||
sessionID?: () => string | undefined
|
||||
serverScoped?: JSX.Element
|
||||
}>
|
||||
|
||||
function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<PermissionProvider directory={props.directory}>
|
||||
<LayoutProvider>
|
||||
{props.serverScoped}
|
||||
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
|
||||
</LayoutProvider>
|
||||
</PermissionProvider>
|
||||
@@ -277,16 +279,16 @@ function ServerScopedProviders(props: ServerScopedShellProps) {
|
||||
|
||||
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||
return (
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID} serverScoped={props.serverScoped}>
|
||||
<LegacyLayout>{props.children}</LegacyLayout>
|
||||
</ServerScopedProviders>
|
||||
)
|
||||
}
|
||||
|
||||
function NewAppLayout(props: ParentProps) {
|
||||
function NewAppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
|
||||
return (
|
||||
<SelectedServerProviders>
|
||||
<ServerScopedProviders>
|
||||
<ServerScopedProviders serverScoped={props.serverScoped}>
|
||||
<NewLayout>{props.children}</NewLayout>
|
||||
</ServerScopedProviders>
|
||||
</SelectedServerProviders>
|
||||
@@ -347,7 +349,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
|
||||
)
|
||||
}
|
||||
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
|
||||
const server = useServer()
|
||||
const checkServerHealth = useCheckServerHealth()
|
||||
|
||||
@@ -376,34 +378,45 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) {
|
||||
const checking = createMemo(
|
||||
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
|
||||
)
|
||||
const [startup] = createResource(async () => {
|
||||
if (!props.startup) return true
|
||||
await props.startup.catch((error) => {
|
||||
console.error("[startup] startup gate failed", error)
|
||||
})
|
||||
return true
|
||||
})
|
||||
const startupChecking = createMemo(
|
||||
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
|
||||
)
|
||||
const loading = createMemo(() => checking() || startupChecking())
|
||||
|
||||
return (
|
||||
<Show
|
||||
when={!checking()}
|
||||
fallback={
|
||||
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base">
|
||||
<>
|
||||
<Show when={!checking()}>
|
||||
<Show
|
||||
when={startupHealthCheck.latest}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
void healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
<Show when={loading()}>
|
||||
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
|
||||
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={startupHealthCheck.latest}
|
||||
fallback={
|
||||
<ConnectionError
|
||||
onRetry={() => {
|
||||
if (checkMode() === "background") void healthCheckActions.refetch()
|
||||
}}
|
||||
onServerSelected={(key) => {
|
||||
setCheckMode("blocking")
|
||||
server.setActive(key)
|
||||
void healthCheckActions.refetch()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</Show>
|
||||
</Show>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -470,6 +483,8 @@ export function AppInterface(props: {
|
||||
servers?: Array<ServerConnection.Any>
|
||||
router?: Component<BaseRouterProps>
|
||||
disableHealthCheck?: boolean
|
||||
startup?: Promise<void>
|
||||
serverScoped?: JSX.Element
|
||||
}) {
|
||||
// The visual new layout lives in the router root so it remains mounted across
|
||||
// route changes. Draft and session routes override only their server-bound data
|
||||
@@ -491,7 +506,7 @@ export function AppInterface(props: {
|
||||
>
|
||||
<GlobalProvider>
|
||||
<SettingsProvider>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
|
||||
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||
<Dynamic
|
||||
component={props.router ?? Router}
|
||||
@@ -500,14 +515,14 @@ export function AppInterface(props: {
|
||||
<NotificationProvider>
|
||||
<ServerShell>
|
||||
<Show when={useSettings().general.newLayoutDesigns()} fallback={routerProps.children}>
|
||||
<NewAppLayout>{routerProps.children}</NewAppLayout>
|
||||
<NewAppLayout serverScoped={props.serverScoped}>{routerProps.children}</NewAppLayout>
|
||||
</Show>
|
||||
</ServerShell>
|
||||
</NotificationProvider>
|
||||
</TabsProvider>
|
||||
)}
|
||||
>
|
||||
<Routes />
|
||||
<Routes serverScoped={props.serverScoped} />
|
||||
</Dynamic>
|
||||
</Show>
|
||||
</ConnectionGate>
|
||||
@@ -517,12 +532,16 @@ export function AppInterface(props: {
|
||||
)
|
||||
}
|
||||
|
||||
function Routes() {
|
||||
function Routes(props: { serverScoped?: JSX.Element }) {
|
||||
const settings = useSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Route component={LegacyServerLayout}>
|
||||
<Route
|
||||
component={(routeProps) => (
|
||||
<LegacyServerLayout serverScoped={props.serverScoped}>{routeProps.children}</LegacyServerLayout>
|
||||
)}
|
||||
>
|
||||
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||
<Route path="/:dir" component={DirectoryLayout}>
|
||||
<Route path="/" component={() => <Navigate href="session" />} />
|
||||
|
||||
@@ -7,29 +7,174 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { List, type ListRef } from "@opencode-ai/ui/list"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { Spinner } from "@opencode-ai/ui/spinner"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js"
|
||||
import {
|
||||
type Accessor,
|
||||
type Component,
|
||||
createEffect,
|
||||
createMemo,
|
||||
createResource,
|
||||
Match,
|
||||
onCleanup,
|
||||
onMount,
|
||||
Show,
|
||||
Switch,
|
||||
} from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { CustomProviderForm } from "./dialog-custom-provider"
|
||||
|
||||
export function DialogConnectProvider(props: { provider: string; directory?: Accessor<string | undefined> }) {
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
export function useProviderConnectController(options: { onBack?: () => void } = {}) {
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const reset = () => setStore("selected", undefined)
|
||||
|
||||
return {
|
||||
selected: () => store.selected,
|
||||
select: (provider?: string) => setStore("selected", provider),
|
||||
back: options.onBack ?? reset,
|
||||
}
|
||||
}
|
||||
|
||||
export const DialogConnectProvider: Component<{
|
||||
directory?: Accessor<string | undefined>
|
||||
controller?: ReturnType<typeof useProviderConnectController>
|
||||
}> = (props) => {
|
||||
const fallback = useProviderConnectController()
|
||||
const controller = props.controller ?? fallback
|
||||
const language = useLanguage()
|
||||
const reset = controller.back
|
||||
const back = { current: reset }
|
||||
const select = (provider?: string) => {
|
||||
back.current = reset
|
||||
controller.select(provider)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
class="h-full"
|
||||
transition
|
||||
title={
|
||||
<Show when={controller.selected()} fallback={language.t("command.provider.connect")}>
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={() => back.current()}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Switch>
|
||||
<Match when={controller.selected() === CUSTOM_ID}>
|
||||
<CustomProviderForm />
|
||||
</Match>
|
||||
<Match when={controller.selected() && controller.selected() !== CUSTOM_ID ? controller.selected() : undefined}>
|
||||
{(provider) => (
|
||||
<ProviderConnection
|
||||
provider={provider()}
|
||||
directory={props.directory}
|
||||
onBack={reset}
|
||||
setBack={(handler) => (back.current = handler)}
|
||||
/>
|
||||
)}
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<ProviderPicker directory={props.directory} onSelect={select} />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderPicker(props: { directory?: Accessor<string | undefined>; onSelect: (provider: string) => void }) {
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||
const note = (id: string) => {
|
||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
||||
return undefined
|
||||
}
|
||||
|
||||
return (
|
||||
<List
|
||||
class="px-3"
|
||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.provider.empty")}
|
||||
activeIcon="plus-small"
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
sortBy={(a, b) => {
|
||||
if (a.id === CUSTOM_ID) return -1
|
||||
if (b.id === CUSTOM_ID) return 1
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const popular = popularGroup()
|
||||
if (a.category === popular && b.category !== popular) return -1
|
||||
if (b.category === popular && a.category !== popular) return 1
|
||||
return 0
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
props.onSelect(x.id)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||
</Show>
|
||||
<Show when={i.id === CUSTOM_ID}>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||
<Show when={i.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
function ProviderConnection(props: {
|
||||
provider: string
|
||||
directory?: Accessor<string | undefined>
|
||||
onBack: () => void
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const providers = useProviders(props.directory)
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={props.directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
const alive = { value: true }
|
||||
const timer = { current: undefined as ReturnType<typeof setTimeout> | undefined }
|
||||
|
||||
@@ -364,21 +509,15 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (methods().length === 1) {
|
||||
all()
|
||||
return
|
||||
}
|
||||
if (store.authorization) {
|
||||
if (methods().length > 1 && store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
if (store.methodIndex !== undefined) {
|
||||
dispatch({ type: "method.reset" })
|
||||
return
|
||||
}
|
||||
all()
|
||||
props.onBack()
|
||||
}
|
||||
|
||||
props.setBack(goBack)
|
||||
|
||||
function MethodSelection() {
|
||||
return (
|
||||
<>
|
||||
@@ -599,79 +738,67 @@ export function DialogConnectProvider(props: { provider: string; directory?: Acc
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id={props.provider} class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">
|
||||
<Switch>
|
||||
<Match when={props.provider === "anthropic" && method()?.label?.toLowerCase().includes("max")}>
|
||||
{language.t("provider.connect.title.anthropicProMax")}
|
||||
</Match>
|
||||
<Match when={true}>{language.t("provider.connect.title", { provider: provider().name })}</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<div class="px-2.5 pb-10 flex flex-col gap-6">
|
||||
<div onKeyDown={handleKey} tabIndex={0} autofocus={store.methodIndex === undefined ? true : undefined}>
|
||||
<Switch>
|
||||
<Match when={loading()}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.methodIndex === undefined}>
|
||||
<MethodSelection />
|
||||
</Match>
|
||||
<Match when={store.state === "pending"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Spinner />
|
||||
<span>{language.t("provider.connect.status.inProgress")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={store.state === "prompt"}>
|
||||
<AuthPromptsView />
|
||||
</Match>
|
||||
<Match when={store.state === "error"}>
|
||||
<div class="text-14-regular text-text-base">
|
||||
<div class="flex items-center gap-x-2">
|
||||
<Icon name="circle-ban-sign" class="text-icon-critical-base" />
|
||||
<span>{language.t("provider.connect.status.failed", { error: store.error ?? "" })}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Match>
|
||||
<Match when={method()?.type === "api"}>
|
||||
<ApiAuthView />
|
||||
</Match>
|
||||
<Match when={method()?.type === "oauth"}>
|
||||
<Switch>
|
||||
<Match when={store.authorization?.method === "code"}>
|
||||
<OAuthCodeView />
|
||||
</Match>
|
||||
<Match when={store.authorization?.method === "auto"}>
|
||||
<OAuthAutoView />
|
||||
</Match>
|
||||
</Switch>
|
||||
</Match>
|
||||
</Switch>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,21 +6,41 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { type Accessor, batch, For } from "solid-js"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { Link } from "@/components/link"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
|
||||
type Props = {
|
||||
back?: "providers" | "close"
|
||||
directory?: Accessor<string | undefined>
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
export function DialogCustomProvider(props: Props) {
|
||||
const language = useLanguage()
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
class="h-full"
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={props.onBack}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
}
|
||||
transition
|
||||
>
|
||||
<CustomProviderForm />
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export function CustomProviderForm() {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -36,14 +56,6 @@ export function DialogCustomProvider(props: Props) {
|
||||
err: {},
|
||||
})
|
||||
|
||||
const goBack = () => {
|
||||
if (props.back === "close") {
|
||||
dialog.close()
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogSelectProvider directory={props.directory} />)
|
||||
}
|
||||
|
||||
const addModel = () => {
|
||||
setForm(
|
||||
"models",
|
||||
@@ -163,168 +175,155 @@ export function DialogCustomProvider(props: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
title={
|
||||
<IconButton
|
||||
tabIndex={-1}
|
||||
icon="arrow-left"
|
||||
variant="ghost"
|
||||
onClick={goBack}
|
||||
aria-label={language.t("common.goBack")}
|
||||
/>
|
||||
}
|
||||
transition
|
||||
>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
<div class="flex flex-col gap-6 px-2.5 pb-3 overflow-y-auto max-h-[60vh]">
|
||||
<div class="px-2.5 flex gap-4 items-center">
|
||||
<ProviderIcon id="synthetic" class="size-5 shrink-0 icon-strong-base" />
|
||||
<div class="text-16-medium text-text-strong">{language.t("provider.custom.title")}</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||
<p class="text-14-regular text-text-base">
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</Link>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save} class="px-2.5 pb-6 flex flex-col gap-6">
|
||||
<p class="text-14-regular text-text-base">
|
||||
{language.t("provider.custom.description.prefix")}
|
||||
<Link href="https://opencode.ai/docs/providers/#custom-provider" tabIndex={-1}>
|
||||
{language.t("provider.custom.description.link")}
|
||||
</Link>
|
||||
{language.t("provider.custom.description.suffix")}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<TextField
|
||||
autofocus
|
||||
label={language.t("provider.custom.field.providerID.label")}
|
||||
placeholder={language.t("provider.custom.field.providerID.placeholder")}
|
||||
description={language.t("provider.custom.field.providerID.description")}
|
||||
value={form.providerID}
|
||||
onChange={(v) => setField("providerID", v)}
|
||||
validationState={form.err.providerID ? "invalid" : undefined}
|
||||
error={form.err.providerID}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.name.label")}
|
||||
placeholder={language.t("provider.custom.field.name.placeholder")}
|
||||
value={form.name}
|
||||
onChange={(v) => setField("name", v)}
|
||||
validationState={form.err.name ? "invalid" : undefined}
|
||||
error={form.err.name}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.baseURL.label")}
|
||||
placeholder={language.t("provider.custom.field.baseURL.placeholder")}
|
||||
value={form.baseURL}
|
||||
onChange={(v) => setField("baseURL", v)}
|
||||
validationState={form.err.baseURL ? "invalid" : undefined}
|
||||
error={form.err.baseURL}
|
||||
/>
|
||||
<TextField
|
||||
label={language.t("provider.custom.field.apiKey.label")}
|
||||
placeholder={language.t("provider.custom.field.apiKey.placeholder")}
|
||||
description={language.t("provider.custom.field.apiKey.description")}
|
||||
value={form.apiKey}
|
||||
onChange={(v) => setField("apiKey", v)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.name.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeModel(i())}
|
||||
disabled={form.models.length <= 1}
|
||||
aria-label={language.t("provider.custom.models.remove")}
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.models.label")}</label>
|
||||
<For each={form.models}>
|
||||
{(m, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={m.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.id.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.id.placeholder")}
|
||||
value={m.id}
|
||||
onChange={(v) => setModel(i(), "id", v)}
|
||||
validationState={m.err.id ? "invalid" : undefined}
|
||||
error={m.err.id}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||
{language.t("provider.custom.models.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.value.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeHeader(i())}
|
||||
disabled={form.headers.length <= 1}
|
||||
aria-label={language.t("provider.custom.headers.remove")}
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.models.name.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.models.name.placeholder")}
|
||||
value={m.name}
|
||||
onChange={(v) => setModel(i(), "name", v)}
|
||||
validationState={m.err.name ? "invalid" : undefined}
|
||||
error={m.err.name}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||
{language.t("provider.custom.headers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-auto self-start"
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="primary"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeModel(i())}
|
||||
disabled={form.models.length <= 1}
|
||||
aria-label={language.t("provider.custom.models.remove")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addModel} class="self-start">
|
||||
{language.t("provider.custom.models.add")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<label class="text-12-medium text-text-weak">{language.t("provider.custom.headers.label")}</label>
|
||||
<For each={form.headers}>
|
||||
{(h, i) => (
|
||||
<div class="flex gap-2 items-start" data-row={h.row}>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.key.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.key.placeholder")}
|
||||
value={h.key}
|
||||
onChange={(v) => setHeader(i(), "key", v)}
|
||||
validationState={h.err.key ? "invalid" : undefined}
|
||||
error={h.err.key}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<TextField
|
||||
label={language.t("provider.custom.headers.value.label")}
|
||||
hideLabel
|
||||
placeholder={language.t("provider.custom.headers.value.placeholder")}
|
||||
value={h.value}
|
||||
onChange={(v) => setHeader(i(), "value", v)}
|
||||
validationState={h.err.value ? "invalid" : undefined}
|
||||
error={h.err.value}
|
||||
/>
|
||||
</div>
|
||||
<IconButton
|
||||
type="button"
|
||||
icon="trash"
|
||||
variant="ghost"
|
||||
class="mt-1.5"
|
||||
onClick={() => removeHeader(i())}
|
||||
disabled={form.headers.length <= 1}
|
||||
aria-label={language.t("provider.custom.headers.remove")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<Button type="button" size="small" variant="ghost" icon="plus-small" onClick={addHeader} class="self-start">
|
||||
{language.t("provider.custom.headers.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
class="w-auto self-start"
|
||||
type="submit"
|
||||
size="large"
|
||||
variant="primary"
|
||||
disabled={saveMutation.isPending}
|
||||
>
|
||||
{saveMutation.isPending ? language.t("common.saving") : language.t("common.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import { useLocal } from "@/context/local"
|
||||
import { popularProviders } from "@/hooks/use-providers"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||
import { SettingsRowV2 } from "./settings-v2/parts/row"
|
||||
@@ -31,7 +31,7 @@ export const DialogManageModels: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
}
|
||||
const providerRank = (id: string) => popularProviders.indexOf(id)
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
@@ -123,7 +123,7 @@ export const DialogManageModelsV2: Component = () => {
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
dialog.show(() => <DialogSelectProvider directory={directory} />)
|
||||
void dialog.show(() => <DialogConnectProvider directory={directory} />)
|
||||
}
|
||||
const providerList = (providerID: string) => local.model.list().filter((x) => x.provider.id === providerID)
|
||||
const providerVisible = (providerID: string) =>
|
||||
|
||||
@@ -22,17 +22,16 @@ export const DialogSelectModelUnpaid: Component<{ model?: ModelState }> = (props
|
||||
const providers = useProviders(directory)
|
||||
const language = useLanguage()
|
||||
|
||||
const connect = (provider: string) => {
|
||||
const openProviders = (provider?: string) => {
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogConnectProvider provider={provider} directory={directory} />)
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select(provider)
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
const all = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
const connect = (provider: string) => openProviders(provider)
|
||||
const all = () => openProviders()
|
||||
|
||||
let listRef: ListRef | undefined
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
|
||||
@@ -155,8 +155,8 @@ export function ModelSelectorPopover(props: {
|
||||
|
||||
const handleConnectProvider = () => {
|
||||
close("provider")
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
const language = useLanguage()
|
||||
@@ -503,8 +503,8 @@ export const DialogSelectModel: Component<{ provider?: string; model?: ModelStat
|
||||
const directory = () => decode64(local.slug())
|
||||
|
||||
const provider = () => {
|
||||
void import("./dialog-select-provider").then((x) => {
|
||||
dialog.show(() => <x.DialogSelectProvider directory={directory} />)
|
||||
void import("./dialog-connect-provider").then((x) => {
|
||||
void dialog.show(() => <x.DialogConnectProvider directory={directory} />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { type Accessor, Component, Show } from "solid-js"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Tag } from "@opencode-ai/ui/tag"
|
||||
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
|
||||
const CUSTOM_ID = "_custom"
|
||||
|
||||
export const DialogSelectProvider: Component<{ directory?: Accessor<string | undefined> }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const providers = useProviders(props.directory)
|
||||
const language = useLanguage()
|
||||
|
||||
const popularGroup = () => language.t("dialog.provider.group.popular")
|
||||
const otherGroup = () => language.t("dialog.provider.group.other")
|
||||
const customLabel = () => language.t("settings.providers.tag.custom")
|
||||
const note = (id: string) => {
|
||||
if (id === "anthropic") return language.t("dialog.provider.anthropic.note")
|
||||
if (id === "openai") return language.t("dialog.provider.openai.note")
|
||||
if (id.startsWith("github-copilot")) return language.t("dialog.provider.copilot.note")
|
||||
if (id === "opencode-go") return language.t("dialog.provider.opencodeGo.tagline")
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog title={language.t("command.provider.connect")} transition>
|
||||
<List
|
||||
class="px-3"
|
||||
search={{ placeholder: language.t("dialog.provider.search.placeholder"), autofocus: true }}
|
||||
emptyMessage={language.t("dialog.provider.empty")}
|
||||
activeIcon="plus-small"
|
||||
key={(x) => x?.id}
|
||||
items={() => {
|
||||
language.locale()
|
||||
return [{ id: CUSTOM_ID, name: customLabel() }, ...providers.all().values()]
|
||||
}}
|
||||
filterKeys={["id", "name"]}
|
||||
groupBy={(x) => (popularProviders.includes(x.id) ? popularGroup() : otherGroup())}
|
||||
sortBy={(a, b) => {
|
||||
if (a.id === CUSTOM_ID) return -1
|
||||
if (b.id === CUSTOM_ID) return 1
|
||||
if (popularProviders.includes(a.id) && popularProviders.includes(b.id))
|
||||
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
|
||||
return a.name.localeCompare(b.name)
|
||||
}}
|
||||
sortGroupsBy={(a, b) => {
|
||||
const popular = popularGroup()
|
||||
if (a.category === popular && b.category !== popular) return -1
|
||||
if (b.category === popular && a.category !== popular) return 1
|
||||
return 0
|
||||
}}
|
||||
onSelect={(x) => {
|
||||
if (!x) return
|
||||
if (x.id === CUSTOM_ID) {
|
||||
dialog.show(() => <DialogCustomProvider back="providers" directory={props.directory} />)
|
||||
return
|
||||
}
|
||||
dialog.show(() => <DialogConnectProvider provider={x.id} directory={props.directory} />)
|
||||
}}
|
||||
>
|
||||
{(i) => (
|
||||
<div class="px-1.25 w-full flex items-center gap-x-3">
|
||||
<ProviderIcon data-slot="list-item-extra-icon" id={i.id} />
|
||||
<span>{i.name}</span>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<div class="text-14-regular text-text-weak">{language.t("dialog.provider.opencode.tagline")}</div>
|
||||
</Show>
|
||||
<Show when={i.id === CUSTOM_ID}>
|
||||
<Tag>{language.t("settings.providers.tag.custom")}</Tag>
|
||||
</Show>
|
||||
<Show when={i.id === "opencode"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
<Show when={note(i.id)}>{(value) => <div class="text-14-regular text-text-weak">{value()}</div>}</Show>
|
||||
<Show when={i.id === "opencode-go"}>
|
||||
<Tag>{language.t("dialog.provider.tag.recommended")}</Tag>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</List>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, createSignal, startTransition } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { SettingsGeneral } from "./settings-general"
|
||||
import { SettingsKeybinds } from "./settings-keybinds"
|
||||
import { SettingsProviders } from "./settings-providers"
|
||||
import { SettingsModels } from "./settings-models"
|
||||
import { SettingsServers } from "./settings-servers"
|
||||
|
||||
export const DialogSettings: Component = () => {
|
||||
export const DialogSettings: Component<{ defaultValue?: string }> = (props) => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
|
||||
const showProviders = () => {
|
||||
void dialog.show(() => <DialogSettings defaultValue="providers" />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" transition>
|
||||
<Tabs orientation="vertical" variant="settings" defaultValue="general" class="h-full settings-dialog">
|
||||
<Tabs
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="h-full settings-dialog"
|
||||
>
|
||||
<Tabs.List>
|
||||
<div class="flex flex-col justify-between h-full w-full gap-4">
|
||||
<div class="flex flex-col gap-3 w-full pt-3">
|
||||
@@ -70,7 +83,7 @@ export const DialogSettings: Component = () => {
|
||||
<SettingsServers />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="no-scrollbar">
|
||||
<SettingsProviders />
|
||||
<SettingsProviders onBack={showProviders} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="models" class="no-scrollbar">
|
||||
<SettingsModels />
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2 } from "./file-tree-v2-model"
|
||||
|
||||
describe("file tree v2 model", () => {
|
||||
test("builds sorted depth-first rows", () => {
|
||||
const model = buildFileTreeV2Model(["src/z.ts", "src/lib/b.ts", "src/lib/a.ts", "README.md", "docs/guide.md"])
|
||||
|
||||
expect(model.total).toBe(8)
|
||||
expect(flattenFileTreeV2(model, () => true).map((row) => [row.node.path, row.node.type, row.level])).toEqual([
|
||||
["docs", "directory", 0],
|
||||
["docs/guide.md", "file", 1],
|
||||
["src", "directory", 0],
|
||||
["src/lib", "directory", 1],
|
||||
["src/lib/a.ts", "file", 2],
|
||||
["src/lib/b.ts", "file", 2],
|
||||
["src/z.ts", "file", 1],
|
||||
["README.md", "file", 0],
|
||||
])
|
||||
})
|
||||
|
||||
test("omits descendants of collapsed directories", () => {
|
||||
const model = buildFileTreeV2Model(["src/lib/a.ts", "src/z.ts"])
|
||||
|
||||
expect(flattenFileTreeV2(model, (path) => path !== "src/lib").map((row) => row.node.path)).toEqual([
|
||||
"src",
|
||||
"src/lib",
|
||||
"src/z.ts",
|
||||
])
|
||||
})
|
||||
|
||||
test("normalizes separators and duplicate paths", () => {
|
||||
const model = buildFileTreeV2Model(["src\\lib\\a.ts", "src/lib/a.ts", "/src//lib/b.ts/"])
|
||||
const rows = flattenFileTreeV2(model, () => true)
|
||||
|
||||
expect(model.total).toBe(4)
|
||||
expect(rows.map((row) => row.node.path)).toEqual(["src", "src/lib", "src/lib/a.ts", "src/lib/b.ts"])
|
||||
expect(rows.find((row) => row.node.path === "src/lib/a.ts")?.node.originalPath).toBe("src\\lib\\a.ts")
|
||||
})
|
||||
|
||||
test("supports paths deeper than the legacy recursion limit", () => {
|
||||
const file = `${Array.from({ length: 130 }, (_, index) => `dir-${index}`).join("/")}/file.ts`
|
||||
const model = buildFileTreeV2Model([file])
|
||||
|
||||
expect(flattenFileTreeV2(model, () => true)).toHaveLength(131)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type FileTreeV2Model = {
|
||||
children: ReadonlyMap<string, readonly FileTreeV2Node[]>
|
||||
total: number
|
||||
}
|
||||
|
||||
export type FileTreeV2Node = FileNode & { originalPath: string }
|
||||
|
||||
export type FileTreeV2Row = {
|
||||
node: FileTreeV2Node
|
||||
level: number
|
||||
}
|
||||
|
||||
export function normalizeFileTreeV2Path(value: string) {
|
||||
return value
|
||||
.replaceAll("\\", "/")
|
||||
.replace(/^\/+|\/+$/g, "")
|
||||
.replace(/\/{2,}/g, "/")
|
||||
}
|
||||
|
||||
export function buildFileTreeV2Model(paths: readonly string[]): FileTreeV2Model {
|
||||
const nodes = new Map<string, FileTreeV2Node>()
|
||||
|
||||
paths.forEach((value) => {
|
||||
const file = normalizeFileTreeV2Path(value)
|
||||
if (!file) return
|
||||
|
||||
const parts = file.split("/")
|
||||
parts.forEach((name, index) => {
|
||||
const path = parts.slice(0, index + 1).join("/")
|
||||
if (nodes.has(path)) return
|
||||
nodes.set(path, {
|
||||
name,
|
||||
path,
|
||||
absolute: path,
|
||||
type: index === parts.length - 1 ? "file" : "directory",
|
||||
ignored: false,
|
||||
originalPath: index === parts.length - 1 ? value : path,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const children = new Map<string, FileTreeV2Node[]>()
|
||||
nodes.forEach((node) => {
|
||||
const index = node.path.lastIndexOf("/")
|
||||
const parent = index === -1 ? "" : node.path.slice(0, index)
|
||||
const list = children.get(parent)
|
||||
if (list) list.push(node)
|
||||
else children.set(parent, [node])
|
||||
})
|
||||
children.forEach((nodes) =>
|
||||
nodes.sort((a, b) => {
|
||||
if (a.type !== b.type) return a.type === "directory" ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
}),
|
||||
)
|
||||
|
||||
return { children, total: nodes.size }
|
||||
}
|
||||
|
||||
export function flattenFileTreeV2(model: FileTreeV2Model, expanded: (path: string) => boolean) {
|
||||
const rows: FileTreeV2Row[] = []
|
||||
const stack = (model.children.get("") ?? []).toReversed().map((node) => ({ node, level: 0 }))
|
||||
|
||||
while (stack.length > 0) {
|
||||
const row = stack.pop()!
|
||||
rows.push(row)
|
||||
if (row.node.type !== "directory" || !expanded(row.node.path)) continue
|
||||
const children = model.children.get(row.node.path) ?? []
|
||||
for (let index = children.length - 1; index >= 0; index--) {
|
||||
stack.push({ node: children[index]!, level: row.level + 1 })
|
||||
}
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
@@ -1,95 +1,26 @@
|
||||
import { useFile } from "@/context/file"
|
||||
import { Collapsible } from "@opencode-ai/ui/collapsible"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import {
|
||||
createEffect,
|
||||
createMemo,
|
||||
createSignal,
|
||||
For,
|
||||
Match,
|
||||
on,
|
||||
Show,
|
||||
splitProps,
|
||||
Switch,
|
||||
untrack,
|
||||
type ComponentProps,
|
||||
type ParentProps,
|
||||
} from "solid-js"
|
||||
import { Dynamic } from "solid-js/web"
|
||||
import type { FileNode } from "@opencode-ai/sdk/v2"
|
||||
import { Icon } from "@opencode-ai/ui/v2/icon"
|
||||
import {
|
||||
dirsToExpand,
|
||||
pathToFileUrl,
|
||||
shouldListRoot,
|
||||
visibleKind,
|
||||
withFileDragImage,
|
||||
type Filter,
|
||||
type Kind,
|
||||
} from "@/components/file-tree"
|
||||
import { pathToFileUrl, withFileDragImage, type Kind } from "@/components/file-tree"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { buildFileTreeV2Model, flattenFileTreeV2, normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
||||
|
||||
export type { Kind } from "@/components/file-tree"
|
||||
|
||||
const MAX_DEPTH = 128
|
||||
|
||||
function visibleNodesForPath(path: string, children: (dir: string) => FileNode[], current: Filter | undefined) {
|
||||
const nodes = children(path)
|
||||
if (!current) return nodes
|
||||
|
||||
const parent = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
if (idx === -1) return ""
|
||||
return item.slice(0, idx)
|
||||
}
|
||||
|
||||
const leaf = (item: string) => {
|
||||
const idx = item.lastIndexOf("/")
|
||||
return idx === -1 ? item : item.slice(idx + 1)
|
||||
}
|
||||
|
||||
const out = nodes.filter((node) => {
|
||||
if (node.type === "file") return current.files.has(node.path)
|
||||
return current.dirs.has(node.path)
|
||||
})
|
||||
|
||||
const seen = new Set(out.map((node) => node.path))
|
||||
|
||||
for (const dir of current.dirs) {
|
||||
if (parent(dir) !== path) continue
|
||||
if (seen.has(dir)) continue
|
||||
out.push({
|
||||
name: leaf(dir),
|
||||
path: dir,
|
||||
absolute: dir,
|
||||
type: "directory",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(dir)
|
||||
}
|
||||
|
||||
for (const item of current.files) {
|
||||
if (parent(item) !== path) continue
|
||||
if (seen.has(item)) continue
|
||||
out.push({
|
||||
name: leaf(item),
|
||||
path: item,
|
||||
absolute: item,
|
||||
type: "file",
|
||||
ignored: false,
|
||||
})
|
||||
seen.add(item)
|
||||
}
|
||||
|
||||
out.sort((a, b) => {
|
||||
if (a.type !== b.type) {
|
||||
return a.type === "directory" ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
const INDENT_STEP = 16
|
||||
|
||||
function rowPaddingLeft(level: number, type: FileNode["type"]) {
|
||||
@@ -123,7 +54,6 @@ const FileTreeNodeV2 = (
|
||||
active?: string
|
||||
draggable: boolean
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
marks?: Set<string>
|
||||
as?: "div" | "button"
|
||||
},
|
||||
) => {
|
||||
@@ -133,18 +63,18 @@ const FileTreeNodeV2 = (
|
||||
"active",
|
||||
"draggable",
|
||||
"kinds",
|
||||
"marks",
|
||||
"as",
|
||||
"children",
|
||||
"class",
|
||||
"classList",
|
||||
])
|
||||
const kind = () => visibleKind(local.node, local.kinds, local.marks)
|
||||
const kind = () => local.kinds?.get(local.node.path)
|
||||
|
||||
return (
|
||||
<Dynamic
|
||||
component={local.as ?? "div"}
|
||||
data-slot="file-tree-v2-row"
|
||||
data-path={local.node.path}
|
||||
data-selected={local.node.path === local.active ? "" : undefined}
|
||||
data-ignored={local.node.ignored ? "" : undefined}
|
||||
classList={{
|
||||
@@ -177,244 +107,161 @@ const FileTreeNodeV2 = (
|
||||
)
|
||||
}
|
||||
|
||||
// V2-styled fork of FileTree for the review sidebar. Unlike the v1 tree it never
|
||||
// lists unloaded subdirectories, so callers must pass `allowed` (nodes are
|
||||
// synthesized from that list) for nested content to appear.
|
||||
function GuideLines(props: { level: number }) {
|
||||
return (
|
||||
<For each={Array.from({ length: props.level })}>
|
||||
{(_, index) => (
|
||||
<div
|
||||
class="absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 group-hover/file-tree-v2:opacity-50"
|
||||
style={`left: ${guideLineLeft(index())}px`}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileTreeV2(props: {
|
||||
path: string
|
||||
active?: string
|
||||
level?: number
|
||||
allowed?: readonly string[]
|
||||
kinds?: ReadonlyMap<string, Kind>
|
||||
draggable?: boolean
|
||||
onFileClick?: (file: FileNode) => void
|
||||
|
||||
_filter?: Filter
|
||||
_marks?: Set<string>
|
||||
_deeps?: Map<string, number>
|
||||
_kinds?: ReadonlyMap<string, Kind>
|
||||
_chain?: readonly string[]
|
||||
}) {
|
||||
const file = useFile()
|
||||
const level = props.level ?? 0
|
||||
const draggable = () => props.draggable ?? true
|
||||
|
||||
const key = (p: string) =>
|
||||
file
|
||||
.normalize(p)
|
||||
.replace(/[\\/]+$/, "")
|
||||
.replaceAll("\\", "/")
|
||||
const chain = props._chain ? [...props._chain, key(props.path)] : [key(props.path)]
|
||||
|
||||
const filter = createMemo(() => {
|
||||
if (props._filter) return props._filter
|
||||
|
||||
const allowed = props.allowed
|
||||
if (!allowed) return
|
||||
|
||||
const files = new Set(allowed)
|
||||
const dirs = new Set<string>()
|
||||
|
||||
for (const item of allowed) {
|
||||
const parts = item.split("/")
|
||||
const parents = parts.slice(0, -1)
|
||||
for (const [idx] of parents.entries()) {
|
||||
const dir = parents.slice(0, idx + 1).join("/")
|
||||
if (dir) dirs.add(dir)
|
||||
}
|
||||
}
|
||||
|
||||
return { files, dirs }
|
||||
const active = () => normalizeFileTreeV2Path(props.active ?? "")
|
||||
const model = createMemo(() => buildFileTreeV2Model(props.allowed ?? []))
|
||||
const rows = createMemo(() => flattenFileTreeV2(model(), (path) => file.tree.state(path)?.expanded ?? true))
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return rows().length
|
||||
},
|
||||
getScrollElement: () => virtualScrollElement(root()),
|
||||
initialRect: { width: 0, height: 600 },
|
||||
estimateSize: () => 28,
|
||||
gap: 2,
|
||||
overscan: 10,
|
||||
get getItemKey() {
|
||||
const current = rows()
|
||||
return (index: number) => current[index]?.node.path ?? index
|
||||
},
|
||||
rangeExtractor: (range) => {
|
||||
const indexes = defaultRangeExtractor(range)
|
||||
const path = focused()
|
||||
const index = path ? rows().findIndex((row) => row.node.path === path) : -1
|
||||
if (index < 0 || indexes.includes(index)) return indexes
|
||||
return [...indexes, index].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
|
||||
const marks = createMemo(() => {
|
||||
if (props._marks) return props._marks
|
||||
|
||||
const out = new Set<string>(props.kinds?.keys() ?? [])
|
||||
if (out.size === 0) return
|
||||
return out
|
||||
})
|
||||
|
||||
const kinds = createMemo(() => {
|
||||
if (props._kinds) return props._kinds
|
||||
return props.kinds
|
||||
})
|
||||
|
||||
const deeps = createMemo(() => {
|
||||
if (props._deeps) return props._deeps
|
||||
|
||||
const out = new Map<string, number>()
|
||||
|
||||
const root = props.path
|
||||
if (!(file.tree.state(root)?.expanded ?? false)) return out
|
||||
|
||||
const seen = new Set<string>()
|
||||
const stack: { dir: string; lvl: number; i: number; kids: string[]; max: number }[] = []
|
||||
|
||||
const push = (dir: string, lvl: number) => {
|
||||
const id = key(dir)
|
||||
if (seen.has(id)) return
|
||||
seen.add(id)
|
||||
|
||||
const kids = file.tree
|
||||
.children(dir)
|
||||
.filter((node) => node.type === "directory" && (file.tree.state(node.path)?.expanded ?? false))
|
||||
.map((node) => node.path)
|
||||
|
||||
stack.push({ dir, lvl, i: 0, kids, max: lvl })
|
||||
}
|
||||
|
||||
push(root, level - 1)
|
||||
|
||||
while (stack.length > 0) {
|
||||
const top = stack[stack.length - 1]!
|
||||
|
||||
if (top.i < top.kids.length) {
|
||||
const next = top.kids[top.i]!
|
||||
top.i++
|
||||
push(next, top.lvl + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
out.set(top.dir, top.max)
|
||||
stack.pop()
|
||||
|
||||
const parent = stack[stack.length - 1]
|
||||
if (!parent) continue
|
||||
parent.max = Math.max(parent.max, top.max)
|
||||
}
|
||||
|
||||
return out
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const current = filter()
|
||||
const dirs = dirsToExpand({
|
||||
level,
|
||||
filter: current,
|
||||
expanded: (dir) => untrack(() => file.tree.state(dir)?.expanded) ?? false,
|
||||
const path = active()
|
||||
if (!path) return
|
||||
const index = rows().findIndex((row) => row.node.path === path)
|
||||
if (index < 0) return
|
||||
queueMicrotask(() => {
|
||||
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
|
||||
virtualizer.scrollToIndex(index, { align: "auto" })
|
||||
})
|
||||
// Nodes come from the `allowed` filter; skip listing so directories that only
|
||||
// exist on the diff's base branch do not each fail with an error toast.
|
||||
for (const dir of dirs) file.tree.expand(dir, { list: false })
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => props.path,
|
||||
(path) => {
|
||||
const dir = untrack(() => file.tree.state(path))
|
||||
if (!shouldListRoot({ level, dir })) return
|
||||
void file.tree.list(path)
|
||||
},
|
||||
{ defer: false },
|
||||
),
|
||||
const rowByKey = createMemo(() => new Map(rows().map((row) => [row.node.path, row] as const)))
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
|
||||
const nodes = createMemo(() => visibleNodesForPath(props.path, file.tree.children, filter()))
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
|
||||
|
||||
return (
|
||||
// group/file-tree-v2 scopes the group-hover guide lines below; hosts may add
|
||||
// an outer group with the same name to widen the hover area.
|
||||
<div data-component="file-tree-v2" class="group/file-tree-v2">
|
||||
<For each={nodes()}>
|
||||
{(node) => {
|
||||
const expanded = () => file.tree.state(node.path)?.expanded ?? false
|
||||
const deep = () => deeps().get(node.path) ?? -1
|
||||
const hasChildren = () => visibleNodesForPath(node.path, file.tree.children, filter()).length > 0
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={node.type === "directory"}>
|
||||
<Collapsible
|
||||
variant="ghost"
|
||||
class="w-full"
|
||||
data-scope="file-tree-v2"
|
||||
forceMount={false}
|
||||
open={expanded()}
|
||||
onOpenChange={(open) =>
|
||||
open ? file.tree.expand(node.path, { list: false }) : file.tree.collapse(node.path)
|
||||
}
|
||||
>
|
||||
<Collapsible.Trigger>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
>
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={expanded() ? "" : undefined}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
</div>
|
||||
</FileTreeNodeV2>
|
||||
</Collapsible.Trigger>
|
||||
<Show when={hasChildren()}>
|
||||
<Collapsible.Content class="relative">
|
||||
<div
|
||||
classList={{
|
||||
"absolute top-0 bottom-0 w-px pointer-events-none bg-border-weak-base opacity-0 transition-opacity duration-150 ease-out motion-reduce:transition-none": true,
|
||||
"group-hover/file-tree-v2:opacity-100": expanded() && deep() === level,
|
||||
"group-hover/file-tree-v2:opacity-50": !(expanded() && deep() === level),
|
||||
}}
|
||||
style={`left: ${guideLineLeft(level)}px`}
|
||||
/>
|
||||
<Show
|
||||
when={level < MAX_DEPTH && !chain.includes(key(node.path))}
|
||||
fallback={<div class="px-2 py-1 text-12-regular text-text-weak">...</div>}
|
||||
>
|
||||
<FileTreeV2
|
||||
path={node.path}
|
||||
level={level + 1}
|
||||
allowed={props.allowed}
|
||||
<div
|
||||
ref={setRoot}
|
||||
data-component="file-tree-v2"
|
||||
data-total-rows={model().total}
|
||||
class="group/file-tree-v2"
|
||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
<For each={virtualRowKeys()}>
|
||||
{(key) => (
|
||||
<Show when={virtualItemByKey().get(key)}>
|
||||
{(item) => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
transform: `translateY(${item().start}px)`,
|
||||
}}
|
||||
>
|
||||
<Show when={rowByKey().get(key as string)}>
|
||||
{(row) => (
|
||||
<Show
|
||||
when={row().node.type === "directory"}
|
||||
fallback={
|
||||
<FileTreeNodeV2
|
||||
node={row().node}
|
||||
level={row().level}
|
||||
active={active()}
|
||||
draggable={draggable()}
|
||||
kinds={props.kinds}
|
||||
active={props.active}
|
||||
draggable={props.draggable}
|
||||
onFileClick={props.onFileClick}
|
||||
_filter={filter()}
|
||||
_marks={marks()}
|
||||
_deeps={deeps()}
|
||||
_kinds={kinds()}
|
||||
_chain={chain}
|
||||
/>
|
||||
</Show>
|
||||
</Collapsible.Content>
|
||||
</Show>
|
||||
</Collapsible>
|
||||
</Match>
|
||||
<Match when={node.type === "file"}>
|
||||
<FileTreeNodeV2
|
||||
node={node}
|
||||
level={level}
|
||||
active={props.active}
|
||||
draggable={draggable()}
|
||||
kinds={kinds()}
|
||||
marks={marks()}
|
||||
as="button"
|
||||
type="button"
|
||||
onClick={() => props.onFileClick?.(node)}
|
||||
>
|
||||
<Show when={level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<Show
|
||||
when={!node.ignored}
|
||||
fallback={<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</Show>
|
||||
</FileTreeNodeV2>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}}
|
||||
as="button"
|
||||
type="button"
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() =>
|
||||
props.onFileClick?.({
|
||||
...row().node,
|
||||
path: row().node.originalPath,
|
||||
absolute: row().node.originalPath,
|
||||
})
|
||||
}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<Show when={row().level > 0}>
|
||||
<div class="w-4 shrink-0" />
|
||||
</Show>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={row().node} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
</FileTreeNodeV2>
|
||||
}
|
||||
>
|
||||
<FileTreeNodeV2
|
||||
node={row().node}
|
||||
level={row().level}
|
||||
active={active()}
|
||||
draggable={draggable()}
|
||||
kinds={props.kinds}
|
||||
as="button"
|
||||
type="button"
|
||||
class="relative"
|
||||
onFocus={() => setFocused(row().node.path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
aria-expanded={file.tree.state(row().node.path)?.expanded ?? true}
|
||||
onClick={() =>
|
||||
file.tree.state(row().node.path)?.expanded === false
|
||||
? file.tree.expand(row().node.path, { list: false })
|
||||
: file.tree.collapse(row().node.path)
|
||||
}
|
||||
>
|
||||
<GuideLines level={row().level} />
|
||||
<div
|
||||
data-slot="file-tree-v2-chevron"
|
||||
data-expanded={file.tree.state(row().node.path)?.expanded === false ? undefined : ""}
|
||||
class="size-4 flex items-center justify-center"
|
||||
>
|
||||
<Icon name="chevron-down" />
|
||||
</div>
|
||||
</FileTreeNodeV2>
|
||||
</Show>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, For, Show } from "solid-js"
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { getDirectory, getFilename, getFilenameTruncated } from "@opencode-ai/core/util/path"
|
||||
import type { ContextItem } from "@/context/prompt"
|
||||
|
||||
@@ -27,7 +27,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
const selected = props.active(item)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
<TooltipV2
|
||||
value={
|
||||
<span class="flex max-w-[300px]">
|
||||
<span class="text-text-invert-base truncate-start [unicode-bidi:plaintext] min-w-0">
|
||||
@@ -78,7 +78,7 @@ export const PromptContextItems: Component<ContextItemsProps> = (props) => {
|
||||
{(comment) => <div class="text-12-regular text-text-strong ml-5 pr-1 truncate">{comment()}</div>}
|
||||
</Show>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</TooltipV2>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
|
||||
import { useTitlebarRightMount } from "../titlebar"
|
||||
|
||||
const OPEN_APPS = [
|
||||
"vscode",
|
||||
@@ -284,10 +285,9 @@ export function SessionHeader() {
|
||||
}
|
||||
|
||||
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
|
||||
const [rightMount, setRightMount] = createSignal<HTMLElement | null>(null)
|
||||
const rightMount = useTitlebarRightMount()
|
||||
onMount(() => {
|
||||
setCenterMount(document.getElementById("opencode-titlebar-center"))
|
||||
setRightMount(document.getElementById("opencode-titlebar-right"))
|
||||
})
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import type { JSX } from "solid-js"
|
||||
import { Show, createEffect, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useSortable } from "@dnd-kit/solid/sortable"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
|
||||
import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
|
||||
import { useTerminal, type LocalPTY } from "@/context/terminal"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { focusTerminalById } from "@/pages/session/helpers"
|
||||
|
||||
export function SortableTerminalTabV2(props: {
|
||||
terminal: LocalPTY
|
||||
index: () => number
|
||||
newLayout: boolean
|
||||
onClose?: () => void
|
||||
}): JSX.Element {
|
||||
const terminal = useTerminal()
|
||||
const language = useLanguage()
|
||||
const sortable = useSortable({
|
||||
get id() {
|
||||
return props.terminal.id
|
||||
},
|
||||
get index() {
|
||||
return props.index()
|
||||
},
|
||||
})
|
||||
const [store, setStore] = createStore({
|
||||
editing: false,
|
||||
title: props.terminal.title,
|
||||
menuOpen: false,
|
||||
menuPosition: { x: 0, y: 0 },
|
||||
blurEnabled: false,
|
||||
})
|
||||
let input: HTMLInputElement | undefined
|
||||
let blurFrame: number | undefined
|
||||
let editRequested = false
|
||||
|
||||
const isDefaultTitle = () => {
|
||||
const number = props.terminal.titleNumber
|
||||
if (!Number.isFinite(number) || number <= 0) return false
|
||||
return isDefaultTerminalTitle(props.terminal.title, number)
|
||||
}
|
||||
|
||||
const label = () => {
|
||||
language.locale()
|
||||
if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
|
||||
|
||||
const number = props.terminal.titleNumber
|
||||
if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
|
||||
if (props.terminal.title) return props.terminal.title
|
||||
return language.t("terminal.title")
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
const count = terminal.all().length
|
||||
void terminal.close(props.terminal.id)
|
||||
if (count === 1) {
|
||||
props.onClose?.()
|
||||
}
|
||||
}
|
||||
|
||||
const focus = () => {
|
||||
if (store.editing) return
|
||||
terminal.open(props.terminal.id)
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
|
||||
focusTerminalById(props.terminal.id)
|
||||
}
|
||||
|
||||
const edit = (e?: Event) => {
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
|
||||
setStore("blurEnabled", false)
|
||||
setStore("title", props.terminal.title)
|
||||
setStore("editing", true)
|
||||
}
|
||||
|
||||
const save = () => {
|
||||
if (!store.blurEnabled) return
|
||||
|
||||
const value = store.title.trim()
|
||||
if (value && value !== props.terminal.title) {
|
||||
terminal.update({ id: props.terminal.id, title: value })
|
||||
}
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
const keydown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
save()
|
||||
return
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
}
|
||||
}
|
||||
|
||||
const menu = (e: MouseEvent) => {
|
||||
e.preventDefault()
|
||||
setStore("menuPosition", { x: e.clientX, y: e.clientY })
|
||||
setStore("menuOpen", true)
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (!store.editing) return
|
||||
if (!input) return
|
||||
input.focus()
|
||||
input.select()
|
||||
if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
|
||||
blurFrame = requestAnimationFrame(() => {
|
||||
blurFrame = undefined
|
||||
setStore("blurEnabled", true)
|
||||
})
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
if (blurFrame === undefined) return
|
||||
cancelAnimationFrame(blurFrame)
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={sortable.ref}
|
||||
class="outline-none focus:outline-none focus-visible:outline-none"
|
||||
classList={{
|
||||
"h-full flex items-center": props.newLayout,
|
||||
"h-full": !props.newLayout,
|
||||
}}
|
||||
>
|
||||
<Show
|
||||
when={props.newLayout}
|
||||
fallback={
|
||||
<div class="relative h-full">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
onClick={focus}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onContextMenu={menu}
|
||||
class="!shadow-none"
|
||||
classes={{
|
||||
button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
|
||||
}}
|
||||
closeButton={
|
||||
<IconButton
|
||||
icon="close"
|
||||
variant="ghost"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
aria-label={language.t("terminal.close")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span onDblClick={edit} classList={{ invisible: store.editing }}>
|
||||
{label()}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
<Show when={store.editing}>
|
||||
<div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
|
||||
<input
|
||||
ref={input}
|
||||
type="text"
|
||||
value={store.title}
|
||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={keydown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
class="fixed"
|
||||
style={{
|
||||
left: `${store.menuPosition.x}px`,
|
||||
top: `${store.menuPosition.y}px`,
|
||||
}}
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!editRequested) return
|
||||
e.preventDefault()
|
||||
editRequested = false
|
||||
requestAnimationFrame(() => edit())
|
||||
}}
|
||||
>
|
||||
<DropdownMenu.Item onSelect={() => (editRequested = true)}>
|
||||
<Icon name="edit" class="w-4 h-4 mr-2" />
|
||||
{language.t("common.rename")}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onSelect={close}>
|
||||
<Icon name="close" class="w-4 h-4 mr-2" />
|
||||
{language.t("common.close")}
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MenuV2.Context>
|
||||
<MenuV2.Context.Trigger class="relative" as="div">
|
||||
<Tabs.Trigger
|
||||
value={props.terminal.id}
|
||||
onClick={focus}
|
||||
closeButton={
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
variant="ghost"
|
||||
class="h-5 w-5"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
close()
|
||||
}}
|
||||
aria-label={language.t("terminal.close")}
|
||||
/>
|
||||
}
|
||||
hideCloseButton
|
||||
onMiddleClick={close}
|
||||
>
|
||||
<span
|
||||
class="truncate"
|
||||
data-slot="terminal-tab-title"
|
||||
onDblClick={edit}
|
||||
classList={{ invisible: store.editing }}
|
||||
>
|
||||
{label()}
|
||||
</span>
|
||||
</Tabs.Trigger>
|
||||
<Show when={store.editing}>
|
||||
<div class="absolute inset-0 flex items-center bg-v2-background-bg-layer-01 z-10 pointer-events-auto rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)] px-2">
|
||||
<input
|
||||
ref={input}
|
||||
type="text"
|
||||
value={store.title}
|
||||
onInput={(e) => setStore("title", e.currentTarget.value)}
|
||||
onBlur={save}
|
||||
onKeyDown={keydown}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
</MenuV2.Context.Trigger>
|
||||
<MenuV2.Context.Portal>
|
||||
<MenuV2.Context.Content
|
||||
onCloseAutoFocus={(e) => {
|
||||
if (!editRequested) return
|
||||
e.preventDefault()
|
||||
editRequested = false
|
||||
requestAnimationFrame(() => edit())
|
||||
}}
|
||||
>
|
||||
<MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
|
||||
<MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
|
||||
</MenuV2.Context.Content>
|
||||
</MenuV2.Context.Portal>
|
||||
</MenuV2.Context>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -5,24 +5,18 @@ import { Button } from "@opencode-ai/ui/button"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TextField } from "@opencode-ai/ui/text-field"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
|
||||
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import fuzzysort from "fuzzysort"
|
||||
import { formatKeybind, parseKeybind, useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsListV2 } from "./settings-v2/parts/list"
|
||||
|
||||
const ButtonV2 = lazy(() => import("@opencode-ai/ui/v2/button-v2").then((module) => ({ default: module.ButtonV2 })))
|
||||
const IconV2 = lazy(() => import("@opencode-ai/ui/v2/icon").then((module) => ({ default: module.Icon })))
|
||||
const IconButtonV2 = lazy(() =>
|
||||
import("@opencode-ai/ui/v2/icon-button-v2").then((module) => ({ default: module.IconButtonV2 })),
|
||||
)
|
||||
const TextInputV2 = lazy(() =>
|
||||
import("@opencode-ai/ui/v2/text-input-v2").then((module) => ({ default: module.TextInputV2 })),
|
||||
)
|
||||
const SettingsListV2 = lazy(() =>
|
||||
import("./settings-v2/parts/list").then((module) => ({ default: module.SettingsListV2 })),
|
||||
)
|
||||
|
||||
const IS_MAC = typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform)
|
||||
const PALETTE_ID = "command.palette"
|
||||
|
||||
@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider } from "./dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "./dialog-select-provider"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "./dialog-custom-provider"
|
||||
import { SettingsList } from "./settings-list"
|
||||
import { SettingsServerPicker, SettingsServerScope } from "./settings-server-picker"
|
||||
@@ -28,20 +27,26 @@ const PROVIDER_NOTES = [
|
||||
{ match: (id: string) => id === "vercel", key: "dialog.provider.vercel.note" },
|
||||
] as const
|
||||
|
||||
export const SettingsProviders: Component = () => {
|
||||
export const SettingsProviders: Component<{ onBack?: () => void }> = (props) => {
|
||||
return (
|
||||
<SettingsServerScope>
|
||||
<SettingsProvidersContent />
|
||||
<SettingsProvidersContent onBack={props.onBack} />
|
||||
</SettingsServerScope>
|
||||
)
|
||||
}
|
||||
|
||||
const SettingsProvidersContent: Component = () => {
|
||||
const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
@@ -206,14 +211,7 @@ const SettingsProvidersContent: Component = () => {
|
||||
{(key) => <span class="text-12-regular text-text-weak pl-8">{language.t(key())}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
<Button
|
||||
size="large"
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogConnectProvider provider={item.id} />)
|
||||
}}
|
||||
>
|
||||
<Button size="large" variant="secondary" icon="plus-small" onClick={() => connect(item.id)}>
|
||||
{language.t("common.connect")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -239,7 +237,7 @@ const SettingsProvidersContent: Component = () => {
|
||||
variant="secondary"
|
||||
icon="plus-small"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider back="close" />)
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
@@ -250,9 +248,7 @@ const SettingsProvidersContent: Component = () => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="px-0 py-0 mt-5 text-14-medium text-text-interactive-base text-left justify-start hover:bg-transparent active:bg-transparent"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
onClick={() => connect()}
|
||||
>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</Button>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Component } from "solid-js"
|
||||
import { Component, createSignal, startTransition } from "solid-js"
|
||||
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -10,16 +10,30 @@ import { SettingsProvidersV2 } from "./providers"
|
||||
import { SettingsModelsV2 } from "./models"
|
||||
import "./settings-v2.css"
|
||||
import { SettingsServersV2 } from "./servers"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
export const DialogSettings: Component<{
|
||||
sessionID?: string
|
||||
defaultValue?: string
|
||||
}> = (props) => {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const dialog = useDialog()
|
||||
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
|
||||
|
||||
const showProviders = () => {
|
||||
void dialog.show(() => <DialogSettings sessionID={props.sessionID} defaultValue="providers" />)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog size="x-large" variant="settings" class="settings-v2-dialog">
|
||||
<TabsV2 orientation="vertical" variant="settings" defaultValue="general" class="settings-v2">
|
||||
<TabsV2
|
||||
orientation="vertical"
|
||||
variant="settings"
|
||||
value={tab()}
|
||||
onChange={(value) => void startTransition(() => setTab(value))}
|
||||
class="settings-v2"
|
||||
>
|
||||
<TabsV2.List>
|
||||
<div class="flex flex-col justify-between h-full w-full">
|
||||
<div class="flex flex-col gap-3 w-full">
|
||||
@@ -73,7 +87,7 @@ export const DialogSettings: Component<{
|
||||
<SettingsServersV2 />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="providers" class="settings-v2-panel">
|
||||
<SettingsProvidersV2 />
|
||||
<SettingsProvidersV2 onBack={showProviders} />
|
||||
</TabsV2.Content>
|
||||
<TabsV2.Content value="models" class="settings-v2-panel">
|
||||
<SettingsModelsV2 />
|
||||
|
||||
@@ -8,8 +8,7 @@ import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider } from "../dialog-connect-provider"
|
||||
import { DialogSelectProvider } from "../dialog-select-provider"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsListV2 } from "./parts/list"
|
||||
import "./settings-v2.css"
|
||||
@@ -30,12 +29,18 @@ const PROVIDER_NOTES = [
|
||||
|
||||
const PROVIDER_ICON_SIZE = 16
|
||||
|
||||
export const SettingsProvidersV2: Component = () => {
|
||||
export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders()
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
const connect = (provider?: string) => {
|
||||
providerConnect.select(provider)
|
||||
void dialog.show(() => <DialogConnectProvider controller={providerConnect} />)
|
||||
}
|
||||
|
||||
const connected = createMemo(() => {
|
||||
return providers
|
||||
@@ -206,14 +211,7 @@ export const SettingsProvidersV2: Component = () => {
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<ButtonV2
|
||||
size="normal"
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogConnectProvider provider={item.id} />)
|
||||
}}
|
||||
>
|
||||
<ButtonV2 size="normal" variant="neutral" icon="plus" onClick={() => connect(item.id)}>
|
||||
{language.t("common.connect")}
|
||||
</ButtonV2>
|
||||
</div>
|
||||
@@ -241,7 +239,7 @@ export const SettingsProvidersV2: Component = () => {
|
||||
variant="neutral"
|
||||
icon="plus"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogCustomProvider back="close" />)
|
||||
dialog.show(() => <DialogCustomProvider onBack={dialog.close} />)
|
||||
}}
|
||||
>
|
||||
{language.t("common.connect")}
|
||||
@@ -249,13 +247,7 @@ export const SettingsProvidersV2: Component = () => {
|
||||
</div>
|
||||
</SettingsListV2>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="settings-v2-providers-view-all"
|
||||
onClick={() => {
|
||||
dialog.show(() => <DialogSelectProvider />)
|
||||
}}
|
||||
>
|
||||
<button type="button" class="settings-v2-providers-view-all" onClick={() => connect()}>
|
||||
{language.t("dialog.provider.viewAll")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createSignal, Show, type JSXElement } from "solid-js"
|
||||
import "./titlebar-tab-popover.css"
|
||||
|
||||
// Initial hover delay before the preview appears, per design.
|
||||
const OPEN_DELAY = 400
|
||||
const OPEN_DELAY = 2_000
|
||||
// Mouse-out delay: begin closing immediately (a brief exit animation plays).
|
||||
const CLOSE_DELAY = 0
|
||||
// After a preview closes, hovering a neighbouring tab within this window skips
|
||||
|
||||
@@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
|
||||
|
||||
const sortableTransition = { duration: 0 }
|
||||
|
||||
function SessionTabSlot(props: {
|
||||
tab: SessionTab
|
||||
id: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createEffect, createMemo, createResource, createSignal, Match, Show, Switch, untrack } from "solid-js"
|
||||
import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
@@ -60,6 +60,12 @@ export type TitlebarUpdate = {
|
||||
install: () => void
|
||||
}
|
||||
|
||||
export function useTitlebarRightMount() {
|
||||
const [mount, setMount] = createSignal<HTMLElement | null>(null)
|
||||
onMount(() => setMount(document.getElementById("opencode-titlebar-right")))
|
||||
return mount
|
||||
}
|
||||
|
||||
export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
const layout = useLayout()
|
||||
const platform = usePlatform()
|
||||
@@ -328,6 +334,21 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
return
|
||||
}
|
||||
|
||||
if (route.type === "home") {
|
||||
const selection = layout.home.selection()
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
|
||||
const project = conn
|
||||
? global
|
||||
.ensureServerCtx(conn)
|
||||
.projects.list()
|
||||
.find((item) => item.worktree === selection.directory)
|
||||
: undefined
|
||||
if (conn && project) {
|
||||
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const current = layout.projects.list()[0]
|
||||
if (current) {
|
||||
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
|
||||
@@ -374,9 +395,16 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
keybind: "mod+w",
|
||||
hidden: true,
|
||||
onSelect: () => {
|
||||
tabsStoreActions.removeTab(tabsStore.findIndex((tab) => current === tab))
|
||||
tabsStoreActions.closeTab(tabsStore.findIndex((tab) => current === tab))
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "tab.reopenClosed",
|
||||
category: language.t("command.category.file"),
|
||||
title: language.t("command.tab.reopenClosed"),
|
||||
keybind: "mod+shift+t",
|
||||
onSelect: () => tabsStoreActions.reopenClosedTab(),
|
||||
},
|
||||
{
|
||||
id: `tab.prev`,
|
||||
category: "tab",
|
||||
@@ -464,7 +492,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||
}}
|
||||
onClose={(tab) => {
|
||||
const index = tabsStore.findIndex((item) => tabKey(item) === tabKey(tab))
|
||||
if (index !== -1) tabsStoreActions.removeTab(index)
|
||||
if (index !== -1) tabsStoreActions.closeTab(index)
|
||||
}}
|
||||
onReorder={(keys) => tabsStoreActions.reorder(keys)}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { virtualScrollElement } from "./virtual-scroll-element"
|
||||
|
||||
test("resolves the connected viewport that owns the virtual root", () => {
|
||||
const stale = document.createElement("div")
|
||||
stale.className = "scroll-view__viewport"
|
||||
const viewport = document.createElement("div")
|
||||
viewport.className = "scroll-view__viewport"
|
||||
const root = document.createElement("div")
|
||||
viewport.append(root)
|
||||
document.body.append(viewport)
|
||||
|
||||
expect(virtualScrollElement(root)).toBe(viewport)
|
||||
expect(virtualScrollElement(root)).not.toBe(stale)
|
||||
|
||||
viewport.remove()
|
||||
expect(virtualScrollElement(root)).toBeNull()
|
||||
})
|
||||
@@ -0,0 +1,4 @@
|
||||
export function virtualScrollElement(root: HTMLElement | undefined) {
|
||||
if (!root?.isConnected) return null
|
||||
return root.closest<HTMLDivElement>(".scroll-view__viewport")
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { SessionTab, Tab } from "./tabs"
|
||||
|
||||
export type ClosedTab = {
|
||||
tab: SessionTab
|
||||
index: number
|
||||
}
|
||||
|
||||
const CLOSED_TAB_LIMIT = 25
|
||||
|
||||
// Only session tabs are recorded; closing a draft tab deletes its persisted
|
||||
// state, so a reopened draft would come back empty anyway.
|
||||
export function pushClosedTab(stack: ClosedTab[], tab: Tab, index: number): ClosedTab[] {
|
||||
if (tab.type !== "session") return stack
|
||||
return [...stack, { tab: { ...tab }, index }].slice(-CLOSED_TAB_LIMIT)
|
||||
}
|
||||
|
||||
// Pops the most recently closed tab that is not open again,
|
||||
// discarding stale entries along the way.
|
||||
export function takeClosedTab(stack: ClosedTab[], tabs: Tab[]): { entry?: ClosedTab; stack: ClosedTab[] } {
|
||||
const remaining = [...stack]
|
||||
while (remaining.length) {
|
||||
const entry = remaining.pop()
|
||||
if (entry && !isOpen(tabs, entry.tab)) return { entry, stack: remaining }
|
||||
}
|
||||
return { stack: remaining }
|
||||
}
|
||||
|
||||
export function removeClosedTabs(stack: ClosedTab[], server: SessionTab["server"], sessionIDs: string[]) {
|
||||
const removed = new Set(sessionIDs)
|
||||
return stack.filter((entry) => entry.tab.server !== server || !removed.has(entry.tab.sessionId))
|
||||
}
|
||||
|
||||
export function nextTabAfterClose(tabs: Tab[], index: number, active: boolean) {
|
||||
if (!active) return undefined
|
||||
return tabs[index + 1] ?? tabs[index - 1] ?? null
|
||||
}
|
||||
|
||||
function isOpen(tabs: Tab[], tab: SessionTab) {
|
||||
return tabs.some((item) => item.type === "session" && item.server === tab.server && item.sessionId === tab.sessionId)
|
||||
}
|
||||
@@ -15,11 +15,13 @@ const session = (id: string, parentID?: string): Session => ({
|
||||
})
|
||||
|
||||
type UserMessage = Extract<Message, { role: "user" }>
|
||||
type AssistantMessage = Extract<Message, { role: "assistant" }>
|
||||
type TextPart = Extract<Part, { type: "text" }>
|
||||
type MessageResponse = {
|
||||
data: { info: Message; parts: Part[] }[]
|
||||
response: { headers: Headers }
|
||||
}
|
||||
type SingleMessageResponse = { data: MessageResponse["data"][number] }
|
||||
|
||||
const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
|
||||
id,
|
||||
@@ -31,6 +33,22 @@ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage
|
||||
...input,
|
||||
})
|
||||
|
||||
const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
|
||||
id,
|
||||
sessionID: "child",
|
||||
role: "assistant",
|
||||
time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
|
||||
parentID,
|
||||
modelID: "model",
|
||||
providerID: "provider",
|
||||
mode: "build",
|
||||
agent: "build",
|
||||
path: { cwd: "/repo", root: "/repo" },
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
...input,
|
||||
})
|
||||
|
||||
const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
|
||||
id: "part",
|
||||
sessionID: "child",
|
||||
@@ -45,6 +63,8 @@ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageR
|
||||
response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
|
||||
})
|
||||
|
||||
const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
|
||||
|
||||
const deferredResponse = () => Promise.withResolvers<MessageResponse>()
|
||||
|
||||
function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
|
||||
@@ -71,6 +91,40 @@ function messageClient(...responses: Array<MessageResponse | Promise<MessageResp
|
||||
})
|
||||
}
|
||||
|
||||
function rootMessageClient(
|
||||
pages: Array<MessageResponse | Promise<MessageResponse>>,
|
||||
roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
|
||||
) {
|
||||
let pageIndex = 0
|
||||
let rootIndex = 0
|
||||
const requests: unknown[] = []
|
||||
const rootRequests: unknown[] = []
|
||||
const rootWaiting = new Map<number, () => void>()
|
||||
const client = {
|
||||
session: {
|
||||
get: async () => ({ data: session("child", "root") }),
|
||||
messages: (input: unknown) => {
|
||||
requests.push(input)
|
||||
return pages[pageIndex++]
|
||||
},
|
||||
message: (input: unknown) => {
|
||||
rootRequests.push(input)
|
||||
rootWaiting.get(rootRequests.length)?.()
|
||||
rootWaiting.delete(rootRequests.length)
|
||||
return roots[rootIndex++]
|
||||
},
|
||||
},
|
||||
} as unknown as OpencodeClient
|
||||
return Object.assign(client, {
|
||||
requests,
|
||||
rootRequests,
|
||||
rootRequested(count: number) {
|
||||
if (rootRequests.length >= count) return Promise.resolve()
|
||||
return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const retryImmediately: typeof retry = async (task, options = {}) => {
|
||||
const attempts = options.attempts ?? 3
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
@@ -124,6 +178,238 @@ describe("server session", () => {
|
||||
expect(ctx.store.data.message.root).toEqual([])
|
||||
})
|
||||
|
||||
test("backfills an assistant-only initial page through its user root", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
|
||||
await store.sync("child")
|
||||
|
||||
expect(client.requests).toEqual([{ sessionID: "child", limit: 2, before: undefined }])
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
expect(store.history.more("child")).toBe(true)
|
||||
})
|
||||
|
||||
test("does not let an optimistic user suppress initial root backfill", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const part = textPart(user.id)
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
|
||||
|
||||
await store.sync("child")
|
||||
store.optimistic.remove({ sessionID: "child", messageID: user.id })
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("backfills the parent of fetched assistants when another user is cached", async () => {
|
||||
const unrelated = userMessage("message-0", { time: { created: 0 } })
|
||||
const user = userMessage("message-1")
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response([{ info: unrelated, parts: [] }]),
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.requests).toHaveLength(2)
|
||||
expect(client.rootRequests).toHaveLength(1)
|
||||
expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves cached history between an injected parent and the page boundary", async () => {
|
||||
const user = userMessage("message-1")
|
||||
const cached = userMessage("message-3", { time: { created: 3 } })
|
||||
const assistant = assistantMessage("message-4", user.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(store.data.message.child).toEqual([user, cached, assistant])
|
||||
})
|
||||
|
||||
test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const stalePart = textPart(stale.id, { text: "stale" })
|
||||
const freshPart = { ...stalePart, text: "fresh" }
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[singleResponse(fresh, [freshPart])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([freshPart])
|
||||
})
|
||||
|
||||
test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
|
||||
const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
|
||||
const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
|
||||
const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
|
||||
const refreshed = { ...confirmed, text: "fresh" }
|
||||
const pending = textPart(stale.id, { id: "pending", text: "pending" })
|
||||
const assistant = assistantMessage("message-2", stale.id)
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
|
||||
[singleResponse(fresh, [refreshed])],
|
||||
)
|
||||
const store = createServerSession(client)
|
||||
store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
|
||||
await store.sync("child")
|
||||
|
||||
await store.sync("child", { force: true })
|
||||
|
||||
expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
|
||||
expect(store.data.message.child).toEqual([fresh, assistant])
|
||||
expect(store.data.part[stale.id]).toEqual([refreshed, pending])
|
||||
})
|
||||
|
||||
test("uses a parent received by SSE during the replacement load", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const client = rootMessageClient([pending.promise], [])
|
||||
const store = createServerSession(client)
|
||||
const loading = store.sync("child")
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: user } })
|
||||
pending.resolve(response([{ info: assistant, parts: [] }], "older"))
|
||||
await loading
|
||||
|
||||
expect(client.rootRequests).toEqual([])
|
||||
expect(store.data.message.child).toEqual([user, assistant])
|
||||
})
|
||||
|
||||
test("uses a successful retry over events received by a failed backfill attempt", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const live = { ...user, agent: "stale" }
|
||||
const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
|
||||
const client = rootMessageClient(
|
||||
[
|
||||
response(
|
||||
assistants.map((info) => ({ info, parts: [] })),
|
||||
"older",
|
||||
),
|
||||
],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(client.requests).toHaveLength(1)
|
||||
expect(client.rootRequests).toHaveLength(2)
|
||||
expect(store.data.message.child).toEqual([user, ...assistants])
|
||||
})
|
||||
|
||||
test("preserves newer-page events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = { ...assistant, cost: 1 }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }], "older")],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, live])
|
||||
})
|
||||
|
||||
test("preserves unrelated message events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const live = userMessage("message-4", { time: { created: 4 } })
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [] }], "older")],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.updated", properties: { info: live } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.message.child).toEqual([user, assistant, live])
|
||||
})
|
||||
|
||||
test("preserves newer-page part events across a failed parent retry", async () => {
|
||||
const failed = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
const assistant = assistantMessage("message-2", user.id)
|
||||
const stale = textPart(assistant.id, { text: "stale" })
|
||||
const live = { ...stale, text: "live" }
|
||||
const client = rootMessageClient(
|
||||
[response([{ info: assistant, parts: [stale] }], "older")],
|
||||
[failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
|
||||
)
|
||||
const store = createServerSession(client, { retry: retryImmediately })
|
||||
const loading = store.sync("child")
|
||||
await client.rootRequested(1)
|
||||
|
||||
store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
|
||||
failed.reject(new Error("retry"))
|
||||
await loading
|
||||
|
||||
expect(store.data.part[assistant.id]).toEqual([live])
|
||||
})
|
||||
|
||||
test("merges live events into the initial page", async () => {
|
||||
const pending = deferredResponse()
|
||||
const user = userMessage("message-1")
|
||||
@@ -905,6 +1191,26 @@ describe("server session", () => {
|
||||
expect(store.data.message.child).toEqual([latest])
|
||||
})
|
||||
|
||||
test("does not scan cached messages for user roots during history prepend", async () => {
|
||||
const guard = { active: false }
|
||||
const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), {
|
||||
get(target, property, receiver) {
|
||||
if (guard.active && property === "role") throw new Error("cached role accessed")
|
||||
return Reflect.get(target, property, receiver)
|
||||
},
|
||||
})
|
||||
const older = userMessage("message-1")
|
||||
const store = createServerSession(
|
||||
messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])),
|
||||
)
|
||||
await store.sync("child")
|
||||
guard.active = true
|
||||
|
||||
await store.history.loadMore("child")
|
||||
|
||||
expect(store.data.message.child).toEqual([older, latest])
|
||||
})
|
||||
|
||||
test("preserves loaded history during an incomplete refresh", async () => {
|
||||
const older = userMessage("message-1")
|
||||
const latest = userMessage("message-2", { time: { created: 2 } })
|
||||
|
||||
@@ -54,6 +54,11 @@ type MessageLoadState = {
|
||||
clearedMessageParts: Set<string>
|
||||
}
|
||||
|
||||
type MessageLoadBaseline = Pick<
|
||||
MessageLoadState,
|
||||
"touchedMessages" | "retainedMessages" | "touchedParts" | "clearedMessageParts"
|
||||
>
|
||||
|
||||
function mergeOptimisticPage(page: MessagePage, items: OptimisticItem[]) {
|
||||
if (items.length === 0) return { ...page, observed: [] as { messageID: string; parts: Part[] }[] }
|
||||
const session = [...page.session]
|
||||
@@ -347,7 +352,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
load.touchedParts.set(messageID, new Set([partID]))
|
||||
}
|
||||
|
||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState) => {
|
||||
const resetMessageLoad = (sessionID: string, load: MessageLoadState, baseline?: MessageLoadBaseline) => {
|
||||
load.touchedMessages.clear()
|
||||
load.retainedMessages.clear()
|
||||
load.touchedParts.clear()
|
||||
@@ -380,8 +385,27 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
}
|
||||
baseline?.touchedMessages.forEach((messageID) => load.touchedMessages.add(messageID))
|
||||
baseline?.retainedMessages.forEach((messageID) => load.retainedMessages.add(messageID))
|
||||
baseline?.clearedMessageParts.forEach((messageID) => load.clearedMessageParts.add(messageID))
|
||||
baseline?.touchedParts.forEach((parts, messageID) => {
|
||||
const touched = load.touchedParts.get(messageID) ?? new Set<string>()
|
||||
parts.forEach((partID) => touched.add(partID))
|
||||
load.touchedParts.set(messageID, touched)
|
||||
})
|
||||
}
|
||||
|
||||
const messageLoadBaseline = (load: MessageLoadState, exclude: string): MessageLoadBaseline => ({
|
||||
touchedMessages: new Set([...load.touchedMessages].filter((messageID) => messageID !== exclude)),
|
||||
retainedMessages: new Set([...load.retainedMessages].filter((messageID) => messageID !== exclude)),
|
||||
touchedParts: new Map(
|
||||
[...load.touchedParts]
|
||||
.filter(([messageID]) => messageID !== exclude)
|
||||
.map(([messageID, parts]) => [messageID, new Set(parts)]),
|
||||
),
|
||||
clearedMessageParts: new Set([...load.clearedMessageParts].filter((messageID) => messageID !== exclude)),
|
||||
})
|
||||
|
||||
const evict = (sessionIDs: string[]) => {
|
||||
if (sessionIDs.length === 0) return
|
||||
const evicted = new Set(sessionIDs)
|
||||
@@ -460,6 +484,18 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
}
|
||||
}
|
||||
|
||||
const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => {
|
||||
const response = await (options?.retry ?? retry)(() => {
|
||||
onAttempt?.()
|
||||
return client.session.message({ sessionID, messageID })
|
||||
})
|
||||
if (!response.data?.info?.id) throw new Error(`Message not found: ${messageID}`)
|
||||
return {
|
||||
message: cleanMessage(response.data.info),
|
||||
parts: response.data.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
}
|
||||
}
|
||||
|
||||
const replaceMessages = (sessionID: string, messages: Message[]) => {
|
||||
const messageIDs = new Set(messages.map((message) => message.id))
|
||||
const dropped = (data.message[sessionID] ?? []).filter((message) => !messageIDs.has(message.id))
|
||||
@@ -578,36 +614,79 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
||||
messageLoads.set(sessionID, load)
|
||||
setMeta("loading", sessionID, true)
|
||||
let applied = false
|
||||
await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
.then((page) => {
|
||||
if (generations.get(sessionID) !== active) return
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!page.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
page,
|
||||
messageLoads.get(sessionID) === load ? load : undefined,
|
||||
preserveUnfetched,
|
||||
mode !== "prepend",
|
||||
)
|
||||
applied = true
|
||||
})
|
||||
.finally(() => {
|
||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||
for (const messageID of load.orphanParents) {
|
||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
orphanParts.get(sessionID)?.delete(messageID)
|
||||
}
|
||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||
try {
|
||||
const page = await fetchMessages(sessionID, limit, before, () => resetMessageLoad(sessionID, load))
|
||||
const first = page.session.reduce<Message | undefined>(
|
||||
(oldest, message) => (!oldest || cmpMessage(message, oldest) < 0 ? message : oldest),
|
||||
undefined,
|
||||
)
|
||||
if (generations.get(sessionID) !== active) return
|
||||
|
||||
const parents = [] as Awaited<ReturnType<typeof fetchMessage>>[]
|
||||
if (mode !== "prepend") {
|
||||
const users = new Set([
|
||||
...page.session.filter((message) => message.role === "user").map((message) => message.id),
|
||||
...(data.message[sessionID] ?? [])
|
||||
.filter((message) => {
|
||||
if (message.role !== "user") return false
|
||||
const item = optimistic.get(sessionID)?.get(message.id)
|
||||
return load.touchedMessages.has(message.id) && (!item || item.confirmedMessage === true)
|
||||
})
|
||||
.map((message) => message.id),
|
||||
])
|
||||
const parentIDs = [
|
||||
...new Set(
|
||||
page.session.flatMap((message) =>
|
||||
message.role === "assistant" && !users.has(message.parentID) ? [message.parentID] : [],
|
||||
),
|
||||
),
|
||||
]
|
||||
for (const parentID of parentIDs) {
|
||||
if (generations.get(sessionID) !== active) break
|
||||
const parent = await fetchMessage(sessionID, parentID, () =>
|
||||
resetMessageLoad(sessionID, load, messageLoadBaseline(load, parentID)),
|
||||
)
|
||||
if (parent.message.role !== "user") throw new Error(`Assistant parent is not a user message: ${parentID}`)
|
||||
parents.push(parent)
|
||||
}
|
||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||
})
|
||||
}
|
||||
if (generations.get(sessionID) !== active) return
|
||||
const result =
|
||||
mode === "prepend"
|
||||
? page
|
||||
: {
|
||||
...page,
|
||||
session: merge(
|
||||
page.session,
|
||||
parents.map((parent) => parent.message),
|
||||
),
|
||||
part: merge(
|
||||
page.part,
|
||||
parents.map((parent) => ({ id: parent.message.id, part: parent.parts })),
|
||||
),
|
||||
}
|
||||
const preserveUnfetched =
|
||||
mode === "prepend" || (!result.complete && (!first || ((message: Message) => cmpMessage(message, first) < 0)))
|
||||
applyMessagePage(
|
||||
sessionID,
|
||||
result,
|
||||
messageLoads.get(sessionID) === load ? load : undefined,
|
||||
preserveUnfetched,
|
||||
mode !== "prepend",
|
||||
)
|
||||
applied = true
|
||||
} finally {
|
||||
if (!applied && generations.get(sessionID) === active && messageLoads.get(sessionID) === load) {
|
||||
for (const messageID of load.orphanParents) {
|
||||
if (!orphanParts.get(sessionID)?.has(messageID)) continue
|
||||
setData(produce((draft) => deleteMessageParts(draft, messageID)))
|
||||
orphanParts.get(sessionID)?.delete(messageID)
|
||||
}
|
||||
if (orphanParts.get(sessionID)?.size === 0) orphanParts.delete(sessionID)
|
||||
}
|
||||
if (messageLoads.get(sessionID) === load) messageLoads.delete(sessionID)
|
||||
if (generations.get(sessionID) === active) setMeta("loading", sessionID, false)
|
||||
}
|
||||
}
|
||||
|
||||
const sync = (sessionID: string, options?: { force?: boolean; messageLimit?: number }) => {
|
||||
|
||||
@@ -312,7 +312,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
})
|
||||
}
|
||||
|
||||
const isReady = createMemo(() => ready() && !!state.active)
|
||||
const isReady = Object.assign(
|
||||
createMemo(() => ready() && !!state.active),
|
||||
{ promise: ready.promise },
|
||||
)
|
||||
|
||||
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
|
||||
const projects = createServerProjects({ scope, store, setStore })
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
import type { SessionTab, Tab } from "./tabs"
|
||||
import type { ServerConnection } from "./server"
|
||||
|
||||
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
|
||||
|
||||
function sessionTab(sessionId: string): SessionTab {
|
||||
return { type: "session", server, sessionId }
|
||||
}
|
||||
|
||||
describe("tab memory", () => {
|
||||
test("keeps state until its tab is removed", () => {
|
||||
@@ -22,3 +31,75 @@ describe("tab memory", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("closed tab stack", () => {
|
||||
test("records session tabs with their index", () => {
|
||||
const stack = pushClosedTab([], sessionTab("a"), 2)
|
||||
|
||||
expect(stack).toEqual([{ tab: sessionTab("a"), index: 2 }])
|
||||
})
|
||||
|
||||
test("ignores draft tabs", () => {
|
||||
const draft: Tab = { type: "draft", draftID: "d1", server, directory: "/tmp" }
|
||||
|
||||
expect(pushClosedTab([], draft, 0)).toEqual([])
|
||||
})
|
||||
|
||||
test("caps the stack size", () => {
|
||||
const stack = Array.from({ length: 30 }, (_, i) => i).reduce<ClosedTab[]>(
|
||||
(acc, i) => pushClosedTab(acc, sessionTab(`s${i}`), i),
|
||||
[],
|
||||
)
|
||||
|
||||
expect(stack).toHaveLength(25)
|
||||
expect(stack[0]?.tab.sessionId).toBe("s5")
|
||||
expect(stack.at(-1)?.tab.sessionId).toBe("s29")
|
||||
})
|
||||
|
||||
test("pops the most recently closed tab", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
const result = takeClosedTab(stack, [])
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("b")
|
||||
expect(result.stack).toEqual([{ tab: sessionTab("a"), index: 0 }])
|
||||
})
|
||||
|
||||
test("skips entries whose tab is already open", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
const result = takeClosedTab(stack, [sessionTab("b")])
|
||||
|
||||
expect(result.entry?.tab.sessionId).toBe("a")
|
||||
expect(result.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("returns no entry when everything is open or empty", () => {
|
||||
expect(takeClosedTab([], []).entry).toBeUndefined()
|
||||
|
||||
const result = takeClosedTab([{ tab: sessionTab("a"), index: 0 }], [sessionTab("a")])
|
||||
expect(result.entry).toBeUndefined()
|
||||
expect(result.stack).toEqual([])
|
||||
})
|
||||
|
||||
test("purges removed sessions", () => {
|
||||
const stack = [
|
||||
{ tab: sessionTab("a"), index: 0 },
|
||||
{ tab: sessionTab("b"), index: 1 },
|
||||
]
|
||||
|
||||
expect(removeClosedTabs(stack, server, ["a"])).toEqual([{ tab: sessionTab("b"), index: 1 }])
|
||||
})
|
||||
|
||||
test("does not navigate when a background tab closes", () => {
|
||||
const tabs = [sessionTab("a"), sessionTab("b"), sessionTab("c")]
|
||||
|
||||
expect(nextTabAfterClose(tabs, 1, false)).toBeUndefined()
|
||||
expect(nextTabAfterClose(tabs, 1, true)).toEqual(sessionTab("c"))
|
||||
expect(nextTabAfterClose([sessionTab("a")], 0, true)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import { uuid } from "@/utils/uuid"
|
||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { createTabMemory } from "./tab-memory"
|
||||
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
|
||||
|
||||
export type SessionTab = {
|
||||
type: "session"
|
||||
@@ -63,6 +64,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
createStore<Tab[]>([]),
|
||||
)
|
||||
const [recent, setRecent, , recentReady] = persisted(Persist.window("tabs.recent"), createStore<RecentTab>({}))
|
||||
const [closed, setClosed, , closedReady] = persisted(Persist.window("tabs.closed"), createStore<ClosedTab[]>([]))
|
||||
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -87,6 +89,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
})
|
||||
}
|
||||
|
||||
const updateClosed = (update: (stack: ClosedTab[]) => ClosedTab[]) => {
|
||||
const apply = () => setClosed((stack) => update(stack))
|
||||
if (closedReady()) {
|
||||
apply()
|
||||
return
|
||||
}
|
||||
void closedReady.promise?.then(apply)
|
||||
}
|
||||
|
||||
const removeDraftPersisted = (draftID: string) => {
|
||||
for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform)
|
||||
}
|
||||
@@ -106,6 +117,13 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (recent.key && !next.some((tab) => tabKey(tab) === recent.key)) setRecentKey(undefined)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!closedReady()) return
|
||||
const servers = new Set(server.list.map(ServerConnection.key))
|
||||
const next = closed.filter((entry) => servers.has(entry.tab.server))
|
||||
if (next.length !== closed.length) setClosed(() => next)
|
||||
})
|
||||
|
||||
const navigateTab = (tab: Tab) => {
|
||||
const href = tabHref(tab)
|
||||
setRecentKey(tabKey(tab))
|
||||
@@ -117,7 +135,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
if (!tab) return
|
||||
const key = tabKey(tab)
|
||||
const draftID = tab.type === "draft" ? tab.draftID : undefined
|
||||
const nextTab = store[index + 1] ?? store[index - 1]
|
||||
const nextTab = nextTabAfterClose(store, index, recentKey() === key && location.pathname !== "/")
|
||||
closing.add(key)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
@@ -125,9 +143,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
tabs.splice(index, 1)
|
||||
}),
|
||||
)
|
||||
if (recent.key === key) setRecentKey(nextTab && tabKey(nextTab))
|
||||
if (nextTab === null) {
|
||||
setRecentKey(undefined)
|
||||
navigate("/")
|
||||
}
|
||||
if (nextTab) navigateTab(nextTab)
|
||||
else navigate("/")
|
||||
}).finally(() => closing.delete(key))
|
||||
memory.remove(key)
|
||||
if (draftID) removeDraftPersisted(draftID)
|
||||
@@ -201,13 +221,45 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
removeDraftPersisted(draftID)
|
||||
},
|
||||
removeTab,
|
||||
// User-initiated close: records the tab so it can be reopened.
|
||||
// Cleanup paths (missing sessions, archive, server removal) go through
|
||||
// removeTab and friends directly and are not recorded.
|
||||
closeTab(index: number) {
|
||||
const tab = store[index]
|
||||
if (!tab) return
|
||||
if (tab.type === "session") updateClosed((stack) => pushClosedTab(stack, tab, index))
|
||||
removeTab(index)
|
||||
},
|
||||
reopenClosedTab() {
|
||||
if (!closedReady()) {
|
||||
void closedReady.promise?.then(() => actions.reopenClosedTab())
|
||||
return
|
||||
}
|
||||
const result = takeClosedTab(closed, store)
|
||||
if (result.stack.length === closed.length) return
|
||||
setClosed(() => result.stack)
|
||||
const entry = result.entry
|
||||
if (!entry) return
|
||||
const index = Math.min(entry.index, store.length)
|
||||
void startTransition(() => {
|
||||
setStore(
|
||||
produce((tabs) => {
|
||||
if (tabs.some((item) => tabKey(item) === tabKey(entry.tab))) return
|
||||
tabs.splice(index, 0, entry.tab)
|
||||
}),
|
||||
)
|
||||
navigateTab(entry.tab)
|
||||
})
|
||||
},
|
||||
removeSessionTab(input: Omit<SessionTab, "type">) {
|
||||
updateClosed((stack) => removeClosedTabs(stack, input.server, [input.sessionId]))
|
||||
const index = store.findIndex(
|
||||
(tab) => tab.type === "session" && tab.server === input.server && tab.sessionId === input.sessionId,
|
||||
)
|
||||
if (index !== -1) removeTab(index)
|
||||
},
|
||||
removeServer(key: ServerConnection.Key) {
|
||||
updateClosed((stack) => stack.filter((entry) => entry.tab.server !== key))
|
||||
const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : []))
|
||||
const removed = store.filter((tab) => tab.server === key).map(tabKey)
|
||||
setStore((tabs) => tabs.filter((tab) => tab.server !== key))
|
||||
@@ -218,6 +270,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||
},
|
||||
removeSessions: (input: SessionTabsRemovedDetail) => {
|
||||
const targetServer = input.server ?? server.key
|
||||
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
|
||||
const removed = store
|
||||
.filter(
|
||||
(tab) => tab.type === "session" && tab.server === targetServer && input.sessionIDs.includes(tab.sessionId),
|
||||
|
||||
@@ -48,6 +48,7 @@ export const dict = {
|
||||
"command.session.new": "New session",
|
||||
"command.file.open": "Open file",
|
||||
"command.tab.close": "Close tab",
|
||||
"command.tab.reopenClosed": "Reopen closed tab",
|
||||
"command.context.addSelection": "Add selection to context",
|
||||
"command.context.addSelection.description": "Add selected lines from the current file",
|
||||
"command.input.focus": "Focus input",
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { useLayout } from "./context/layout"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServerSync } from "./context/server-sync"
|
||||
export { useServer } from "./context/server"
|
||||
export { useTabs } from "./context/tabs"
|
||||
export { useProviders } from "./hooks/use-providers"
|
||||
export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker"
|
||||
export { useCommand } from "./context/command"
|
||||
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||
|
||||
describe("shouldOpenSessionInBackground", () => {
|
||||
test("requires only the platform primary modifier", () => {
|
||||
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: false, alt: false })).toBe(true)
|
||||
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: false })).toBe(true)
|
||||
expect(shouldOpenSessionInBackground({ mac: true, meta: true, ctrl: false, shift: true, alt: false })).toBe(false)
|
||||
expect(shouldOpenSessionInBackground({ mac: false, meta: false, ctrl: true, shift: false, alt: true })).toBe(false)
|
||||
expect(shouldOpenSessionInBackground({ mac: false, meta: true, ctrl: false, shift: false, alt: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
export function shouldOpenSessionInBackground(input: {
|
||||
mac: boolean
|
||||
meta: boolean
|
||||
ctrl: boolean
|
||||
shift: boolean
|
||||
alt: boolean
|
||||
}) {
|
||||
if (input.shift || input.alt) return false
|
||||
if (input.mac) return input.meta && !input.ctrl
|
||||
return input.ctrl && !input.meta
|
||||
}
|
||||
@@ -66,6 +66,7 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { useMarked } from "@opencode-ai/ui/context/marked"
|
||||
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
|
||||
import { archiveHomeSession } from "./home-session-archive"
|
||||
import { shouldOpenSessionInBackground } from "./home-session-open"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
const HOME_SESSION_LIMIT = 64
|
||||
@@ -238,6 +239,20 @@ function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) {
|
||||
return { setViewport, setContentRef, setHeaderRef, update, titleOpacity }
|
||||
}
|
||||
|
||||
// Cmd+click on macOS (Ctrl+click elsewhere) opens a session tab in the
|
||||
// background without navigating, matching browser conventions.
|
||||
function isBackgroundOpen(event: MouseEvent) {
|
||||
return shouldOpenSessionInBackground({
|
||||
mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform),
|
||||
meta: event.metaKey,
|
||||
ctrl: event.ctrlKey,
|
||||
shift: event.shiftKey,
|
||||
alt: event.altKey,
|
||||
})
|
||||
}
|
||||
|
||||
type OpenSessionOptions = { background?: boolean }
|
||||
|
||||
export function NewHome() {
|
||||
const sync = useServerSync()
|
||||
const layout = useLayout()
|
||||
@@ -377,9 +392,11 @@ export function NewHome() {
|
||||
setState("searchFocused", false)
|
||||
}
|
||||
|
||||
function selectSearchSession(session: Session) {
|
||||
openSession(session)
|
||||
closeSearch()
|
||||
function selectSearchSession(session: Session, options?: OpenSessionOptions) {
|
||||
openSession(session, options)
|
||||
// Background opens keep the search visible so several results can be
|
||||
// opened in a row.
|
||||
if (!options?.background) closeSearch()
|
||||
}
|
||||
|
||||
command.register("home", () => [
|
||||
@@ -464,13 +481,17 @@ export function NewHome() {
|
||||
.forEach((directory) => state.project.markViewed(directory))
|
||||
}
|
||||
|
||||
function openSession(session: Session) {
|
||||
function openSession(session: Session, options?: OpenSessionOptions) {
|
||||
const project = projectForSession(session, projects(), projectByID())
|
||||
const conn = focusedServer()
|
||||
if (!conn) return
|
||||
const directory = project?.worktree ?? session.directory
|
||||
const ctx = global.ensureServerCtx(conn)
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
return
|
||||
}
|
||||
ctx.projects.touch(directory)
|
||||
startTransition(() => {
|
||||
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
|
||||
@@ -1130,7 +1151,7 @@ function HomeSessionSearch(props: {
|
||||
onInput: (value: string) => void
|
||||
onFocus: () => void
|
||||
onClose: () => void
|
||||
onSelect: (session: Session) => void
|
||||
onSelect: (session: Session, options?: OpenSessionOptions) => void
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const [store, setStore] = createStore({ active: "" })
|
||||
@@ -1244,7 +1265,7 @@ function HomeSessionSearch(props: {
|
||||
server={props.server}
|
||||
selected={store.active === homeSessionSearchKey(record)}
|
||||
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
|
||||
onSelect={(session) => props.onSelect(session)}
|
||||
onSelect={(session, options) => props.onSelect(session, options)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
@@ -1324,7 +1345,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
server: ServerConnection.Key
|
||||
selected: boolean
|
||||
onHighlight: () => void
|
||||
onSelect: (session: Session) => void
|
||||
onSelect: (session: Session, options?: OpenSessionOptions) => void
|
||||
}) {
|
||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||
const showProjectName = () => props.showProjectName && props.record.projectName
|
||||
@@ -1345,7 +1366,7 @@ function HomeSessionSearchResultRow(props: {
|
||||
group: !!showProjectName(),
|
||||
}}
|
||||
onMouseEnter={() => props.onHighlight()}
|
||||
onClick={() => props.onSelect(props.record.session)}
|
||||
onClick={(event) => props.onSelect(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
@@ -1389,7 +1410,7 @@ function HomeSessionRow(props: {
|
||||
record: HomeSessionRecord
|
||||
showProjectName: boolean
|
||||
server: ServerConnection.Key
|
||||
openSession: (session: Session) => void
|
||||
openSession: (session: Session, options?: OpenSessionOptions) => void
|
||||
archiveSession: (session: Session) => Promise<void>
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
@@ -1405,7 +1426,7 @@ function HomeSessionRow(props: {
|
||||
type="button"
|
||||
data-component="home-session-row"
|
||||
class={`${HOME_ROW} h-10 min-w-0 flex-1 gap-2 py-3 pl-3 pr-10`}
|
||||
onClick={() => props.openSession(props.record.session)}
|
||||
onClick={(event) => props.openSession(props.record.session, { background: isBackgroundOpen(event) })}
|
||||
>
|
||||
<HomeSessionLeading
|
||||
project={props.record.project}
|
||||
|
||||
@@ -1095,9 +1095,9 @@ export default function LegacyLayout(props: ParentProps) {
|
||||
|
||||
function connectProvider() {
|
||||
const run = ++dialogRun
|
||||
void import("@/components/dialog-select-provider").then((x) => {
|
||||
void import("@/components/dialog-connect-provider").then((x) => {
|
||||
if (dialogDead || dialogRun !== run) return
|
||||
dialog.show(() => <x.DialogSelectProvider />)
|
||||
void dialog.show(() => <x.DialogConnectProvider />)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Show, createEffect, createMemo, createResource, untrack } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { useSearchParams } from "@solidjs/router"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { NewSessionDesignView } from "@/components/session"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import {
|
||||
PromptProjectAddButton,
|
||||
@@ -15,13 +18,15 @@ import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
|
||||
import { useTitlebarRightMount } from "@/components/titlebar"
|
||||
|
||||
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
|
||||
|
||||
/**
|
||||
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
|
||||
@@ -35,6 +40,7 @@ export default function NewSessionPage() {
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
const route = useSessionKey()
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>()
|
||||
|
||||
@@ -55,8 +61,11 @@ export default function NewSessionPage() {
|
||||
})
|
||||
|
||||
const [store, setStore] = createStore<{ worktree?: string }>({})
|
||||
const rightMount = useTitlebarRightMount()
|
||||
|
||||
const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git")
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (!showWorkspaceBar()) return "main"
|
||||
if (store.worktree) return store.worktree
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
@@ -92,8 +101,19 @@ export default function NewSessionPage() {
|
||||
|
||||
return (
|
||||
<div class="relative size-full overflow-hidden flex flex-col">
|
||||
<Show when={rightMount()}>
|
||||
{(mount) => (
|
||||
<Portal mount={mount()}>
|
||||
<Show when={settings.visibility.status()}>
|
||||
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
|
||||
<StatusPopoverV2 />
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Portal>
|
||||
)}
|
||||
</Show>
|
||||
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full bg-background-stronger flex-1">
|
||||
<div class="@container relative flex flex-col min-h-0 h-full flex-1">
|
||||
<div class="flex-1 min-h-0 overflow-hidden rounded-[10px]">
|
||||
<NewSessionDesignView>
|
||||
<div class={NEW_SESSION_CONTENT_WIDTH}>
|
||||
@@ -105,7 +125,7 @@ export default function NewSessionPage() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
|
||||
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar(), "gap-3": !showWorkspaceBar() }}>
|
||||
<PromptInput
|
||||
controls={inputController()}
|
||||
variant="new-session"
|
||||
@@ -125,15 +145,15 @@ export default function NewSessionPage() {
|
||||
<div
|
||||
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
|
||||
classList={{
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar,
|
||||
"justify-start": !showWorkspaceBar,
|
||||
"flex-col justify-center sm:flex-row": showWorkspaceBar(),
|
||||
"justify-start": !showWorkspaceBar(),
|
||||
}}
|
||||
>
|
||||
<PromptProjectSelector
|
||||
controller={projectController}
|
||||
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
|
||||
placement={showWorkspaceBar() ? "bottom" : "bottom-start"}
|
||||
/>
|
||||
<Show when={showWorkspaceBar}>
|
||||
<Show when={showWorkspaceBar()}>
|
||||
<PromptWorkspaceSelector
|
||||
value={newSessionWorktree()}
|
||||
projectRoot={projectRoot()}
|
||||
|
||||
@@ -64,25 +64,20 @@ import {
|
||||
createSessionComposerRegionController,
|
||||
SessionComposerRegion,
|
||||
} from "@/pages/session/composer"
|
||||
import {
|
||||
createOpenReviewFile,
|
||||
createSessionTabs,
|
||||
createSizing,
|
||||
focusTerminalById,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
shouldShowFileTree,
|
||||
} from "@/pages/session/helpers"
|
||||
import { createOpenReviewFile, createSessionTabs, createSizing, shouldShowFileTree } from "@/pages/session/helpers"
|
||||
import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||
import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
|
||||
import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
|
||||
import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
|
||||
import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
|
||||
import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
|
||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||
import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
|
||||
import { useComposerCommands } from "@/pages/session/use-composer-commands"
|
||||
import { useSessionCommands } from "@/pages/session/use-session-commands"
|
||||
import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
@@ -434,6 +429,12 @@ export default function Page() {
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const size = createSizing()
|
||||
const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
|
||||
const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
|
||||
const terminalOpen = createMemo(() => view().terminal.opened())
|
||||
const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
|
||||
const desktopInlineTerminalOnlyOpen = createMemo(
|
||||
() => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
|
||||
)
|
||||
const desktopFileTreeOpen = createMemo(
|
||||
() =>
|
||||
isDesktop() &&
|
||||
@@ -442,13 +443,23 @@ export default function Page() {
|
||||
opened: layout.fileTree.opened(),
|
||||
}),
|
||||
)
|
||||
const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
|
||||
const desktopSessionResizeOpen = createMemo(() =>
|
||||
newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen(),
|
||||
)
|
||||
const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
|
||||
const sessionPanelWidth = createMemo(() => {
|
||||
if (!desktopSidePanelOpen()) return "100%"
|
||||
if (desktopReviewOpen()) return `${layout.session.width()}px`
|
||||
if (desktopSessionResizeOpen()) return `${layout.session.width()}px`
|
||||
return `calc(100% - ${layout.fileTree.width()}px)`
|
||||
})
|
||||
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
|
||||
const desktopV2PanelLayout = createMemo(() =>
|
||||
sessionPanelLayout({
|
||||
review: desktopV2ReviewOpen(),
|
||||
terminal: desktopTerminalOpen(),
|
||||
files: desktopFileTreeOpen(),
|
||||
}),
|
||||
)
|
||||
|
||||
function normalizeTab(tab: string) {
|
||||
if (!tab.startsWith("file://")) return tab
|
||||
@@ -942,12 +953,6 @@ export default function Page() {
|
||||
return
|
||||
}
|
||||
|
||||
// Prefer the open terminal over the composer when it can take focus
|
||||
if (view().terminal.opened()) {
|
||||
const id = terminal.active()
|
||||
if (id && shouldFocusTerminalOnKeyDown(event) && focusTerminalById(id)) return
|
||||
}
|
||||
|
||||
const key = scrollKey(event)
|
||||
if (key) {
|
||||
if (!scroller || !isScrollKeyTarget(target ?? null, key)) return
|
||||
@@ -2106,7 +2111,7 @@ export default function Page() {
|
||||
classList={{
|
||||
"@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
|
||||
"duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!size.active() && !ui.reviewSnap,
|
||||
!size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(),
|
||||
}}
|
||||
style={{
|
||||
width: sessionPanelWidth(),
|
||||
@@ -2126,7 +2131,7 @@ export default function Page() {
|
||||
</SessionPanelFrame>
|
||||
)}
|
||||
|
||||
<Show when={desktopReviewOpen()}>
|
||||
<Show when={desktopSessionResizeOpen()}>
|
||||
<div onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
classList={{
|
||||
@@ -2145,22 +2150,84 @@ export default function Page() {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
<Show when={!newSessionDesign()}>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={hasReview}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanel}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={newSessionDesign()}>
|
||||
<Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
|
||||
<div class="min-w-0 h-full flex flex-1 flex-col">
|
||||
<Show when={isDesktop()}>
|
||||
<div
|
||||
classList={{
|
||||
"min-h-0 flex-1": desktopV2ReviewOpen() || desktopFileTreeOpen(),
|
||||
"size-0 shrink-0 overflow-hidden": !(desktopV2ReviewOpen() || desktopFileTreeOpen()),
|
||||
}}
|
||||
>
|
||||
<SessionSidePanel
|
||||
canReview={canReview}
|
||||
diffs={reviewDiffs}
|
||||
diffsReady={reviewReady}
|
||||
empty={reviewEmptyText}
|
||||
hasReview={hasReview}
|
||||
reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()}
|
||||
reviewCount={reviewCount}
|
||||
reviewPanel={reviewPanelV2}
|
||||
activeDiff={tree.activeDiff}
|
||||
focusReviewDiff={focusReviewDiff}
|
||||
reviewSnap={ui.reviewSnap}
|
||||
size={size}
|
||||
stacked={desktopV2PanelLayout().stacked}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={desktopV2PanelLayout().stacked}>
|
||||
<div class="relative h-2 shrink-0" onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
class="!relative !inset-auto !h-full !w-full !transform-none"
|
||||
direction="vertical"
|
||||
size={layout.terminal.height()}
|
||||
min={100}
|
||||
max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
|
||||
collapseThreshold={50}
|
||||
onResize={(height) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(height)
|
||||
}}
|
||||
onCollapse={() => view().terminal.close()}
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={terminalOpen()}>
|
||||
<div
|
||||
classList={{
|
||||
"min-h-0 shrink-0": desktopV2PanelLayout().stacked,
|
||||
"min-h-0 flex-1": !desktopV2PanelLayout().stacked,
|
||||
}}
|
||||
>
|
||||
<TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} />
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<TerminalPanel />
|
||||
<Show when={!newSessionDesign()}>
|
||||
<TerminalPanel />
|
||||
</Show>
|
||||
</SessionRouteFrame>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
createSessionTabs,
|
||||
focusTerminalById,
|
||||
getTabReorderIndex,
|
||||
shouldFocusTerminalOnKeyDown,
|
||||
shouldShowFileTree,
|
||||
} from "./helpers"
|
||||
|
||||
@@ -95,26 +94,6 @@ describe("focusTerminalById", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldFocusTerminalOnKeyDown", () => {
|
||||
test("skips pure modifier keys", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Meta", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Control", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Alt", altKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "Shift", shiftKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("skips shortcut key combos", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", metaKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "c", ctrlKey: true }))).toBe(false)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true }))).toBe(false)
|
||||
})
|
||||
|
||||
test("keeps plain typing focused on terminal", () => {
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "a" }))).toBe(true)
|
||||
expect(shouldFocusTerminalOnKeyDown(new KeyboardEvent("keydown", { key: "A", shiftKey: true }))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getTabReorderIndex", () => {
|
||||
test("returns target index for valid drag reorder", () => {
|
||||
expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2)
|
||||
|
||||
@@ -98,13 +98,6 @@ export const focusTerminalById = (id: string) => {
|
||||
return true
|
||||
}
|
||||
|
||||
const skip = new Set(["Alt", "Control", "Meta", "Shift"])
|
||||
|
||||
export const shouldFocusTerminalOnKeyDown = (event: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "altKey">) => {
|
||||
if (skip.has(event.key)) return false
|
||||
return !(event.ctrlKey || event.metaKey || event.altKey)
|
||||
}
|
||||
|
||||
export const createOpenReviewFile = (input: {
|
||||
showAllFiles: () => void
|
||||
tabForPath: (path: string) => string
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { sessionPanelLayout } from "./session-panel-layout"
|
||||
|
||||
describe("sessionPanelLayout", () => {
|
||||
test("keeps one V2 owner while changing panel geometry", () => {
|
||||
expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
|
||||
visible: false,
|
||||
stacked: false,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: false,
|
||||
})
|
||||
expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
|
||||
visible: true,
|
||||
stacked: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
|
||||
return {
|
||||
visible: input.review || input.terminal || input.files,
|
||||
stacked: input.review && input.terminal,
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
|
||||
import FileTree from "@/components/file-tree"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
|
||||
const reviewTabID = "session-side-panel-review-tab"
|
||||
const reviewTabPanelID = "session-side-panel-review-tabpanel"
|
||||
import { SessionContextTab, SortableTab, FileVisual } from "@/components/session"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useFile, type SelectedLineRange } from "@/context/file"
|
||||
@@ -45,12 +48,14 @@ export function SessionSidePanel(props: {
|
||||
diffsReady: () => boolean
|
||||
empty: () => string
|
||||
hasReview: () => boolean
|
||||
reviewHasFocusableContent: () => boolean
|
||||
reviewCount: () => number
|
||||
reviewPanel: () => JSX.Element
|
||||
activeDiff?: string
|
||||
focusReviewDiff: (path: string) => void
|
||||
reviewSnap: boolean
|
||||
size: Sizing
|
||||
stacked?: boolean
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const settings = useSettings()
|
||||
@@ -74,6 +79,7 @@ export function SessionSidePanel(props: {
|
||||
}),
|
||||
)
|
||||
const open = createMemo(() => reviewOpen() || fileOpen())
|
||||
const rendered = createMemo<boolean>((previous) => previous || open(), false)
|
||||
const reviewTab = createMemo(() => isDesktop())
|
||||
const panelWidth = createMemo(() => {
|
||||
if (!open()) return "0px"
|
||||
@@ -154,6 +160,10 @@ export function SessionSidePanel(props: {
|
||||
const openedTabs = tabState.openedTabs
|
||||
const activeTab = tabState.activeTab
|
||||
const activeFileTab = tabState.activeFileTab
|
||||
const reviewContentRendered = createMemo<boolean>(
|
||||
(previous) => previous || (reviewOpen() && activeTab() === "review"),
|
||||
false,
|
||||
)
|
||||
|
||||
const fileTreeTab = () => layout.fileTree.tab()
|
||||
|
||||
@@ -219,8 +229,10 @@ export function SessionSidePanel(props: {
|
||||
aria-label={language.t("session.panel.reviewAndFiles")}
|
||||
aria-hidden={!open()}
|
||||
inert={!open()}
|
||||
class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
|
||||
class="relative min-w-0 flex overflow-hidden bg-background-base"
|
||||
classList={{
|
||||
"h-full shrink-0": !props.stacked,
|
||||
"h-full min-h-0": props.stacked,
|
||||
"pointer-events-none": !open(),
|
||||
"transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
|
||||
!props.size.active() && !props.reviewSnap,
|
||||
@@ -229,7 +241,7 @@ export function SessionSidePanel(props: {
|
||||
}}
|
||||
style={{ width: panelWidth() }}
|
||||
>
|
||||
<Show when={open()}>
|
||||
<Show when={rendered()}>
|
||||
<div
|
||||
class="size-full flex"
|
||||
classList={{
|
||||
@@ -262,7 +274,11 @@ export function SessionSidePanel(props: {
|
||||
}}
|
||||
>
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Trigger value="review">
|
||||
<Tabs.Trigger
|
||||
value="review"
|
||||
id={reviewTabID}
|
||||
aria-controls={activeTab() === "review" ? reviewTabPanelID : undefined}
|
||||
>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div>{language.t("session.tab.review")}</div>
|
||||
<Show when={props.hasReview()}>
|
||||
@@ -325,10 +341,20 @@ export function SessionSidePanel(props: {
|
||||
</Tabs.List>
|
||||
</div>
|
||||
|
||||
<Show when={reviewTab() && props.canReview()}>
|
||||
<Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
<Show when={reviewOpen() && activeTab() === "review"}>{props.reviewPanel()}</Show>
|
||||
</Tabs.Content>
|
||||
<Show when={reviewTab() && props.canReview() && reviewContentRendered()}>
|
||||
<div
|
||||
id={reviewTabPanelID}
|
||||
role="tabpanel"
|
||||
aria-labelledby={reviewTabID}
|
||||
aria-hidden={activeTab() !== "review"}
|
||||
inert={activeTab() !== "review"}
|
||||
tabIndex={props.reviewHasFocusableContent() ? undefined : 0}
|
||||
data-slot="tabs-content"
|
||||
class="flex flex-col h-full overflow-hidden contain-strict"
|
||||
classList={{ hidden: activeTab() !== "review" }}
|
||||
>
|
||||
{props.reviewPanel()}
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createMediaQuery } from "@solid-primitives/media"
|
||||
import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
|
||||
import { isSortable } from "@dnd-kit/solid/sortable"
|
||||
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
|
||||
import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
|
||||
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
|
||||
import { IconButton } from "@opencode-ai/ui/icon-button"
|
||||
import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
|
||||
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
|
||||
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
|
||||
|
||||
import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2"
|
||||
import { Terminal } from "@/components/terminal"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
|
||||
export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
|
||||
const delays = [120, 240]
|
||||
const layout = useLayout()
|
||||
const terminal = useTerminal()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const command = useCommand()
|
||||
const settings = useSettings()
|
||||
const { workspaceKey, view } = useSessionLayout()
|
||||
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const newLayout = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const opened = createMemo(() => view().terminal.opened())
|
||||
const size = createSizing()
|
||||
const height = createMemo(() => layout.terminal.height())
|
||||
const close = () => view().terminal.close()
|
||||
let root: HTMLDivElement | undefined
|
||||
let tabList: HTMLDivElement | undefined
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
autoCreated: false,
|
||||
recovered: {} as Record<string, boolean>,
|
||||
view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
|
||||
})
|
||||
|
||||
const max = () => store.view * 0.6
|
||||
const pane = () => Math.min(height(), max())
|
||||
const stacked = createMemo(() => isDesktop() && props.stacked)
|
||||
const panelHeight = createMemo(() =>
|
||||
isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px",
|
||||
)
|
||||
const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
|
||||
const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
|
||||
|
||||
onMount(() => {
|
||||
if (typeof window === "undefined") return
|
||||
|
||||
const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
|
||||
const port = window.visualViewport
|
||||
|
||||
sync()
|
||||
makeEventListener(window, "resize", sync)
|
||||
if (port) makeEventListener(port, "resize", sync)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!opened()) {
|
||||
setStore("autoCreated", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
|
||||
terminal.new()
|
||||
setStore("autoCreated", true)
|
||||
})
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => terminal.all().length,
|
||||
(count, prevCount) => {
|
||||
if (prevCount === undefined || prevCount <= 0 || count !== 0) return
|
||||
if (!opened()) return
|
||||
close()
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const focus = (id: string) => {
|
||||
focusTerminalById(id)
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
})
|
||||
|
||||
const timers = delays.map((ms) =>
|
||||
window.setTimeout(() => {
|
||||
if (!opened()) return
|
||||
if (terminal.active() !== id) return
|
||||
focusTerminalById(id)
|
||||
}, ms),
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(frame)
|
||||
for (const timer of timers) clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [opened(), terminal.active()] as const,
|
||||
([next, id]) => {
|
||||
if (!next || !id) return
|
||||
const stop = focus(id)
|
||||
onCleanup(stop)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (opened()) return
|
||||
const active = document.activeElement
|
||||
if (!(active instanceof HTMLElement)) return
|
||||
if (!root?.contains(active)) return
|
||||
active.blur()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
const dir = sdk().directory
|
||||
if (!dir) return
|
||||
if (!terminal.ready()) return
|
||||
language.locale()
|
||||
|
||||
setTerminalHandoff(
|
||||
workspaceKey(),
|
||||
terminal.all().map((pty) =>
|
||||
terminalTabLabel({
|
||||
title: pty.title,
|
||||
titleNumber: pty.titleNumber,
|
||||
t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const handoff = createMemo(() => {
|
||||
const dir = sdk().directory
|
||||
if (!dir) return []
|
||||
return getTerminalHandoff(workspaceKey()) ?? []
|
||||
})
|
||||
|
||||
const all = terminal.all
|
||||
const ids = createMemo(() => all().map((pty) => pty.id))
|
||||
|
||||
const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
|
||||
if (store.recovered[key]) return
|
||||
setStore("recovered", key, true)
|
||||
void clone(id)
|
||||
}
|
||||
|
||||
const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
|
||||
return String(pty.titleNumber || pty.title || pty.id)
|
||||
}
|
||||
|
||||
const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
|
||||
setStore("recovered", key, false)
|
||||
trim(id)
|
||||
}
|
||||
|
||||
const handleTerminalDragEnd = () => {
|
||||
const activeId = terminal.active()
|
||||
if (!activeId) return
|
||||
requestAnimationFrame(() => {
|
||||
if (terminal.active() !== activeId) return
|
||||
focusTerminalById(activeId)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
ref={root}
|
||||
id="terminal-panel"
|
||||
role="region"
|
||||
aria-label={language.t("terminal.title")}
|
||||
aria-hidden={!opened()}
|
||||
inert={!opened()}
|
||||
class="relative shrink-0 overflow-hidden bg-background-stronger"
|
||||
classList={{
|
||||
"w-full": !isDesktop() || stacked(),
|
||||
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
|
||||
"w-0 h-full pointer-events-none": isDesktop() && !opened(),
|
||||
"rounded-[10px] shadow-[var(--v2-elevation-raised)]": isDesktop() && newLayout(),
|
||||
"transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
|
||||
!isDesktop() && !size.active(),
|
||||
}}
|
||||
style={{ height: panelHeight() }}
|
||||
>
|
||||
<div classList={{ "md:hidden": !stacked(), hidden: stacked() }} onPointerDown={() => size.start()}>
|
||||
<ResizeHandle
|
||||
classList={{
|
||||
"-top-1": newLayout(),
|
||||
}}
|
||||
direction="vertical"
|
||||
size={pane()}
|
||||
min={100}
|
||||
max={max()}
|
||||
collapseThreshold={50}
|
||||
onResize={(next) => {
|
||||
size.touch()
|
||||
layout.terminal.resize(next)
|
||||
}}
|
||||
onCollapse={close}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="absolute inset-0 flex flex-col overflow-hidden"
|
||||
classList={{
|
||||
"border-t border-border-weak-base": opened() && !isDesktop(),
|
||||
"border-t border-border-weaker-base": opened() && stacked() && !newLayout(),
|
||||
"border-l border-border-weaker-base": opened() && isDesktop() && !newLayout(),
|
||||
"pointer-events-none": !opened(),
|
||||
}}
|
||||
style={{ height: contentHeight() }}
|
||||
>
|
||||
<Show
|
||||
when={terminal.ready()}
|
||||
fallback={
|
||||
<div class="flex flex-col h-full pointer-events-none">
|
||||
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
|
||||
<For each={handoff()}>
|
||||
{(title) => (
|
||||
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
<div class="flex-1" />
|
||||
<div class="text-text-weak pr-2">
|
||||
{language.t("common.loading")}
|
||||
{language.t("common.loading.ellipsis")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
|
||||
preventActivation: (event) =>
|
||||
event.target instanceof Element &&
|
||||
!!event.target.closest('[data-slot="tabs-trigger-close-button"], input, [contenteditable="true"]'),
|
||||
}),
|
||||
]}
|
||||
modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
|
||||
plugins={(defaults) => [
|
||||
...defaults.filter((plugin) => plugin !== Accessibility),
|
||||
AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
|
||||
Feedback.configure({ dropAnimation: null }),
|
||||
]}
|
||||
onDragEnd={(event) => {
|
||||
const source = event.operation.source
|
||||
if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
|
||||
terminal.move(source.id.toString(), source.index)
|
||||
}
|
||||
handleTerminalDragEnd()
|
||||
}}
|
||||
>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs
|
||||
variant={newLayout() ? "normal" : "alt"}
|
||||
value={terminal.active()}
|
||||
onChange={(id) => terminal.open(id)}
|
||||
class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
|
||||
>
|
||||
<Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}>
|
||||
<For each={all()}>
|
||||
{(pty, index) => (
|
||||
<SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
|
||||
)}
|
||||
</For>
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<Show
|
||||
when={newLayout()}
|
||||
fallback={
|
||||
<TooltipKeybind
|
||||
title={language.t("command.terminal.new")}
|
||||
keybind={command.keybind("terminal.new")}
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
}
|
||||
>
|
||||
<TooltipV2
|
||||
value={
|
||||
<>
|
||||
{language.t("command.terminal.new")}
|
||||
<Show when={newTerminalKeybind().length > 0}>
|
||||
<KeybindV2 keys={newTerminalKeybind()} variant="neutral" />
|
||||
</Show>
|
||||
</>
|
||||
}
|
||||
placement="bottom"
|
||||
class="flex items-center"
|
||||
>
|
||||
<IconButton
|
||||
icon="plus-small"
|
||||
variant="ghost"
|
||||
iconSize="large"
|
||||
onClick={terminal.new}
|
||||
aria-label={language.t("command.terminal.new")}
|
||||
/>
|
||||
</TooltipV2>
|
||||
</Show>
|
||||
</div>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
<div class="flex-1 min-h-0 relative">
|
||||
<Show when={opened() && terminal.active()} keyed>
|
||||
{(id) => {
|
||||
const ops = terminal.bind()
|
||||
return (
|
||||
<Show when={all().find((pty) => pty.id === id)}>
|
||||
{(pty) => (
|
||||
<div id={`terminal-wrapper-${id}`} class="absolute inset-0">
|
||||
<Terminal
|
||||
pty={pty()}
|
||||
autoFocus={opened()}
|
||||
class="!px-[14px]"
|
||||
onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
|
||||
onCleanup={ops.update}
|
||||
onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</DragDropProvider>
|
||||
</Show>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -598,8 +598,12 @@ export function MessageTimeline(props: {
|
||||
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
if (!prependLoading) clearPrependAnchor()
|
||||
if (event.target !== event.currentTarget) return
|
||||
props.onMarkScrollGesture(event.currentTarget)
|
||||
props.onMarkScrollGesture(event.target)
|
||||
}
|
||||
|
||||
const handleListPointerMove = (event: PointerEvent) => {
|
||||
if (event.buttons !== 1) return
|
||||
props.onMarkScrollGesture(event.target)
|
||||
}
|
||||
|
||||
const handleListKeyDown = (event: KeyboardEvent & { currentTarget: HTMLDivElement }) => {
|
||||
@@ -1356,6 +1360,7 @@ export function MessageTimeline(props: {
|
||||
onTouchEnd={handleListTouchEnd}
|
||||
onTouchCancel={handleListTouchEnd}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onPointerMove={handleListPointerMove}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
onClick={props.onAutoScrollInteraction}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AssistantMessage, Message, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||
import { isTimelineReady, loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
|
||||
|
||||
const user = (id: string) => ({ id, role: "user" }) as UserMessage
|
||||
const assistant = (id: string) => ({ id, role: "assistant" }) as AssistantMessage
|
||||
@@ -15,6 +15,12 @@ describe("timeline model", () => {
|
||||
expect(selectVisibleUserMessages(users)).toBe(users)
|
||||
})
|
||||
|
||||
test("waits for an assistant-only load to hydrate its user root", () => {
|
||||
expect(isTimelineReady([assistant("msg_2")], true)).toBe(false)
|
||||
expect(isTimelineReady([user("msg_1"), assistant("msg_2")], true)).toBe(true)
|
||||
expect(isTimelineReady([], false)).toBe(true)
|
||||
})
|
||||
|
||||
test("loads exactly one opaque cursor page", async () => {
|
||||
let calls = 0
|
||||
const anchors: Array<string | boolean> = []
|
||||
|
||||
@@ -45,7 +45,7 @@ export function createTimelineModel(input: {
|
||||
})
|
||||
const ready = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return !id || sync().data.message[id] !== undefined
|
||||
return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
|
||||
})
|
||||
const userMessages = createMemo(() => selectUserMessages(messages()), emptyUserMessages, { equals: same })
|
||||
const visibleUserMessages = createMemo(
|
||||
@@ -98,6 +98,10 @@ export function selectUserMessages(messages: Message[]) {
|
||||
return messages.filter((message): message is UserMessage => message.role === "user")
|
||||
}
|
||||
|
||||
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
|
||||
return messages !== undefined && (messages.some((message) => message.role === "user") || !loading)
|
||||
}
|
||||
|
||||
export function selectVisibleUserMessages(messages: UserMessage[], revertMessageID?: string) {
|
||||
if (!revertMessageID) return messages
|
||||
return messages.filter((message) => message.id < revertMessageID)
|
||||
|
||||
@@ -76,9 +76,11 @@ export function useUsageExceededDialogs() {
|
||||
setGoUpsellState(keys.lastSeenAt, Date.now())
|
||||
if (dontShowAgain) setGoUpsellState(keys.dontShow, Date.now())
|
||||
else {
|
||||
void import("../../components/dialog-connect-provider").then((x) =>
|
||||
dialog.show(() => <x.DialogConnectProvider provider="opencode-go" />),
|
||||
)
|
||||
void import("../../components/dialog-connect-provider").then((x) => {
|
||||
const controller = x.useProviderConnectController()
|
||||
controller.select("opencode-go")
|
||||
void dialog.show(() => <x.DialogConnectProvider controller={controller} />)
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -12,6 +12,13 @@ describe("reviewDiffKinds", () => {
|
||||
expect(kinds.get("src/b.ts")).toBe("del")
|
||||
expect(kinds.get("src")).toBe("mix")
|
||||
})
|
||||
|
||||
test("normalizes file and directory paths", () => {
|
||||
const kinds = reviewDiffKinds([{ file: "\\src//lib/a.ts/", additions: 1, deletions: 1, status: "modified" }])
|
||||
|
||||
expect(kinds.get("src/lib/a.ts")).toBe("mix")
|
||||
expect(kinds.get("src/lib")).toBe("mix")
|
||||
})
|
||||
})
|
||||
|
||||
describe("filterReviewFiles", () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { Kind } from "@/components/file-tree-v2"
|
||||
import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model"
|
||||
|
||||
export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff
|
||||
|
||||
export function normalizePath(p: string) {
|
||||
return p.replaceAll("\\", "/").replace(/\/+$/, "")
|
||||
return normalizeFileTreeV2Path(p)
|
||||
}
|
||||
|
||||
export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff {
|
||||
|
||||
@@ -203,7 +203,6 @@ function ReviewPanelV2Sidebar(props: {
|
||||
when={props.searching()}
|
||||
fallback={
|
||||
<FileTreeV2
|
||||
path=""
|
||||
allowed={props.filteredFiles()}
|
||||
kinds={props.kinds()}
|
||||
draggable={false}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { FileIcon } from "@opencode-ai/ui/file-icon"
|
||||
import "@opencode-ai/ui/v2/file-tree-v2.css"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
import { createEffect, For, Show } from "solid-js"
|
||||
import { createEffect, createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { kindChange, kindLabel, type Kind } from "@/components/file-tree-v2"
|
||||
import { normalizePath } from "@/pages/session/v2/review-diff-kinds"
|
||||
import { createVirtualizer, defaultRangeExtractor } from "@tanstack/solid-virtual"
|
||||
import { virtualScrollElement } from "@/components/virtual-scroll-element"
|
||||
|
||||
// Drives the highlight/selection of the flat search-result list from the filter
|
||||
// input's keyboard events.
|
||||
@@ -45,62 +47,107 @@ export function SessionFileListV2(props: {
|
||||
}) {
|
||||
const active = () => normalizePath(props.active ?? "")
|
||||
const highlighted = () => normalizePath(props.highlighted ?? "")
|
||||
let rootRef: HTMLDivElement | undefined
|
||||
const normalized = createMemo(() => props.files.map(normalizePath))
|
||||
const [root, setRoot] = createSignal<HTMLDivElement>()
|
||||
const [focused, setFocused] = createSignal<string>()
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
get count() {
|
||||
return props.files.length
|
||||
},
|
||||
getScrollElement: () => virtualScrollElement(root()),
|
||||
initialRect: { width: 0, height: 600 },
|
||||
estimateSize: () => 28,
|
||||
gap: 2,
|
||||
overscan: 10,
|
||||
get getItemKey() {
|
||||
const files = props.files
|
||||
return (index: number) => files[index] ?? index
|
||||
},
|
||||
rangeExtractor: (range) => {
|
||||
const indexes = defaultRangeExtractor(range)
|
||||
const path = focused()
|
||||
const index = path ? props.files.indexOf(path) : -1
|
||||
if (index < 0 || indexes.includes(index)) return indexes
|
||||
return [...indexes, index].sort((a, b) => a - b)
|
||||
},
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
highlighted()
|
||||
if (!rootRef) return
|
||||
const index = normalized().indexOf(highlighted())
|
||||
if (index < 0) return
|
||||
queueMicrotask(() => {
|
||||
const row = rootRef?.querySelector<HTMLElement>('[data-slot="file-tree-v2-row"][data-highlighted]')
|
||||
row?.scrollIntoView({ block: "nearest" })
|
||||
if (virtualizer.range && index >= virtualizer.range.startIndex && index <= virtualizer.range.endIndex) return
|
||||
virtualizer.scrollToIndex(index, { align: "auto" })
|
||||
})
|
||||
})
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
)
|
||||
const virtualRowKeys = createMemo(() => virtualizer.getVirtualItems().map((item) => item.key))
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
rootRef = el
|
||||
}}
|
||||
ref={setRoot}
|
||||
data-component="file-tree-v2"
|
||||
data-total-rows={props.files.length}
|
||||
style={{ position: "relative", height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
<For each={props.files}>
|
||||
{(path) => {
|
||||
const normalized = normalizePath(path)
|
||||
const selected = () => {
|
||||
if (highlighted()) return highlighted() === normalized
|
||||
return active() === normalized
|
||||
}
|
||||
const highlightedRow = () => highlighted() === normalized
|
||||
const kind = () => props.kinds?.get(normalized)
|
||||
const directory = () => (normalized.includes("/") ? getDirectory(normalized) : undefined)
|
||||
const filename = () => getFilename(normalized)
|
||||
<For each={virtualRowKeys()}>
|
||||
{(key) => {
|
||||
const path = key as string
|
||||
const value = normalizePath(path)
|
||||
const selected = () => (highlighted() ? highlighted() === value : active() === value)
|
||||
const highlightedRow = () => highlighted() === value
|
||||
const kind = () => props.kinds?.get(value)
|
||||
const directory = () => (value.includes("/") ? getDirectory(value) : undefined)
|
||||
const filename = () => getFilename(value)
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-slot="file-tree-v2-row"
|
||||
data-selected={selected() ? "" : undefined}
|
||||
data-highlighted={highlightedRow() ? "" : undefined}
|
||||
style="padding-left: 8px"
|
||||
onClick={() => props.onFileClick(path)}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
|
||||
<Show when={directory()}>
|
||||
{(value) => <span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>}
|
||||
</Show>
|
||||
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
|
||||
</span>
|
||||
<Show when={kind()}>
|
||||
{(value) => (
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
|
||||
{kindLabel(value())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
<Show when={virtualItemByKey().get(key)}>
|
||||
{(item) => (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
transform: `translateY(${item().start}px)`,
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="file-tree-v2-row"
|
||||
data-path={path}
|
||||
data-selected={selected() ? "" : undefined}
|
||||
data-highlighted={highlightedRow() ? "" : undefined}
|
||||
style="padding-left: 8px"
|
||||
onFocus={() => setFocused(path)}
|
||||
onBlur={() => setFocused(undefined)}
|
||||
onClick={() => props.onFileClick(path)}
|
||||
>
|
||||
<span class="filetree-iconpair size-4">
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--color" />
|
||||
<FileIcon node={{ path, type: "file" }} class="size-4 filetree-icon filetree-icon--mono" mono />
|
||||
</span>
|
||||
<span class="flex min-w-0 flex-1 items-center overflow-hidden whitespace-nowrap">
|
||||
<Show when={directory()}>
|
||||
{(value) => (
|
||||
<span class="text-12-medium text-text-muted truncate min-w-0 shrink">{value()}</span>
|
||||
)}
|
||||
</Show>
|
||||
<span class="text-12-medium text-text-base truncate min-w-0 shrink-0">{filename()}</span>
|
||||
</span>
|
||||
<Show when={kind()}>
|
||||
{(value) => (
|
||||
<span data-slot="file-tree-v2-change" data-change={kindChange(value())}>
|
||||
{kindLabel(value())}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
|
||||
@@ -27,6 +27,21 @@ test("reactive count updates preserve measured row sizes", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("initial rect projects rows before a scroll element connects", () => {
|
||||
createRoot((dispose) => {
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
count: 100,
|
||||
getScrollElement: () => null,
|
||||
estimateSize: () => 28,
|
||||
initialRect: { width: 0, height: 600 },
|
||||
overscan: 10,
|
||||
})
|
||||
|
||||
expect(virtualizer.getVirtualItems().length).toBeGreaterThan(0)
|
||||
dispose()
|
||||
})
|
||||
})
|
||||
|
||||
test("logical scroll offset includes pending measurement adjustments", () => {
|
||||
createRoot((dispose) => {
|
||||
const virtualizer = createVirtualizer<HTMLDivElement, HTMLDivElement>({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/cli",
|
||||
"version": "1.17.13",
|
||||
"version": "1.17.14",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -5,6 +5,14 @@
|
||||
- Keep Code Mode unaware of host session, channel, and conversation models. The hosting application supplies trusted execution scope around it.
|
||||
- Tool schemas are the model-facing Interface. Keep arguments minimal and natural to the operation; never add unrelated IDs as ambient capability tokens.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
- Generate an operation only when its transport semantics are supported; otherwise return a precise `skipped` reason.
|
||||
- Never guess parameter serialization or malformed security semantics. Unsupported serialization is skipped and malformed security fails closed.
|
||||
- Render unresolved schema constructs as `unknown`, never as invented TypeScript names.
|
||||
- Keep network reads bounded and map expected encoding, transport, and decoding failures to model-safe `ToolError` values.
|
||||
- Test supported behavior directly; do not reproduce adapter algorithms in tests.
|
||||
|
||||
## Future Design Notes
|
||||
|
||||
- If a captured user-visible output channel returns (an earlier `output.text`/`output.file`/`output.image` API was removed from v1), keep `output` as its name, distinct from the program return value: `return` stays the structured result for the model, while `output.*` describes artifacts the host may render into a conversation or UI after execution. Keep this host-neutral and let applications decide how captured output is delivered. In v1, hosts collect media host-side (outside the sandbox) instead.
|
||||
|
||||
+63
-32
@@ -4,7 +4,7 @@ Effect-native confined code execution over explicit, schema-described tools.
|
||||
|
||||
CodeMode lets a model write a small JavaScript program that can call only the tools supplied by the host. The program can sequence calls, transform plain data, branch, loop, and run independent calls in parallel without receiving ambient filesystem, process, network, module, or application authority.
|
||||
|
||||
The package is currently private to this workspace. Its API is designed around three uses:
|
||||
The package is currently private to this workspace. Its API is designed around one-shot and reusable execution:
|
||||
|
||||
```ts
|
||||
// One execution
|
||||
@@ -13,9 +13,6 @@ yield * CodeMode.execute({ tools, code })
|
||||
// A reusable runtime
|
||||
const runtime = CodeMode.make({ tools, limits })
|
||||
yield * runtime.execute(code)
|
||||
|
||||
// One agent-facing code tool
|
||||
const codeTool = runtime.agentTool()
|
||||
```
|
||||
|
||||
## Install
|
||||
@@ -63,7 +60,7 @@ const result =
|
||||
`)
|
||||
```
|
||||
|
||||
`result` is always an `ExecuteResult`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
|
||||
`result` is always a `CodeMode.Result`. Program, validation, limit, and tool failures are returned as diagnostics rather than failing the Effect. Host interruption remains interruption.
|
||||
|
||||
Successful result values are JSON-safe data. A program that returns `undefined`, including by reaching the end without `return`, produces `null`; nested `undefined` values are normalized to `null` as well.
|
||||
|
||||
@@ -86,6 +83,8 @@ const tool = Tool.make({
|
||||
|
||||
The description and schemas are part of the model-visible tool contract. Keep descriptions concrete and put authorization in `run` or in the service it calls.
|
||||
|
||||
Public tool types are grouped under the same namespace: `Tool.Definition`, `Tool.Options`, `Tool.SchemaType`, and `Tool.JsonSchema`.
|
||||
|
||||
### `CodeMode.execute`
|
||||
|
||||
Use `CodeMode.execute` for a single execution:
|
||||
@@ -116,31 +115,32 @@ const runtime = CodeMode.make({
|
||||
|
||||
runtime.catalog() // structured tool descriptions
|
||||
runtime.instructions() // model-facing syntax and tool guide
|
||||
runtime.execute(source) // ExecuteResult
|
||||
runtime.agentTool() // { name, description, input, output, execute }
|
||||
runtime.execute(source) // CodeMode.Result
|
||||
```
|
||||
|
||||
`catalog`, `instructions`, and `agentTool` are projections of the same configured tool tree. `agentTool().description` is exactly `instructions()`.
|
||||
`CodeMode.Input`, `CodeMode.Result`, `CodeMode.Success`, `CodeMode.Failure`, `CodeMode.Diagnostic`, and `CodeMode.DiagnosticKind` are both Effect schemas and their inferred TypeScript types. Hosts can combine `CodeMode.Input` and `CodeMode.Result` with `runtime.instructions()` and `runtime.execute()` when constructing a framework-specific agent tool.
|
||||
|
||||
All other CodeMode types use the same namespace: `CodeMode.Options`, `CodeMode.ExecuteOptions`, `CodeMode.Runtime`, `CodeMode.ExecutionLimits`, `CodeMode.DiscoveryOptions`, `CodeMode.DataValue`, `CodeMode.ToolDescription`, and the `CodeMode.ToolCall*` observation types.
|
||||
|
||||
### Results
|
||||
|
||||
```ts
|
||||
type ExecuteResult = ExecuteSuccess | ExecuteFailure
|
||||
type Result = Success | Failure
|
||||
|
||||
interface ExecuteSuccess {
|
||||
interface Success {
|
||||
readonly ok: true
|
||||
readonly value: Schema.Json
|
||||
readonly value: CodeMode.DataValue
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<ToolCall>
|
||||
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
|
||||
}
|
||||
|
||||
interface ExecuteFailure {
|
||||
interface Failure {
|
||||
readonly ok: false
|
||||
readonly error: Diagnostic
|
||||
readonly error: CodeMode.Diagnostic
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<ToolCall>
|
||||
readonly toolCalls: ReadonlyArray<CodeMode.ToolCall>
|
||||
}
|
||||
```
|
||||
|
||||
@@ -152,16 +152,41 @@ interface ExecuteFailure {
|
||||
|
||||
`onToolCallEnd` receives `{ index, name, input, durationMs, outcome, message? }` when an admitted call settles. `outcome` is `"success"` or `"failure"`; `message` is the model-safe failure message and is present only on failure. Interrupted calls (for example when the execution timeout fires) do not produce an end event. Both hooks are Effect-returning and must not fail.
|
||||
|
||||
### OpenAPI tools
|
||||
|
||||
`OpenAPI.fromSpec` turns an OpenAPI 3.x document into a tool subtree - one tool per operation. Dotted `operationId` values form namespaces such as `v2.session.get`. Missing IDs receive a flat method/path fallback such as `getUsersById`; names are sanitized and deduplicated. The host places the subtree under a key in its `tools` tree; that key is the model-visible namespace.
|
||||
|
||||
```ts
|
||||
import { CodeMode, OpenAPI } from "@opencode-ai/codemode"
|
||||
import { Effect } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
|
||||
const api = OpenAPI.fromSpec({
|
||||
spec: await Bun.file("openapi.json").json(), // parsed document (no YAML)
|
||||
auth: {
|
||||
resolve: ({ name, scopes, operation }) =>
|
||||
name === "BearerAuth" ? Effect.succeed({ type: "bearer", token }) : Effect.succeed(undefined),
|
||||
},
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({ tools: { opencode: api.tools } })
|
||||
const result = await Effect.runPromise(runtime.execute(code).pipe(Effect.provide(FetchHttpClient.layer)))
|
||||
```
|
||||
|
||||
`fromSpec` is synchronous and returns `{ tools, skipped }`. The initial adapter supports query `form`/`deepObject`, path/header `simple`, JSON request bodies, JSON responses, and text responses; unsupported parameter encodings, non-JSON request bodies, binary responses, and streaming operations land in `skipped` instead of producing broken tools. Operation and path servers take precedence over document servers unless `baseUrl` explicitly overrides all of them. Tool inputs flatten path, query, header, and closed object-body fields into one model-facing object while retaining their HTTP locations internally. Cross-location name collisions receive a location prefix such as `path_id` and `query_id`; composed, nullable, dictionary, conditionally-required, and non-object JSON bodies remain under `body`. Auth is never model-visible. Responses are limited to 50 MiB, and non-2xx responses become safe tool failures carrying the status and a size-capped body summary. Deferred capabilities are tracked in `src/openapi/TODO.md`.
|
||||
|
||||
Supported bearer, basic, header, and query authentication follows OpenAPI `security` semantics and is resolved host-side via `auth.resolve` - credential storage, OAuth flows, and token refresh never enter the compiler. Cookie authentication alternatives are discarded; an operation is skipped when it has no supported alternative. See the option docstrings in `src/openapi/types.ts` for the full semantics. Generated tools require `HttpClient.HttpClient` (from `effect/unstable/http`) in the Effect environment - provide `FetchHttpClient.layer` or a custom/test client layer at execution. The supplied client owns redirect policy; credentialed hosts should reject redirects or strip credentials when the origin changes.
|
||||
|
||||
## Discovery
|
||||
|
||||
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature line against the shared budget, and a namespace whose next line does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
|
||||
The agent-tool instructions use a budgeted catalog. Every tool namespace is always listed with its tool count regardless of budget, and as many complete, JSDoc-annotated tool signatures (each with a one-line description) as fit an estimated-token budget are inlined. Schema field descriptions and tags are part of each signature's measured cost. Selection is round-robin across namespaces for fairness: in each round (namespaces alphabetical), every namespace still holding un-inlined tools attempts to place its next-cheapest signature against the shared budget, and a namespace whose next signature does not fit drops out while the others keep going - so every namespace gets some representation before any namespace gets everything. The instructions state exactly how comprehensive the list is, both overall (`COMPLETE list` vs `PARTIAL - N of M shown`) and per namespace (`(3 tools)`, `(3 tools, 1 shown)`, `(3 tools, none shown)`).
|
||||
|
||||
The default budget is 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). Override it when constructing a runtime:
|
||||
The catalog-entry budget defaults to 2,000 estimated tokens (characters / 4, the same heuristic OpenCode uses). It applies only to full tool entries shown in the catalog; fixed instructions and namespace summaries are not counted. Override it when constructing a runtime:
|
||||
|
||||
```ts
|
||||
const runtime = CodeMode.make({
|
||||
tools,
|
||||
discovery: { maxInlineCatalogTokens: 6_000 },
|
||||
discovery: { catalogBudget: 6_000 },
|
||||
})
|
||||
```
|
||||
|
||||
@@ -174,30 +199,37 @@ const matches = await tools.$codemode.search({
|
||||
query: "order status",
|
||||
namespace: "orders", // optional: scope to one top-level namespace
|
||||
limit: 10,
|
||||
offset: 0,
|
||||
})
|
||||
```
|
||||
|
||||
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path) and capped at `limit` results (default 10).
|
||||
`search` performs deterministic, additive field-weighted matching. The query is tokenized (camelCase boundaries split; every non-alphanumeric character is a separator; empties and `*` are dropped), and each term scores every tool: exact path or path-segment match (20), path substring (8), description substring (4), and searchable-text substring (2). Each term also carries naive singular variants (trailing `s`/`es` stripped), and a field check passes when the term or any variant matches - so a plural query term (`issues`) still finds a tool whose text only says `issue`, without changing the weights. The searchable text also includes the input schema's property names and their description strings, so a query naming a parameter finds its tool, and substring matching means partial words match. Scores sum across terms; matches are sorted by score (ties broken alphabetically by path), then sliced from the zero-based `offset` (default 0) to the configured `limit` (default 10). `remaining` counts matches after the current page. `next` is `{ offset }` when another page exists and `null` on the final page; spread it into the original request to preserve its query, namespace, and limit.
|
||||
|
||||
Each result contains the path, description, and generated TypeScript signature, so no second lookup is needed. The result signature is the pretty, JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`). The inline catalog in the instructions keeps the compact single-line form.
|
||||
```ts
|
||||
const request = { query: "order status", namespace: "orders", limit: 10 }
|
||||
const page = await tools.$codemode.search(request)
|
||||
const nextPage = page.next ? await tools.$codemode.search({ ...request, ...page.next }) : undefined
|
||||
```
|
||||
|
||||
Each result contains the path, description, and the same generated TypeScript signature used by the inline catalog, so no second lookup is needed. Signatures use the JSDoc-annotated multiline form: each described input/output field carries its schema `description` as a `/** ... */` comment, and constraints TypeScript cannot express ride along as tags (`@deprecated`, `@default`, `@format`, `@minItems`, `@maxItems`).
|
||||
|
||||
```ts
|
||||
tools.github.list_issues(input: {
|
||||
/** Repository owner */
|
||||
owner: string
|
||||
owner: string,
|
||||
/** Cursor from the previous response's pageInfo */
|
||||
after?: string
|
||||
after?: string,
|
||||
/**
|
||||
* Results per page
|
||||
* @default 30
|
||||
*/
|
||||
perPage?: number
|
||||
perPage?: number,
|
||||
}): Promise<unknown>
|
||||
```
|
||||
|
||||
Result paths are rendered as JavaScript expressions rooted at `tools` (`tools.orders.lookup`, or `tools.context7["resolve-library-id"]` for non-identifier segments), so each `path` is directly usable as the call site. An empty query browses the catalog alphabetically by path; combined with `namespace` (`{ query: "", namespace: "orders" }`) it lists everything in that namespace. A query that names one tool path exactly (canonical path, `tools.`-prefixed path, or rendered JavaScript expression) is treated as a lookup and returns that tool alone.
|
||||
|
||||
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; `JSON.parse` string results; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result tools exist inside `tools`; filter and aggregate collections in code; treat `Promise<unknown>` results as shapeless until verified; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace via search when it is advertised), a short `## Syntax` section that assumes standard JavaScript and names only what is unusual (TypeScript annotations stripped; the data-boundary serialization of Date/Map/Set/RegExp) or missing (classes, generators, `for await...of`, `.then`/`.catch`/`.finally`), and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
|
||||
The instructions are structured markdown, ordered so the workflow sits at the top and the catalog at the bottom: a `## Workflow` section with numbered steps (find a tool via search when the catalog is partial, or pick from the inlined list when it is complete; call the exact path as-is; return only the needed fields), a `## Rules` section holding only guidance the workflow does not already cover (only listed/search-result Code Mode tools and internal runtime tools exist inside `tools`; filter and aggregate collections in code; narrow `Promise<unknown>` results at runtime; run independent calls through `Promise.all`; enumerate `tools` with `Object.keys`/`for...in`; browse a namespace and paginate search results when search is advertised), a short `## Language` section that identifies the runtime as a restricted JavaScript orchestration language and names its major unavailable capabilities, and the budgeted `## Available tools` catalog. Example call forms use explicit `<namespace>.<tool>`/`<field>` placeholders - never a real or fabricated tool name.
|
||||
|
||||
A host cannot define its own `$codemode` top-level namespace.
|
||||
|
||||
@@ -209,14 +241,15 @@ CodeMode executes a deliberately bounded JavaScript subset. It supports:
|
||||
- `if`, conditional expressions, `switch`, `for`, `for...of` (arrays, strings, Maps, Sets), `for...in` (own keys of plain objects, index strings of arrays, and namespace/tool names of `tools` references - anything else is an error suggesting `for...of` or `Object.keys`, rather than real JS's surprising behavior of indices for strings and zero iterations for Maps/Sets), `while`, and `do...while`.
|
||||
- Arrow functions and function declarations with closures, defaults, rest parameters, and destructuring.
|
||||
- Optional chaining, nullish coalescing, templates, spread (arrays, strings, Maps, Sets), and `try`/`catch`.
|
||||
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces and `Object.keys(tools.ns)` the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
|
||||
- Common array, string, number, `Object`, `Math`, and `JSON` operations. Mutating array methods include `push`/`pop`/`shift`/`unshift`/`splice` (removes in place and returns the removed elements)/`fill`/`copyWithin`; array `keys`/`values`/`entries` return **arrays** (matching the Map/Set convention) and work with `for...of` and spread. String methods include `localeCompare` (locale/options arguments ignored), `normalize`, and the `trimLeft`/`trimRight` aliases. `Object.keys` also accepts arrays (index strings, as in JS) and tool references: `Object.keys(tools)` lists the top-level namespaces, including `$codemode`, and `Object.keys(tools.ns)` lists the names at that node (a callable tool enumerates as `[]`; an unknown path is an `UnknownTool` diagnostic). `Object.values`/`Object.entries` on a tool reference fail with a pointer at `Object.keys(tools)` and `tools.$codemode.search`.
|
||||
- `Date` - `Date.now()`/`Date.parse()`/`Date.UTC()`, `new Date(...)`, the getter methods, and date arithmetic/comparison via the time value. Dates stringify as ISO (`toString` included, for determinism across host timezones).
|
||||
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout. Function replacers are not supported.
|
||||
- Regular expressions - `/literals/` and `new RegExp(...)` with `test`/`exec` (stateful `lastIndex` for `g`), plus string `match`/`matchAll`/`replace`/`replaceAll`/`split`/`search` with patterns. Match results are arrays carrying `index` and named `groups` as own properties (`input` is omitted). `replace` and `replaceAll` accept function replacers with captures, offset, input, and named groups; callbacks run sequentially, may await tool calls, and have their results coerced to strings. Invalid patterns, invalid flags, and missing-`g` calls fail with catchable errors that say what was wrong and how to fix it (escaping hints, the exact `/pattern/g` to write). Patterns run on the host engine, so pathological backtracking is bounded only by the execution timeout.
|
||||
- `Map` and `Set` - construction from entries/arrays/strings, `get`/`set`/`add`/`has`/`delete`/`clear`/`size`/`forEach`, and `keys`/`values`/`entries` returning **arrays** (not iterators).
|
||||
- URL helpers - `URL` resolution and mutation, linked `URLSearchParams`, `URL.canParse`/`URL.parse`, URI and URI-component encoding/decoding, and query parameter construction, lookup, mutation, sorting, callbacks, and materialization. URLSearchParams iteration methods return arrays, matching the Map/Set convention.
|
||||
- First-class promises - an un-awaited `tools.ns.tool(...)` is a promise value whose call starts immediately on a supervised fiber; `await` resolves it (awaiting a non-promise value is a no-op, and `return tools.ns.tool(...)` resolves like an async-function return). `Promise.all`, `Promise.allSettled`, and `Promise.race` accept any array mixing promises and plain values (built inline, beforehand, or via spread); `Promise.resolve`/`Promise.reject` construct settled promises. `Promise.allSettled` rejection reasons are the same plain `{ name?, message }` data a `catch` binding sees, and `Promise.race` interrupts its losing in-flight calls. At most 8 tool calls run concurrently. When a program completes, still-running un-awaited calls are awaited before the execution ends; a failure from a call that was never awaited surfaces as an unhandled-rejection diagnostic.
|
||||
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
|
||||
- `throw value` and `throw new Error(message)` for explicit program failure. `Error` (and `TypeError`/`RangeError`/`SyntaxError`/`ReferenceError`/`EvalError`/`URIError`) are real constructors, callable with or without `new`; error values are plain `{ name, message }` data that additionally satisfy `instanceof Error` (a specific type matches itself and `Error`, as in JS). Every caught failure - thrown errors, interpreter runtime errors, and tool failures - is `instanceof Error` in a `catch` block; a thrown non-error value (`throw "text"`) is not, matching JS. Caught failures carry the `name` the equivalent real-JS failure would have - `JSON.parse` and invalid regex patterns produce a `SyntaxError` (satisfying `instanceof SyntaxError`), an unknown identifier a `ReferenceError`, assigning to a constant a `TypeError`, a bad `normalize` form a `RangeError`; failures with no specific analogue (including tool failures) are named `"Error"`. `instanceof` also recognizes `Date`, `RegExp`, `Map`, `Set`, `URL`, `URLSearchParams`, `Array`, `Object`, and `Promise`; any other right-hand side is a catchable error.
|
||||
|
||||
Inside a program, Date/RegExp/Map/Set values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do the four value types serialize exactly as `JSON.stringify` would: a Date becomes its ISO string (`null` when invalid) and RegExp/Map/Set become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
|
||||
Inside a program, standard-library values stay live everywhere: the internal data checkpoints (`Object.*` helpers, spread, coercion inputs) preserve the instances, so `Object.values({ d: date })[0].getTime()` and a spread copy of an object holding a Map keep working. Only at the host boundary (final result, tool arguments, `JSON.stringify`) do they serialize exactly as `JSON.stringify` would: Date and URL become strings (an invalid Date becomes `null`), while RegExp, Map, Set, and URLSearchParams become `{}`. Promise values never cross a data boundary: an un-awaited promise in a result or tool argument produces a diagnostic that says to await it, instead of serializing to `{}`.
|
||||
|
||||
It does not expose `eval`, dynamic imports, modules, classes, generators, timers, host globals, prototype mutation, custom promise constructors (`new Promise`), promise chaining (`.then`/`.catch`/`.finally` - `await` with `try`/`catch` is the supported style), or arbitrary method calls. Unsupported syntax returns an `UnsupportedSyntax` diagnostic with a source location when available.
|
||||
|
||||
@@ -279,7 +312,7 @@ import { toolError } from "@opencode-ai/codemode"
|
||||
run: ({ id }) => (authorized(id) ? loadOrder(id) : Effect.fail(toolError("Order is unavailable")))
|
||||
```
|
||||
|
||||
Only the supplied message is model-visible. The optional cause is never returned in `ExecuteResult`; hosts should perform any required internal logging before crossing this boundary.
|
||||
Only the supplied message is model-visible. The optional cause is never returned in `CodeMode.Result`; hosts should perform any required internal logging before crossing this boundary.
|
||||
|
||||
## Authority Boundary
|
||||
|
||||
@@ -308,12 +341,10 @@ A program cannot gain authority through prose or generated code. It can only exe
|
||||
The public contract is guided by these equivalences:
|
||||
|
||||
- `CodeMode.execute({ ...options, code })` is equivalent to `CodeMode.make(options).execute(code)`.
|
||||
- `CodeMode.make(options).agentTool().execute({ code })` is equivalent to `CodeMode.make(options).execute(code)`.
|
||||
- `CodeMode.make(options).agentTool().description` equals `CodeMode.make(options).instructions()`.
|
||||
- A tool implementation is not invoked unless its input has decoded successfully.
|
||||
- A tool result is not visible to the program unless its output has decoded and crossed the plain-data boundary successfully.
|
||||
- Unknown host failures do not become model-visible diagnostics; `ToolError` is the explicit safe-message channel.
|
||||
- Host interruption remains interruption rather than an `ExecuteFailure`.
|
||||
- Host interruption remains interruption rather than a `CodeMode.Failure`.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
|
||||
+154
-1196
@@ -1,1218 +1,176 @@
|
||||
# CodeMode - Status, Decisions, and Remaining Work
|
||||
# CodeMode Design and Status
|
||||
|
||||
This document is the working plan for `@opencode-ai/codemode` and its OpenCode integration.
|
||||
It captures every locked decision, everything already implemented, and a detailed TODO of what
|
||||
remains - enough context that someone (human or agent) can pick up any item cold.
|
||||
This is the living design and status document for `@opencode-ai/codemode` and its existing V2 OpenCode adapter.
|
||||
It records current behavior, intentional boundaries, durable rationale, and material remaining work.
|
||||
|
||||
Tracking issue: https://github.com/anomalyco/opencode/issues/34787
|
||||
Working branch: `codemode-v2` (base: `dev`)
|
||||
Completed implementation history, branch names, test counts, and closed findings belong in git, not here. Remove
|
||||
completed work instead of preserving checked-off chronology.
|
||||
|
||||
---
|
||||
Detailed package API documentation lives in [README.md](./README.md). OpenAPI-specific follow-ups live in
|
||||
[src/openapi/TODO.md](./src/openapi/TODO.md).
|
||||
|
||||
## 1. What this is
|
||||
## How CodeMode Works
|
||||
|
||||
CodeMode gives a model one `execute` tool that runs JavaScript/TypeScript programs against a
|
||||
tree of schema-described tools (`tools.<namespace>.<tool>(input)`), instead of exposing dozens
|
||||
of MCP tools individually. The point is **control flow**: sequencing, filtering, and composing
|
||||
tool calls in one program instead of round-tripping through the agent loop, plus not flooding
|
||||
the context window when users connect many MCP servers.
|
||||
### Purpose
|
||||
|
||||
Architecture split (locked):
|
||||
CodeMode gives a model one `execute` tool backed by a confined JavaScript interpreter. Inside the program, the model
|
||||
can call an explicit tree of schema-described tools, sequence dependent work, run independent calls concurrently,
|
||||
and filter or aggregate results before returning them to the agent loop.
|
||||
|
||||
- **`packages/codemode` (`@opencode-ai/codemode`)** - the generic, host-agnostic runtime:
|
||||
a hand-rolled, Effect-native, tree-walking interpreter over acorn ASTs (TypeScript stripped
|
||||
via `typescript`'s `transpileModule`), the tool runtime/data boundary, discovery/search, and
|
||||
`Tool.make`. It knows nothing about OpenCode, MCP, permissions, or rendering.
|
||||
- **`packages/opencode`** - the OpenCode integration: an MCP adapter that converts MCP tool
|
||||
definitions into `Tool.make(...)` definitions, permission gating, host-side attachment
|
||||
collection, the agent-facing `execute` tool, and TUI progress rendering.
|
||||
The goals are:
|
||||
|
||||
This package was seeded from the experiments workspace implementation
|
||||
(`experiments/agents/packages/codemode`, package `@agents/codemode`) and then modified here.
|
||||
The older vendored interpreter in `packages/opencode/src/session/rune/` was superseded by this
|
||||
package and was **deleted** in Wave 3 (done, see below).
|
||||
- Reduce model context consumed by large tool catalogs.
|
||||
- Avoid an agent round-trip between every dependent tool call.
|
||||
- Keep large intermediate results inside the program instead of sending them through model context.
|
||||
- Give generated code only the authority explicitly supplied by the host.
|
||||
|
||||
---
|
||||
CodeMode is an orchestration language, not a general JavaScript runtime or an application authorization system.
|
||||
|
||||
## 2. Locked decisions
|
||||
### Runtime
|
||||
|
||||
From issue #34787 and design discussion. Do not relitigate these casually.
|
||||
The generic runtime lives in `packages/codemode` and is host-neutral:
|
||||
|
||||
### Core direction
|
||||
1. The host builds a tree of `Tool.make(...)` definitions and calls `CodeMode.make(...)` or `CodeMode.execute(...)`.
|
||||
2. CodeMode generates model instructions, a budgeted inline catalog, and the internal `$codemode.search` tool.
|
||||
3. TypeScript syntax is transpiled away, Acorn parses the resulting JavaScript, and an owned tree-walking interpreter
|
||||
executes it without `eval`.
|
||||
4. Tool inputs and outputs cross schema and plain-data boundaries before they become visible on either side.
|
||||
5. Execution returns `CodeMode.Result`. Expected program and tool failures are diagnostic data; host interruption
|
||||
remains Effect interruption.
|
||||
|
||||
- Generic CodeMode lives in its own package: `@opencode-ai/codemode` (repo scope convention;
|
||||
the issue's `@opencode/codemode` name was normalized to the `@opencode-ai/*` convention).
|
||||
- **Keep the hand-rolled interpreter.** No QuickJS/V8/sandbox-engine dependency. We own and
|
||||
test the whole surface; the model only needs orchestration syntax, not a full runtime.
|
||||
- Naming: `CodeMode`, `Tool`, `ToolError`, `UnknownTool` (diagnostic kind), `$codemode`
|
||||
reserved discovery namespace. (Historical names - "rune", "capability" - are dead.)
|
||||
- Existing OpenCode core tools (bash/edit/patch/...) stay registered normally for v1.
|
||||
CodeMode covers MCP tools, user-registered tools, and deferred tools only.
|
||||
- Test runner is `bun test`; typecheck is `tsgo --noEmit` (repo conventions). Not vitest.
|
||||
- **Never reference external prior-art implementations** (other companies' code-execution
|
||||
products/blog posts) in code, comments, commit messages, or docs in this repo.
|
||||
Effect Schemas validate and transform tool inputs and outputs. JSON Schemas render model-facing signatures but do not
|
||||
validate values; adapter-provided values still cross the plain-data boundary. A tool without an output schema is
|
||||
advertised as `Promise<unknown>`.
|
||||
|
||||
### MCP / tools
|
||||
### Discovery and model workflow
|
||||
|
||||
- The MCP adapter lives in OpenCode, not here. It converts MCP definitions into ordinary
|
||||
`Tool.make(...)` definitions and hands CodeMode a plain tool tree.
|
||||
- Permissions stay in the OpenCode adapter (each tool's `run` wraps the permission ask).
|
||||
CodeMode stays dumb - no permission model in this package.
|
||||
- Namespace collisions: last write wins (plain JS object override). No `tools.mcp.*` prefix,
|
||||
no `_2` suffixing, no cleverness. OpenCode groups flat `server_tool` MCP names into
|
||||
`tools.<server>.<tool>` namespaces before handing them over.
|
||||
The model sees a token-budgeted catalog. Every namespace remains visible, and complete signatures are selected
|
||||
round-robin across namespaces so one large namespace cannot starve the others. `$codemode.search` is always callable
|
||||
and is advertised when the inline catalog is partial.
|
||||
|
||||
### Discovery / search
|
||||
The intended workflow is:
|
||||
|
||||
- **Search only - no separate `describe`.** `tools.$codemode.search({ query?, namespace?,
|
||||
limit? })` over the final tool tree, owned by this package.
|
||||
- Search result item shape: `{ path, description, signature }` in an `{ items, total }`
|
||||
wrapper. The `signature` string embeds the full input/output TypeScript types - in search
|
||||
results it is the pretty, JSDoc-annotated multiline form (Fix 7), so per-field schema
|
||||
`description`s and constraints (`@default`, `@format`, `@deprecated`, `@minItems`,
|
||||
`@maxItems`) ride along as field comments. The original spec's separate `input`/`output`
|
||||
raw-schema fields are deliberately NOT added: shapes are already fully expressed in the
|
||||
TypeScript signature and schema annotations now arrive as JSDoc - intent satisfied, letter
|
||||
deviated. Result `path`s render a JavaScript expression rooted at `tools` (for example
|
||||
`tools.github.list_issues` or `tools.context7["resolve-library-id"]`) so each is directly
|
||||
usable as the call site; the internal `ToolDescription.path` stays unprefixed.
|
||||
- Default limit: **10** (done). Exact-path lookup goes through search too: a query equal to a
|
||||
canonical tool path, `tools.`-prefixed path, or rendered JavaScript expression returns that
|
||||
tool alone (done).
|
||||
- Signatures render **native payloads**: `Promise<Issue>`, NOT `Promise<Result<Issue>>`.
|
||||
There is no result envelope; attachments never appear in return types (they are collected
|
||||
host-side, see below).
|
||||
- Tools without an output schema render `unknown` as their return type.
|
||||
1. Pick an exact signature from the inline catalog, or return `$codemode.search(...)` results and use a selected path
|
||||
in the next execution.
|
||||
2. Call the exact returned path without guessing or normalizing segments.
|
||||
3. Narrow `Promise<unknown>` results before reading fields.
|
||||
4. Start independent calls together and await them with `Promise.all`.
|
||||
5. Filter and aggregate inside the program, then return only the data needed by the model.
|
||||
|
||||
### Schemas / Tool.make
|
||||
Search returns directly usable JavaScript paths, descriptions, and complete TypeScript signatures. It supports exact
|
||||
path lookup, namespace browsing, deterministic ranking, and pagination.
|
||||
|
||||
- `Tool.make` carries rich metadata so search can render real signatures.
|
||||
- Support **Effect Schema** (first-class, validating) and **JSON Schema** (initially
|
||||
render-only - used for TypeScript rendering; the adapter may validate on its own). Leave
|
||||
room for Standard Schema later.
|
||||
- Tool implementations are **Effect-based** for v1 (`run` returns `Effect`). Promise
|
||||
normalization for plugin authors can come later.
|
||||
### Tool execution
|
||||
|
||||
### Attachments / output
|
||||
Calling a tool starts its Effect eagerly on a supervised fiber. The returned sandbox promise is run-once and can be
|
||||
awaited directly or through the supported `Promise` combinators. At most eight tool calls execute concurrently.
|
||||
Unfinished calls are drained before successful program completion, and an unhandled call failure becomes a diagnostic.
|
||||
|
||||
The public execution-policy knobs are `timeoutMs`, `maxToolCalls`, and `maxOutputBytes`. The package supplies no
|
||||
defaults because budgets are host policy. The interpreter also enforces fixed internal boundaries for tool-call
|
||||
concurrency and data nesting depth.
|
||||
|
||||
- **No `output.text/file/image` API in v1.** (Deleted in Wave 2.)
|
||||
- Tool calls return native structured payloads into the sandbox. Files/images emitted by
|
||||
child tools **never enter the sandbox** - the OpenCode adapter strips and accumulates them
|
||||
host-side as calls happen, then returns them on the outer `execute` tool result as ordinary
|
||||
tool-result attachments (OpenCode already has `Tool.ExecuteResult.attachments` -> vision
|
||||
plumbing in `message-v2.ts`).
|
||||
- No base64 in CodeMode values, ever. The model routes nothing; it can't accidentally dump
|
||||
image bytes into context or drop attachments.
|
||||
### Data, files, and failures
|
||||
|
||||
### Runtime behavior
|
||||
|
||||
- Limits are EXACTLY the three public knobs: `{ timeoutMs, maxToolCalls, maxOutputBytes }` -
|
||||
matching the original locked spec exactly. NO limit has a default (user direction, Fix 6
|
||||
for the first two; extended to `maxOutputBytes` in the truncation-layering fix below):
|
||||
absent = no timeout / unlimited calls / no output truncation - budgets are host policy.
|
||||
A host without its own output bounding should set `maxOutputBytes` explicitly, or
|
||||
oversized results silently flood model context. OpenCode's adapter policy (user
|
||||
direction): NO limits at all - no timeout, unlimited tool calls (each child call is
|
||||
permission-gated; user cancel interrupts the execution fiber and its children), and no
|
||||
CodeMode truncation (output bounding is OpenCode's native tool-output truncation).
|
||||
The internal limit system that Wave 2 kept behind
|
||||
an `@internal` `InternalExecutionLimits` type (maxOperations, maxDataBytes, maxValueDepth,
|
||||
maxCollectionLength, maxSourceBytes, maxAuditBytes, maxConcurrency) was deleted outright in
|
||||
Fix 5 (see Post-wave fixes). Two internals survive as fixed constants, not knobs:
|
||||
`TOOL_CALL_CONCURRENCY = 8` (the fork semaphore) and `MAX_VALUE_DEPTH = 32` (the `copyIn`
|
||||
boundary depth check, kept only because it beats a native stack-overflow RangeError as an
|
||||
error message; still reports `InvalidDataValue`).
|
||||
- Truncation layering RESOLVED (user direction): CodeMode truncation is off in OpenCode.
|
||||
`execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation
|
||||
(50KB / 2000 lines in `tool.ts` + `truncate.ts`, full output dumped to a file) applies to
|
||||
it with no special-casing - verified by tracing `wrap()` in `tool.ts:130-144` (the
|
||||
`metadata.truncated` exemption never fires for `execute`). One truncation layer, the
|
||||
host's. `maxOutputBytes` remains available for hosts without their own bounding.
|
||||
- Pure-JS built-ins only. **No ambient authority**: no fs, child processes, network/fetch,
|
||||
process/env, or timers in v1. The agent has the bash tool for that.
|
||||
- Forgiving JS semantics are locked (see section 3, Wave 1a/1b-i) - missing props read `undefined`,
|
||||
`typeof` never throws, NaN/Infinity flow in-sandbox, etc.
|
||||
- `console.*` is captured into `logs` on the result; the host appends them to model-facing
|
||||
output. Not a tool call; costs no tool budget.
|
||||
- Simple tool-call **start/end hooks** for nested progress: `onToolCallStart({ index, name,
|
||||
input })` and `onToolCallEnd({ index, name, input, durationMs, outcome, message? })`.
|
||||
Interrupted calls fire no end event. No `CurrentToolCall` context service (removed in
|
||||
Wave 2).
|
||||
|
||||
---
|
||||
|
||||
## 3. Current status (what is already done on `codemode-v2`)
|
||||
|
||||
Everything below is committed and pushed on `codemode-v2` (six commits, in pairs of
|
||||
generic-package + OpenCode-integration: waves 0-5, Fixes 4-9, then the DSL-expansion pass /
|
||||
real-JS error names / truncation layering). Verification: from `packages/codemode`,
|
||||
`bun test` (211 pass / 0 fail across `codemode/parity/stdlib/promise/enumeration/signature`)
|
||||
and `bun run typecheck`; from `packages/opencode`, `bun run typecheck` and
|
||||
`bun test test/tool/` (all green - the adapter suites are `test/tool/code-mode.test.ts`,
|
||||
43 tests, and `test/tool/code-mode-integration.test.ts`, 16 tests, moved from
|
||||
`test/session/` by the registry promotion; registry coverage in
|
||||
`test/tool/registry.test.ts`).
|
||||
|
||||
### Wave 0 - scaffold (done)
|
||||
|
||||
- `packages/codemode` created from the experiments implementation: `src/{index,codemode,tool,
|
||||
tool-error,tool-runtime}.ts`, README, AGENTS.md, tests.
|
||||
- `package.json`: name `@opencode-ai/codemode`, deps `acorn@8.15.0`, `typescript: catalog:`,
|
||||
`effect: catalog:` (both repos pin effect `4.0.0-beta.83`; opencode's effect patch only
|
||||
touches `unstable/httpapi`, which this package doesn't use).
|
||||
- Tests converted vitest -> `bun:test`. Only src change from verbatim: the `CurrentToolCall`
|
||||
Context.Service key string renamed to `@opencode-ai/codemode/CurrentToolCall`.
|
||||
|
||||
### Wave 1a - forgiving JS semantics (done)
|
||||
|
||||
Ported from the old opencode rune work; `test/parity.test.ts` (24 tests) is the acceptance
|
||||
spec. The seeded interpreter was deliberately strict; these behaviors replaced that:
|
||||
|
||||
- **H1**: NaN/Infinity flow as in-sandbox values (`copyIn` admits them; `NaN`/`Infinity` are
|
||||
bindable globals; `charCodeAt` returns real NaN). Normalized to `null` only at the data
|
||||
boundary (`copyOut` - single chokepoint for final results AND tool-call arguments), matching
|
||||
`JSON.stringify`. Guards like `Number.isNaN(x)` / `parseInt(x) || 0` work.
|
||||
- **H2/H3**: unknown property reads on strings/numbers/arrays -> `undefined` (incl. under
|
||||
`?.`), instead of throwing. This was the real-transcript failure: models write
|
||||
`result?.login ?? result` against JSON-string tool results.
|
||||
- **H4**: `typeof undeclaredIdentifier` -> `"undefined"` (short-circuits before resolution).
|
||||
- **H5**: `Boolean`/`String`/`Number` accepted as array callbacks (`filter(Boolean)`).
|
||||
- **H6**: `{...null}` / `{...undefined}` object spread is a no-op. Array spread of
|
||||
null/undefined still throws (real JS throws too).
|
||||
|
||||
### Wave 1b-i - stdlib value types: Date, RegExp, Map, Set (done)
|
||||
|
||||
`src/values.ts` holds `SandboxDate/SandboxRegExp/SandboxMap/SandboxSet` (own module so both
|
||||
`codemode.ts` and `tool-runtime.ts` import without a cycle). Design:
|
||||
|
||||
- Opaque-by-default: all four join `isRuntimeReference`, with explicit carve-outs (member
|
||||
access allowlists, Date in binary/unary ops, Map/Set in spread/for...of, console formatting,
|
||||
`containsOpaqueReference` for operator guards; the `runtimeValueBytes` byte-accounting
|
||||
carve-out died with that machinery in Fix 5).
|
||||
- **JSON semantics at every boundary and checkpoint**: Date -> ISO string (invalid -> null),
|
||||
RegExp/Map/Set -> `{}`. `copyIn` also converts host `Date`/`RegExp`/`Map`/`Set` instances the
|
||||
same way (a host tool may legitimately return them). (Narrowed by the DSL-expansion pass:
|
||||
intra-sandbox checkpoints now preserve the instances; JSON forms apply at the host
|
||||
boundary only.)
|
||||
- Date: `Date.now/parse/UTC`, `new Date(epoch|string|components)`, getters + UTC variants,
|
||||
`end - start`, `a < b`, `+date`; `toString` is ISO for cross-host determinism.
|
||||
- RegExp: literals + `new RegExp`, `test`/`exec` (stateful `lastIndex` for `g`), string
|
||||
`match/matchAll/replace/replaceAll/split/search`. Match results are plain arrays carrying
|
||||
`index`/named `groups` as own properties (enabled by a general array own-property read fix);
|
||||
`input` omitted deliberately. Function replacers unsupported (clear error). Patterns run on
|
||||
the host engine - catastrophic backtracking is bounded only by `timeoutMs` (accepted, in
|
||||
README).
|
||||
- Map/Set: full method sets; `keys/values/entries` return **arrays** (not iterators);
|
||||
`for...of` + spread work; `Object.fromEntries(map)`, `Array.from(map|set)`; SameValueZero
|
||||
keys (NaN findable). (The incremental byte totals and `maxCollectionLength`/`maxDataBytes`
|
||||
enforcement this wave added were deleted in Fix 5.)
|
||||
- Rode along, same spirit: `typeof` never throws for any value (`typeof fn` -> `"function"`),
|
||||
`!` works on any value, `for...of` over strings, `{...sandboxValue}` no-op, template
|
||||
interpolation renders `/regex/` and ISO dates directly.
|
||||
|
||||
### Wave 2 - API layer (done)
|
||||
|
||||
The package's public contract, reshaped for the Wave 3 adapter. 101 tests / 0 fail after this
|
||||
wave; both packages typecheck clean.
|
||||
|
||||
- **`Tool.make` schema flexibility** (`src/tool.ts`): `input`/`output` each accept an Effect
|
||||
Schema (validating, decoded both directions as before) OR a raw JSON Schema document
|
||||
(render-only - no validation, values pass through; rendering handles `$defs`/`definitions`
|
||||
- `$ref`). `output` is **optional** -> signature renders `Promise<unknown>` and the host
|
||||
result is exposed as-is. Discrimination via `Schema.isSchema`. New helpers exported from
|
||||
`tool.ts`: `inputTypeScript`/`outputTypeScript`/`decodeInput`/`decodeOutput`/
|
||||
`jsonSchemaToTypeScript`; `tool-runtime.ts` consumes them (no direct `Schema.*` use there
|
||||
anymore). Types `JsonSchema`/`ToolSchema` exported from the index. Note: an empty
|
||||
`Schema.Struct({})` renders as `{ } | Array<unknown>` (effect's JSON Schema emission) -
|
||||
cosmetic, fixed in Wave 4.
|
||||
- **`output.*` API deleted**: `OutputItem`(+Schema), result `output` fields, the `output`
|
||||
global/namespace dispatch, `invokeOutput`/`outputItem`/helpers, interpreter output fields,
|
||||
instructions line, README section, seeded tests. AGENTS.md keeps a rephrased
|
||||
future-design note (channel name stays `output` if it ever returns).
|
||||
- **Hooks**: `CurrentToolCall` removed entirely (class, provideService, `Services` Exclude
|
||||
special-casing, index export). `onToolCall` -> `onToolCallStart({ index, name, input })` +
|
||||
`onToolCallEnd({ index, name, input, durationMs, outcome: "success"|"failure", message? })`.
|
||||
End fires symmetrically via `Effect.tap`/`tapError` around the settling portion (host run +
|
||||
output decode + boundary copy; search too - its post-record body is wrapped in `Effect.try`
|
||||
so failures are typed and observable). `message` is the model-safe failure message
|
||||
(`ToolError`/`ToolRuntimeError` message, else "Tool execution failed"). Interrupted calls
|
||||
fire no end event (timeout kills the whole execution anyway).
|
||||
- **Limits collapse**: public `ExecutionLimits` = `{ timeoutMs?, maxToolCalls?,
|
||||
maxOutputBytes? }` (defaults 10_000 / 100 / 32_000). This wave kept the other knobs as
|
||||
internal defaults reachable through an `@internal` `InternalExecutionLimits` type; Fix 5
|
||||
later deleted that type and the internal limit system entirely.
|
||||
- **`maxOutputBytes` truncation** (CodeMode-owned, never fails): applied via `boundOutput` in
|
||||
a final `Effect.map` over every result path (success/timeout/normalized failure). Oversized
|
||||
serialized values become truncated text + ` [result truncated: N bytes exceeds the M-byte
|
||||
output limit; return a smaller value]`; logs keep leading lines within the remaining budget
|
||||
- `[logs truncated: showing K of N lines]`; result gains `truncated: true` (also added to
|
||||
`ExecuteResultSchema`). UTF-8-safe truncation (no split code points). (The in-sandbox
|
||||
`maxDataBytes` check that used to throw first on oversized raw values died in Fix 5 -
|
||||
truncation is now the only result-size mechanism.)
|
||||
- **Search polish**: default limit 12 -> **10** (`defaultSearchLimit`); exact-path lookup - a
|
||||
trimmed query equal to one tool path (optionally `tools.`-prefixed) returns that tool alone
|
||||
(`total: 1`), bypassing ranking. Tokenization/ranking/shape unchanged.
|
||||
|
||||
### Wave 3 - OpenCode MCP adapter (done)
|
||||
|
||||
`packages/opencode/src/session/code-mode.ts` rewritten as a thin adapter over this package;
|
||||
the vendored rune interpreter is gone. Same `define(mcpTools, mcpDefs, servers)` signature, so
|
||||
`tools.ts` gating (flag on + MCP tools exist -> single `execute` tool, early-return suppresses
|
||||
per-MCP registration; MCP resource tools unaffected) is unchanged.
|
||||
|
||||
- **Tool tree**: `groupByServer` (longest-sanitized-prefix, ported) groups flat `server_tool`
|
||||
keys into `CatalogEntry`s carrying the raw MCP `inputSchema`/`outputSchema` as render-only
|
||||
JSON Schema; `toolTree` turns each into `Tool.make({ description, input, output?, run })`
|
||||
under `tools.<server>.<tool>`. The agent-facing description is
|
||||
`CodeMode.make({ tools }).instructions()` over a preview tree (placeholder runs, never
|
||||
invoked) - so signature rendering, the inline-vs-search switch, and `$codemode.search`
|
||||
availability all come from this package and stay consistent with execution.
|
||||
- **`run` path**: per-child permission ask first (`ctx.ask({ permission: entry.key, patterns:
|
||||
["*"], always: ["*"] })`, exactly the old gating; approving `execute` approves no child).
|
||||
Denials and host failures are mapped to `toolError(message)` so they surface as safe,
|
||||
catchable in-program failures (MCP `isError` text propagates as `e.message`; without this
|
||||
they'd be sanitized to "Tool execution failed"). Dispatch reuses the ai-sdk wrapper from
|
||||
`catalog.convertTool` (`entry.tool.execute!`), which owns callTool timeouts/progress-reset.
|
||||
- **Result shaping** (`toSandboxResult`): prefer `structuredContent`; else joined text
|
||||
content; media (image/audio/resource blob/resource_link) NEVER enters the sandbox - blocks
|
||||
are stripped into a per-execution `Attachment[]` accumulator, and a media-only result
|
||||
becomes a marker payload (`"[1 image attached to the result]"`, noun/count adjusted). An
|
||||
MCP-shaped result with nothing extractable becomes `null`; non-MCP values pass through.
|
||||
No handles, no `Result<T>` envelope, no base64 in the sandbox, no data-size tuning (the
|
||||
`maxDataBytes` budget that existed at the time was deleted in Fix 5).
|
||||
- **Execute result**: `{ output: formatValue(value) + trailing "Logs:" section (success AND
|
||||
error - logs are plain pre-formatted lines now), attachments: accumulated }` through the
|
||||
existing `Tool.ExecuteResult.attachments` -> `message-v2.ts` vision plumbing; attachments
|
||||
ride on both success and error results. Diagnostic `suggestions` not already contained in
|
||||
the message are appended to error output. Native outer truncation stays on (adapter never
|
||||
sets `metadata.truncated`); CodeMode's own `maxOutputBytes` (32 KB default at the time)
|
||||
cut first - since the truncation-layering fix, native truncation is the only layer.
|
||||
Limits: `{ timeoutMs: 30_000 }` at the time (matched the default MCP request timeout);
|
||||
killed in Fix 6 - the adapter now passes no limits at all.
|
||||
- **Progress**: `onToolCallStart`/`onToolCallEnd` -> `ctx.metadata({ toolCalls })` with
|
||||
`{ tool, status: running|completed|error, input? }` per call index - the exact shape the
|
||||
TUI `Execute` component (`packages/tui/src/routes/session/index.tsx`) already renders.
|
||||
`$codemode.search` calls stream through the same channel.
|
||||
- **Deletions/deps**: `src/session/rune/` (all five files) and
|
||||
`test/session/rune-parity.test.ts` (superseded by this package's `test/parity.test.ts`)
|
||||
deleted; `acorn` removed from opencode deps, `typescript` moved back to devDependencies,
|
||||
`"@opencode-ai/codemode": "workspace:*"` added; `bun install` run (lockfile updated).
|
||||
- **Tests**: both opencode suites rewritten against the adapter design -
|
||||
`code-mode.test.ts` (34: grouping, description/signature rendering incl. the large-catalog
|
||||
search fallback, execution, permission flow + denial, metadata streaming, attachment
|
||||
accumulation + media-only marker, logs on success/error, truncation marker,
|
||||
`toSandboxResult`/`formatValue`/`withLogs` units) and `code-mode-integration.test.ts`
|
||||
(16: real in-memory MCP server; native structured results, attachment accumulation, isError
|
||||
propagation, logs, permissions, live metadata). Old envelope/attachment-handle/`$rune`
|
||||
describe/`renderType`/`rankTools` tests died with the old design (58+17+24 -> 34+16).
|
||||
|
||||
### Wave 4 - instructions/prompting + polish (done)
|
||||
|
||||
Instructions are now the budgeted-catalog + prompting-guidance form; verified e2e against a
|
||||
real MCP config. Package still 101 tests / 0 fail; opencode adapter suites still 34 + 16; both
|
||||
packages typecheck clean.
|
||||
|
||||
- **Budgeted catalog** (`discoveryPlan` in `tool-runtime.ts`): the all-or-nothing
|
||||
inline/search modes are gone - `DiscoveryMode` deleted, `DiscoveryOptions` is just
|
||||
`{ maxInlineCatalogBytes? }` (default 16,000 UTF-8 bytes; later converted to
|
||||
`maxInlineCatalogTokens`, default 4,000 estimated tokens - see Post-wave fixes). Port of
|
||||
the old opencode
|
||||
`describe()` `PREVIEW_BUDGET` algorithm, adapted to `ToolDescription`: every namespace is
|
||||
ALWAYS listed with its tool count; full signature lines
|
||||
(` - <signature> // <first line of description, capped at 120 chars>`) are inlined
|
||||
cheapest-first (line byte length, path tiebreak) within each namespace, namespaces processed
|
||||
alphabetically; once one line does not fit, inlining stops for every remaining namespace
|
||||
(counts only), exactly like the ported algorithm (this stop-everything behavior was later
|
||||
replaced by round-robin fairness in Fix 8). The header states comprehensiveness
|
||||
precisely: "Available tools (COMPLETE list - ...)" vs "Available tools (PARTIAL - N of M
|
||||
shown; find the rest with tools.$codemode.search)"; namespace labels are `(N tools)` /
|
||||
`(N tools, K shown)` / `(N tools, none shown)`. An empty tree renders "No tools are
|
||||
currently available."
|
||||
- **Search always registered** (documented decision): `DiscoveryPlan.searchIndex` is required
|
||||
and built unconditionally (new exported `ToolRuntime.searchIndex(tools)`; `SearchEntry` type
|
||||
exported); `CodeMode.execute` (one-shot) passes it too, preserving the
|
||||
`execute`==`make().execute` law. A speculative `tools.$codemode.search` call on a small
|
||||
catalog now succeeds instead of `UnknownTool`, and unknown-tool suggestions always point at
|
||||
search. Search is _advertised_ in the instructions only when the inlined list is PARTIAL,
|
||||
keeping small-catalog instructions tight.
|
||||
- **Prompting content** in `instructions()`, mapping 1:1 to the section 5 transcript failures:
|
||||
parse-string-results-as-JSON, return-small, console-for-intermediates, and
|
||||
read-the-description-before-calling guidance. (The flat prose layout this wave produced
|
||||
was later replaced wholesale by the markdown-section restructure - see Post-wave fixes -
|
||||
which also deleted this wave's worked example.)
|
||||
- **Cosmetic renderer fixes** (`renderSchema` in `tool.ts`): an object schema with no
|
||||
properties renders `{}` (was `{ }`), and the empty `Schema.Struct({})` emission
|
||||
(`anyOf: [{ type: "object" }, { type: "array" }]`, no properties/items) collapses to `{}`
|
||||
(was `{ } | Array<unknown>`).
|
||||
- **Tests**: 4 package discovery tests rewritten for the budgeted behavior (COMPLETE small
|
||||
catalog + search-still-registered; PARTIAL at budget 0; cheapest-first selection +
|
||||
per-namespace labels + budget-exhaustion stopping later namespaces; mode-validation
|
||||
assertion dropped); 3 opencode description assertions updated (COMPLETE/PARTIAL headers,
|
||||
namespace labels, `(input: {})` rendering, cheapest-first op_0 shown / op_149 not).
|
||||
- **E2E (verified, headless)**: from the repo root with `OPENCODE_EXPERIMENTAL_CODE_MODE=1`,
|
||||
the scratch `.opencode/opencode.jsonc` (context7, github, playwright, sentry, memory,
|
||||
sequential-thinking; left uncommitted/as-is), and `bun packages/opencode/src/index.ts run
|
||||
--dangerously-skip-permissions -m opencode/claude-sonnet-4-5 "..."`. Confirmed: a single
|
||||
`execute` tool registered alongside core tools (per-MCP registration suppressed; MCP
|
||||
resource tools unaffected); the live description read back as "Available tools (PARTIAL -
|
||||
56 of 88 shown; find the rest with tools.$codemode.search):" with correct per-namespace
|
||||
labels (context7/github/memory fully shown; playwright/sentry/sequential-thinking "none
|
||||
shown" - the alphabetical-exhaustion starvation Fix 8 later replaced with round-robin
|
||||
fairness); programs executed with in-program `$codemode.search`
|
||||
calls and returned the correct answer. NOT verified e2e (headless only; covered by
|
||||
unit/integration tests instead): TUI child-call rendering, attachments becoming visible
|
||||
images, output truncation.
|
||||
|
||||
### Wave 5 - Promise generalization (done)
|
||||
|
||||
First-class promise values in the interpreter; the direct-tool-call-only `Promise.all`
|
||||
restriction (and its bespoke AST checks) is gone. Package suite is 136 tests / 0 fail (35 new
|
||||
in `test/promise.test.ts`); adapter suites and both typechecks unchanged/green; the opencode
|
||||
adapter needed **no changes**.
|
||||
|
||||
- **Decision: eager fork** (`const p = tools.a.b(x)` starts the call immediately on a
|
||||
supervised child fiber; `await p` observes its settlement). Chosen over lazy because:
|
||||
(1) it's spec-faithful - JS promise work starts at call time, so
|
||||
`const a = t1(); const b = t2(); return [await a, await b]` gets real parallelism instead of
|
||||
silently sequential awaits; (2) run-once is free - a fiber settles exactly once and
|
||||
`Fiber.await` is idempotent, so `await p` twice or `Promise.all([p, p])` can never re-invoke
|
||||
the tool (lazy needs a deferred/latch to match); (3) effect's structured concurrency does the
|
||||
hard part - `Effect.forkChild` children are auto-supervised (interrupted when the parent
|
||||
fiber exits) and `Effect.timeoutOrElse` is `raceFirst`, which runs the program on its own
|
||||
raced fiber, so forked calls cannot escape the timeout (tested: in-flight forks are
|
||||
interrupted, awaited or abandoned, direct or inside `Promise.all`).
|
||||
- **Mechanics**: `SandboxPromise` in `values.ts` (fiber-backed for tool calls; fiberless
|
||||
`immediate` effect for `Promise.resolve`/`reject`). Forks run
|
||||
`semaphore.withPermit(invoke)` with `startImmediately: true` - a per-execution
|
||||
`Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)` (fixed 8, see Fix 5) caps live calls (the
|
||||
"Effect.all or equivalent" cap lives where the work is, so combinator joins can be
|
||||
sequential without losing parallelism), and the tool-call-count charge (`recordCall`) plus
|
||||
`onToolCallStart` fire at the call site before any await. `await` of a non-promise is a passthrough no-op; a returned
|
||||
top-level promise resolves like an async-function return (`return tools.a.b(x)` works
|
||||
without await).
|
||||
- **Promise combinators are normal functions over values**: `Promise.all`/`allSettled`/`race`
|
||||
accept any array (or spreadable collection) mixing promises and plain data - inline, built
|
||||
beforehand, spread, nested in variables. `allSettled` yields
|
||||
`{ status: "fulfilled", value } | { status: "rejected", reason }` with reasons produced by
|
||||
the same `caughtErrorValue` helper the `catch` binding uses (factored out of
|
||||
`evaluateTryStatement`). `race` resolves/rejects with the first settlement and interrupts
|
||||
losing in-flight calls; awaiting an interrupted loser afterwards is a catchable program
|
||||
failure ("interrupted because another value settled a Promise.race first"), while any other
|
||||
interrupt-only settlement keeps propagating as interruption (preserving the
|
||||
host-interruption law). `Promise.resolve` flattens promises; `Promise.reject` rejects with
|
||||
the reason via `ProgramThrow`.
|
||||
- **Opaqueness/boundaries**: promises are runtime references - `typeof` -> `"object"` (real JS),
|
||||
operators reject them, `copyIn` raises an await-hinting `InvalidDataValue` ("contains an
|
||||
un-awaited Promise; await tool calls (...) before using their results") for results, tool
|
||||
arguments, and `JSON.stringify` instead of `{}`. Property access on a promise is a
|
||||
deliberate error (not the forgiving `undefined`): `.then/.catch/.finally` ->
|
||||
`UnsupportedSyntax` pointing at `await` + try/catch; anything else -> "await it first".
|
||||
`new Promise(...)` -> UnsupportedSyntax ("tool calls already return promises");
|
||||
`Promise.<unknown>` lists the five available statics. `console.log(p)` prints
|
||||
`[Promise (await it to get its value)]`.
|
||||
- **Program-end drain**: on successful completion the interpreter awaits still-running
|
||||
un-awaited fibers (like a runtime waiting on in-flight I/O at exit), so fire-and-forget
|
||||
calls complete deterministically; a failure nobody could have handled surfaces as an
|
||||
"Unhandled rejection from an un-awaited tool call: ..." diagnostic (kind preserved,
|
||||
suggestion says to await) - keeping pre-wave failure visibility for un-awaited
|
||||
statement-position calls. Settlement observation (await/all/allSettled/race) marks a
|
||||
promise handled; failed executions skip the drain and children are interrupted by
|
||||
supervision.
|
||||
- **Deletions/updates**: `evaluatePromiseAll`, `evaluateParallelMap`, `isToolCallExpression`,
|
||||
`isToolPath`, `forkForParallelCallback`, and `PromiseAllReference` deleted
|
||||
(`PromiseMethodReference` over `all/allSettled/race/resolve/reject` replaces it);
|
||||
`supportedSyntaxMessage`, the two instructions lines in `tool-runtime.ts`, and README
|
||||
"Supported Programs" rewritten for the new surface.
|
||||
- **Known divergences (deliberate)**: `p === q` on promises throws the operators-need-data
|
||||
diagnostic instead of comparing identity; `{...promise}` errors instead of JS's silent `{}`;
|
||||
a per-iteration `await` inside `items.map(async (i) => await tools.x(i))` runs sequentially
|
||||
(interpreter callbacks compose synchronously) - the parallel idiom is mapping to un-awaited
|
||||
calls and awaiting `Promise.all`, which the instructions show.
|
||||
|
||||
### Post-wave fixes
|
||||
|
||||
- **Key enumeration: `Object.keys(tools)` + `for...in` (done).** Motivating transcript: a
|
||||
model tried to enumerate tool namespaces with `Object.keys(tools)` (failed with the generic
|
||||
"Object.keys input must contain plain objects only." - `tools` is a `ToolReference`, not
|
||||
plain data) and then `for (const key in tools)` ("Syntax 'ForInStatement' is not
|
||||
supported"), and had to fall back to guessing namespace names from the instructions -
|
||||
defeating discovery. Fixes, all in this package:
|
||||
- `ToolRuntime.make` now returns a `keys(path)` capability (`namespaceKeys` in
|
||||
`tool-runtime.ts`) threaded into the `Interpreter` alongside `invoke` - the interpreter
|
||||
still never holds the host tool tree. `Object.keys(tools)` yields the top-level namespace
|
||||
names (never `$codemode`, which is virtual - but `Object.keys(tools.$codemode)` yields
|
||||
`["search"]`), `Object.keys(tools.ns)` the names at that node; a callable tool leaf
|
||||
enumerates as `[]` (like `Object.keys` of a JS function); an unknown path throws an
|
||||
`UnknownTool` diagnostic suggesting `Object.keys(tools)` and `$codemode.search` (matching
|
||||
call-time unknown-tool behavior rather than silently returning `[]`).
|
||||
- `Object.values`/`Object.entries` (and every other `Object.*` helper) on a tool reference
|
||||
now fail with "...not plain data. Use Object.keys(tools) for names, or
|
||||
tools.$codemode.search({ query }) for signatures." instead of the generic message.
|
||||
- `Object.keys(array)` returns index strings (`["0", "1", ...]`) like real JS (was a
|
||||
Backlog item).
|
||||
- `for...in` (ForInStatement) iterates own enumerable string keys of plain objects, index
|
||||
strings of arrays, and namespace/tool names of tool references - sharing the interpreter's
|
||||
`enumerableKeys` helper with the `Object.keys` tool path. const/let declarations and bare
|
||||
identifiers bind the key; break/continue work. Anything else (strings, Map/Set, numbers,
|
||||
null, ...) is a clear error suggesting `for...of` or `Object.keys` - deliberately smaller
|
||||
than real JS (which yields indices for strings and zero iterations for Maps/Sets/null).
|
||||
- `supportedSyntaxMessage`, the instructions loops line, and README "Supported Programs"
|
||||
mention the new surface; tests in `test/enumeration.test.ts` (14, incl. the exact
|
||||
transcript program) plus one adapter-level assertion that `Object.keys(tools)` returns
|
||||
MCP server names.
|
||||
|
||||
- **Search ranking, namespace scoping, prefixed result paths (done).**
|
||||
Motivation: the Wave 4 e2e run showed a model retrying calls because search-result paths
|
||||
lacked the `tools.` prefix (a Backlog item), and the word-set ranker missed
|
||||
parameter-name and partial-word queries. Fixes:
|
||||
- **Ranking ported from the pre-rebuild implementation** (the `searchTextFor`/`tokenize`/
|
||||
`rankTools` algorithm in `packages/opencode/src/session/code-mode.ts` at git HEAD),
|
||||
replacing the word-set ranker in `tool-runtime.ts`. Searchable text per tool = path +
|
||||
description + input-schema property names + their `description` strings - extracted by
|
||||
the new `inputProperties` helper in `tool.ts` (Effect Schemas via
|
||||
`Schema.toJsonSchemaDocument`, the same emission signature rendering uses; JSON Schemas
|
||||
read `properties` directly, resolving a trivial top-level `$ref`; try/catch falls back to
|
||||
path + description). Queries tokenize on camelCase boundaries + non-alphanumeric
|
||||
separators (empties and `*` dropped). Additive per-term scoring: exact path or
|
||||
path-segment match 20, path substring 8, description substring 4, searchable-text
|
||||
substring 2; summed across terms, filtered to score > 0, sorted score desc then path asc
|
||||
(Fix 8 later made each field check accept the term OR a naive singular variant).
|
||||
An empty query now browses ALPHABETICALLY by path (was declaration order). Kept:
|
||||
`{ path, description, signature }` result items, default limit 10, exact-path instant
|
||||
lookup, input validation errors.
|
||||
- **Namespace scoping**: `tools.$codemode.search({ query?, namespace?, limit? })` -
|
||||
`namespace` (validated as a string when provided) filters `SearchEntry`s to one top-level
|
||||
namespace before ranking; `{ query: "", namespace: "github" }` lists that namespace
|
||||
alphabetically. `searchSignature` updated.
|
||||
- **Callable result paths**: search-result `path`s are rendered as JavaScript expressions
|
||||
rooted at `tools` (`tools.github.list_issues`, or bracket notation for non-identifier
|
||||
segments), directly usable as the call site. Internal `ToolDescription.path` stays
|
||||
unprefixed; only the search RESULT items are rendered this way. Exact-path queries accept
|
||||
canonical paths and rendered expressions.
|
||||
- **Instructions** (`discoveryPlan`): an explicit calling-convention line and a browse
|
||||
hint on the search advertisement (both since absorbed into the `## Rules` section by
|
||||
the instructions restructure below).
|
||||
- **Tests**: package search/discovery tests updated (prefixed paths, alphabetical browse)
|
||||
plus new coverage for namespace scoping, parameter-name matching, partial-word substring
|
||||
matching, alphabetical empty-query order, and prefixed exact-path lookup; one adapter
|
||||
assertion updated to the prefixed path (suites stay 35 + 16, green).
|
||||
|
||||
- **Instructions restructure: markdown sections, placeholder-only call forms (done).**
|
||||
The flat prose instructions (which mixed a real catalog tool with fabricated result
|
||||
fields in the worked example) are replaced by structured markdown in `discoveryPlan`,
|
||||
ordered so the workflow sits at the top (the least likely part of a long description to
|
||||
be truncated or skimmed away) and the catalog at the bottom (the per-section content
|
||||
described here was later condensed by Fix 8 - Workflow/Rules deduped, Syntax inverted):
|
||||
- **Intro** (2 lines): "Write a CodeMode program... Return code only." + "Execute
|
||||
JavaScript in a confined runtime with access to the tools listed below under
|
||||
`tools.*`." (the second line drops the tools clause when the tree is empty).
|
||||
- **`## Workflow`**: numbered steps - find a tool via `tools.$codemode.search` -> read
|
||||
the `{ path, description, signature }` matches -> call by path -> `typeof res ===
|
||||
"string" ? JSON.parse(res) : res` -> return only the needed fields. When the catalog is
|
||||
COMPLETE the search/read steps collapse into "Pick a tool from the list under
|
||||
`## Available tools`" and the steps renumber (4 instead of 5).
|
||||
- **`## Rules`**: call-by-exact-path; TEXT-is-JSON -> JSON.parse; return small (never raw
|
||||
payloads); filter/aggregate large collections in code instead of per-item round-trips;
|
||||
console.log/warn/error/dir/table for intermediates; `Promise.all` parallelism (no
|
||||
.then/.catch - await + try/catch); `Object.keys(tools)`/`for...in` enumeration;
|
||||
browse-one-namespace via search (PARTIAL only); and host-side media handling (files/
|
||||
images never enter the program; a media-only call yields a small text marker - wording
|
||||
verified against the adapter's `toSandboxResult`/`mediaMarker`).
|
||||
- **`## Syntax`**: the dense syntax lines unchanged, minus the Promise.all and console
|
||||
lines (moved into Rules) and the `for (const ns in tools)` fragment (redundant with
|
||||
the enumeration rule).
|
||||
- **`## Available tools`**: the budgeted catalog unchanged, with the COMPLETE/PARTIAL
|
||||
header merged into the section heading (no trailing colon); the search-signature
|
||||
advertisement follows when PARTIAL (its description-reading and browse clauses moved
|
||||
to Workflow/Rules).
|
||||
- Every call form in Workflow/Rules uses explicit `<namespace>.<tool>`/`<field>`
|
||||
placeholders - the example builder that derived a worked example from the first inlined
|
||||
catalog tool (`exampleArguments` + the example-selection machinery) is DELETED, so no
|
||||
real catalog tool is cherry-picked into examples and no fabricated names or fields
|
||||
appear anywhere in the instructions. Zero tools keep "No tools are currently
|
||||
available." under minimal sections (intro + Syntax + Available tools).
|
||||
- **Tests**: the package worked-example test replaced by section-structure/placeholder
|
||||
assertions (section order; JSON.parse + return-small rules present; no
|
||||
`total_count`/`list_issues`/real-tool example lines; browse hint only when PARTIAL;
|
||||
zero-tool minimal sections) - 156 pass / 0 fail; adapter suites gain the same
|
||||
assertions on the built description (still 35 + 16, green).
|
||||
|
||||
**Fix 4 - token-budgeted catalog (was bytes)** (user direction: signatures need a token
|
||||
budget; namespaces must always be present):
|
||||
|
||||
- `src/token.ts` added: copy of `@opencode-ai/core/util/token` (`round(chars / 4)`), so
|
||||
the package stays dependency-free; keep in sync if the core heuristic changes.
|
||||
- `DiscoveryOptions.maxInlineCatalogBytes` -> `maxInlineCatalogTokens` (default 4,000
|
||||
estimated tokens ~ the old 16,000 bytes at 4 chars/token - behavior parity, not a size
|
||||
reduction). `discoveryPlan` charges `estimate(catalogLine(tool))` per line; cheapest-first
|
||||
- stop-on-first-miss unchanged at the time (stop-on-first-miss replaced by round-robin in
|
||||
Fix 8). Namespace stub lines were and remain unbudgeted - every
|
||||
namespace always appears with its tool count, even at budget 0 (asserted in package and
|
||||
adapter tests).
|
||||
- Ripple: chars/4 rounding erases small line-length differences, so equal-cost lines fall
|
||||
to the lexicographic path tiebreak; the adapter's PARTIAL test now asserts the
|
||||
lexicographic tail (`op_99`) is excluded instead of `op_149`. Fixed-prose measurements
|
||||
(2026-07): preamble ~44 + Workflow ~146 + Rules ~362 + Syntax ~453 ~ 1,100 tokens fixed;
|
||||
worst-case net description ~ fixed + 4,000 ~ 5,100 estimated tokens.
|
||||
|
||||
**Fix 5 - internal limits removed** (user direction: only the three PUBLIC limits survive as
|
||||
configurable knobs; the internal limit system dies):
|
||||
|
||||
- `ExecutionLimits` (`timeoutMs` 10_000 / `maxToolCalls` 100 / `maxOutputBytes` 32_000 at
|
||||
the time; Fix 6 later removed the first two defaults. Same validation: safe integers,
|
||||
timeoutMs >= 1, others >= 0, RangeError otherwise) is now
|
||||
the ENTIRE limit surface - exactly the shape section 2's original locked spec named.
|
||||
`ResolvedExecutionLimits` shrank to those three fields; the `@internal`
|
||||
`InternalExecutionLimits` type is deleted.
|
||||
- **Deleted outright**: `maxOperations` and the whole operation-budget machinery
|
||||
(`recordWork`/`recordOperation`/`budget.operations`, plus the `workUnits`/
|
||||
`cheapArrayMethods` cost helpers); `maxSourceBytes` (the pre-parse source-size check);
|
||||
`maxDataBytes` (every byte-accounting path: `runtimeValueBytes`, `boundedProgramValue`,
|
||||
the container-size caches (`containerSizes`/`objectCounts`), Map/Set incremental `bytes`
|
||||
fields in `values.ts`, string-growth `limitString` checks, tool-argument/result byte
|
||||
checks in `tool-runtime.ts`, and the final-result size check); `maxAuditBytes` (log and
|
||||
audit-trail byte accounting - `toolCalls` records and the start/end hooks are unchanged);
|
||||
`maxCollectionLength` (every array-length/object-field-count check - this knob was
|
||||
actively harmful: an MCP tool returning 20k rows failed). The `OperationLimitExceeded`
|
||||
and `AuditLimitExceeded` diagnostic kinds are gone from the `DiagnosticKind` union and
|
||||
`ExecuteResultSchema` (fine - the package is unreleased).
|
||||
- **Fixed constants, not knobs**: `TOOL_CALL_CONCURRENCY = 8` (codemode.ts; the fork
|
||||
semaphore) and `MAX_VALUE_DEPTH = 32` (tool-runtime.ts; the `copyIn` depth check - kept
|
||||
only because it produces a clearer error than a native stack-overflow RangeError; still
|
||||
`InvalidDataValue`). The `DataLimits` plumbing through `tool-runtime.ts` is gone -
|
||||
`copyIn(value, label)` needs no limits argument, and `ToolRuntime.make` takes just
|
||||
`(tools, maxToolCalls, hooks?, searchIndex?)`.
|
||||
- **Verified fact**: timeout interruption does NOT depend on the operation budget - the
|
||||
Effect fiber runtime auto-yields between interpreter steps, so `timeoutMs` interrupts
|
||||
even a pure `while (true) {}` loop (empirically verified: a 200ms timeout fired at
|
||||
~225ms with maxOperations set to MAX_SAFE_INTEGER before the deletion). A regression
|
||||
test in `codemode.test.ts` asserts exactly this (`while(true){}` + `timeoutMs: 200` ->
|
||||
`TimeoutExceeded`, elapsed well under a few seconds).
|
||||
- **Kept (correctness, not budgets)**: circular detection (`copyIn` walks +
|
||||
`rejectCircularInsertion` on mutations), plain-objects-only, blocked properties
|
||||
(`__proto__`/`constructor`/`prototype`), data-only checks, and all three public-limit
|
||||
behaviors unchanged.
|
||||
- Behavior deltas beyond the intended kills: in-sandbox structures deeper than 32 levels
|
||||
now fail at the data boundary (`copyIn`) instead of at construction; array index
|
||||
assignment allows any non-negative integer index (holes permitted, message now "must be
|
||||
a non-negative integer"); interpreter-produced deep/hostile structures that overflow the
|
||||
native stack during a walk still normalize to the existing "Execution exceeded the
|
||||
maximum nesting depth." data diagnostic - failures remain data everywhere.
|
||||
- Tests: deleted the knob-only tests (stdlib Map/Set collection-length growth x2,
|
||||
enumeration operation-budget, codemode maxDataBytes/maxSourceBytes/maxOperations/
|
||||
maxConcurrency-RangeError assertions, and the adapter's runaway-loop-via-operation-limit
|
||||
test - superseded by the package timeout regression test); rewrote the helpers that used
|
||||
`InternalExecutionLimits` as a convenience to plain `ExecutionLimits`
|
||||
(promise/enumeration/stdlib run helpers). Package suite: 154 pass / 0 fail; adapter
|
||||
suites: 34 + 16.
|
||||
|
||||
**Fix 6 - no default timeout / tool-call cap** (user direction): `timeoutMs` and
|
||||
`maxToolCalls` lost their defaults (were 10_000 / 100) - absent now means no timeout /
|
||||
unlimited calls. Budgets are host policy, not library policy; `maxOutputBytes` kept its
|
||||
32,000 default at the time (removed later - see the truncation-layering entry: absent now
|
||||
means no truncation). `ResolvedExecutionLimits` carries `number | undefined` for both, the
|
||||
timeout wrapper is only applied when configured, and `ToolRuntime.make` treats undefined
|
||||
`maxToolCalls` as uncapped. Validation is unchanged when values ARE provided (safe integers,
|
||||
timeoutMs >= 1, others >= 0). The OpenCode adapter is unaffected in behavior it sets
|
||||
(explicit 30s timeout) but now runs with unlimited tool calls. Immediately after, per user
|
||||
direction, the adapter's 30s timeout was killed too: `CODE_LIMITS` is deleted and OpenCode
|
||||
passes NO limits - no timeout, no tool-call cap. Rationale: user cancel interrupts the
|
||||
execution fiber and structured concurrency takes the program and in-flight child calls down
|
||||
with it; every child call is permission-gated; output truncation (32KB default) is the only
|
||||
active bound. New regression test: 150 tool calls succeed with no limits configured (would
|
||||
have tripped the old default 100). Package suite: 155 pass / 0 fail.
|
||||
|
||||
**Fix 7 - JSDoc-annotated search signatures**: `tools.$codemode.search` result signatures are
|
||||
now the pretty, indented multiline form with per-field JSDoc - ported from the pre-rebuild
|
||||
rune renderer in this repo's git history (`renderType(def, { pretty })`/`docTags`/`jsdoc`/
|
||||
`renderObject`), adapted to the current renderer's conventions (`Array<T>`, `unknown`
|
||||
fallback, existing `$defs`/`$ref` handling and empty-object `{}` collapse; the old
|
||||
`Result<T>`/`returnType` machinery was deliberately not ported - payloads stay native).
|
||||
Semantics: each described input/output field carries its schema `description` as a
|
||||
`/** ... */` comment at the right indent (nested objects recurse deeper); constraints TS can't
|
||||
express surface as JSDoc tags - `@deprecated`, `@default <json>` (unserializable defaults
|
||||
skipped), `@format`, `@minItems`/`@maxItems`; `*/` inside text is neutralized to `* /`;
|
||||
multiline descriptions become `*`-prefixed blocks with blank edges trimmed; undescribed,
|
||||
untagged fields get no comment. Implementation: `renderSchema` in `tool.ts` grew a
|
||||
`RenderContext` (`{ definitions, pretty }`), a `MAX_RENDER_DEPTH = 8` recursion ceiling plus
|
||||
a `$ref` `seen` guard (the renderer previously had neither - a cyclic `$defs` would have
|
||||
looped; it now degrades to the ref name/`unknown`), and try/catch totality on the public
|
||||
helpers (`toTypeScript`/`jsonSchemaToTypeScript`/`inputTypeScript`/`outputTypeScript` never
|
||||
throw - pathological schemas render `unknown`); each helper takes an optional trailing
|
||||
`pretty = false` parameter, so existing callers are unchanged and compact output stays
|
||||
byte-identical (inline `catalogLine`s and the token budget depend on it). `SearchEntry`
|
||||
gained an eagerly-computed `signature` field (built once per tool at index-build time in
|
||||
`toSearchEntry` - rendering is cheap and the search hot path stays allocation-free); both
|
||||
ranked results and exact-path lookups serve it. Works for both tool kinds: Effect Schema
|
||||
annotations (`Schema.String.annotate({ description })`) flow through the emitted JSON
|
||||
Schema, and raw JSON Schema (MCP) property metadata is read directly - both covered in
|
||||
`test/signature.test.ts` (12 tests) plus one strengthened adapter assertion (MCP property
|
||||
description appears as JSDoc in a live search result; the tool description/catalog contains
|
||||
no `/**`). README search section updated with an example. Package suite: 167 pass / 0 fail;
|
||||
adapter suites: 34 + 16.
|
||||
|
||||
**Fix 8 - condensed instructions + round-robin catalog fairness + plural-aware search**
|
||||
(user direction: the fixed instruction prose was too verbose; two discovery fixes ride
|
||||
along). All in `tool-runtime.ts`; no interpreter changes.
|
||||
|
||||
- **Syntax section inverted**: the three dense allowlist lines (~453 estimated tokens)
|
||||
are replaced by four short lines (~188) built on "models already know JavaScript; name
|
||||
only what is unusual or missing": (1) standard modern JS works - functions/closures,
|
||||
destructuring, template literals, loops, try/catch, spread, optional chaining, the
|
||||
usual Array/String/Object/Math/JSON methods, plus Date/RegExp/Map/Set and
|
||||
Promise.all/allSettled/race/resolve/reject; (2) TypeScript type annotations are
|
||||
stripped before execution, decorators are not supported; (3) NOT supported (each fails
|
||||
with a message naming the alternative): classes, generators, for await...of,
|
||||
.then/.catch/.finally (use await with try/catch), `x instanceof Error` (caught errors
|
||||
are plain `{ name, message }` objects), splice; (4) the data-boundary note (Dates ->
|
||||
ISO strings; Map/Set/RegExp -> `{}`). Every claim was verified against the interpreter
|
||||
before writing: probed empirically - classes/generators/for-await/.then/.catch/
|
||||
.finally/`instanceof Error`/splice/decorators/BigInt/labeled statements/tagged
|
||||
templates/object getters all fail with clear diagnostics; TS annotations/`as`/
|
||||
interfaces/type aliases are stripped and TS **enums actually work** (transpileModule
|
||||
compiles them to an IIFE the interpreter runs), hence enums deliberately unmentioned.
|
||||
`supportedSyntaxMessage` (the in-diagnostic text in `codemode.ts`) is untouched.
|
||||
- **Workflow/Rules deduped**: the call-by-exact-path, JSON.parse-string-results, and
|
||||
return-small content now lives ONLY in the numbered Workflow steps (with their
|
||||
compliance-driving justifications inline: "most tools return JSON as a string", "raw
|
||||
payloads get truncated and waste context"); Rules keeps only bullets adding new
|
||||
content - filter/aggregate collections in code, console.\* intermediates (logs ride
|
||||
back), Promise.all parallelism, Object.keys/for...in enumeration, browse-namespace
|
||||
(PARTIAL only), and the media rule compressed to one line. The no-.then/.catch
|
||||
guidance moved to the Syntax not-supported line. Content upgrades: the PARTIAL search
|
||||
step gained query-style guidance (`- short phrases like "list issues" work best`; a
|
||||
clearly-a-query-string example, not a tool name), and the exact-path guidance is now
|
||||
"call it with the result's `path` as-is (never guess segments)" / COMPLETE: "use it
|
||||
as-is rather than guessing segments".
|
||||
- **Fixed-prose measurements** (instructions split on `"\n## "`, catalog budget 0,
|
||||
bytes/3.7 - same method as Fix 4; chars/4 in parentheses):
|
||||
preamble 44 -> 44 (41 -> 41), Workflow 146 -> 187 (135 -> 171), Rules 362 -> 191
|
||||
(332 -> 176), Syntax 453 -> 188 (419 -> 174); fixed prose total 1,005 -> 610 (927 -> 562),
|
||||
~ 40% reduction with no behavioral content dropped. Workflow grew slightly because it
|
||||
absorbed the deduped parse/return-small justifications.
|
||||
- **Round-robin namespace inlining** (`discoveryPlan`): the ported stop-on-first-miss
|
||||
behavior (alphabetically-late namespaces starved to "none shown" while an early
|
||||
namespace inlines everything) is replaced by round-robin fairness - in each round
|
||||
(namespaces alphabetical), every namespace still holding un-inlined tools attempts to
|
||||
place its next-cheapest line against the shared token budget; a namespace whose next
|
||||
line does not fit is done while the others keep going; stop when all are done. Every
|
||||
namespace gets some representation before any namespace gets everything. Kept:
|
||||
`estimate` (chars/4) budget accounting, unbudgeted namespace stub lines, per-namespace
|
||||
`(N tools)`/`(N tools, K shown)`/`(N tools, none shown)` labels, COMPLETE vs PARTIAL
|
||||
header, alphabetical namespace order in the output, cheapest-first within each
|
||||
namespace's shown set.
|
||||
- **Plural/singular search fix**: `tokenize`d terms matched one-directionally (term must
|
||||
be substring of indexed text), so query "issues" missed a tool whose text only says
|
||||
"issue". Now each term expands to `termForms` - the term plus naive singular variants
|
||||
(trailing "es" stripped when length > 3, trailing "s" when length > 2) - and each of
|
||||
the four field checks passes when ANY form matches. Weights, exact-path lookup, and
|
||||
namespace scoping untouched. A true plural path match still outranks a singular-only
|
||||
description match (path substring 8 + searchable 2 > description 4 + searchable 2).
|
||||
- **Tests**: package instruction/structure assertions updated to the new text; new
|
||||
syntax-section test (leads with "Standard modern JavaScript works", names the
|
||||
verified not-supported list, keeps the data-boundary note); the budget-exhaustion
|
||||
test rewritten to assert the new fairness (alpha.expensive not fitting must NOT
|
||||
prevent beta.cheap from showing: PARTIAL 2 of 3, `- beta (1 tool)` fully shown); new
|
||||
plural/singular test (query "issues" finds a singular-only tool; ranking still
|
||||
prefers the true "issues" path match). Adapter: description assertions updated; the
|
||||
large-catalog PARTIAL test now asserts `zeta_only_tool` IS shown (`- zeta (1 tool)` +
|
||||
its inlined line) - it was "none shown" under starvation. README updated (budgeted
|
||||
catalog paragraph -> round-robin; search paragraph -> singular variants;
|
||||
instructions-structure paragraph -> new section contents). Package suite: 169 pass /
|
||||
0 fail; adapter suites: 34 + 16.
|
||||
|
||||
**Fix 9 - prompting trims per user review of Fix 8** (user reviewed the condensed
|
||||
instructions and directed further cuts):
|
||||
|
||||
- Default `maxInlineCatalogTokens` 4,000 -> **2,000** (user wants ~2k tokens of signatures
|
||||
auto-inlined; round-robin fairness from Fix 8 spreads it across all namespaces).
|
||||
- Console rule and files/images rule DROPPED from `## Rules`. Replaced by a single
|
||||
`unknown`-treatment warning: "A result typed `Promise<unknown>` has no guaranteed
|
||||
shape - verify what actually came back before relying on its fields." (Deliberately
|
||||
does NOT suggest console.log - user review: naming it there nudges models to log AND
|
||||
return the same data; the prompt stays console-neutral, neither for nor against.)
|
||||
The media-stripping MECHANISM is unchanged and still tested; only the prose about it
|
||||
is gone - the `[N images attached]` marker is self-explanatory in context.
|
||||
- Kept as-is per user: the JSON.parse workflow step (maps to the original motivating
|
||||
transcript failure; NOT copied from prior art - see section 5 note), the browse-namespace rule
|
||||
(undecided), no no-fetch/ambient-authority rule added (proposed, not approved).
|
||||
- Explicitly REJECTED for now: auto-parsing JSON-looking text results at the adapter
|
||||
boundary ("could get weird" - type flips, program-sees vs tool-sent divergence). Logged
|
||||
as a next-iteration follow-up below.
|
||||
|
||||
**DSL-expansion pass - interpreter-surface batch from section 4** (the deferred medium-tier JS
|
||||
parity items, done as one focused pass; no public API or limit changes):
|
||||
|
||||
- **`instanceof` + real Error values**: the `errorConstructors` names (`Error`,
|
||||
`TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, `URIError`) are
|
||||
bound globals (`ErrorConstructorReference`, callable with or without `new`; `typeof` ->
|
||||
`"function"`). Error values stay the same plain `{ name, message }` null-prototype
|
||||
objects as before - the constructor name additionally rides on a NON-ENUMERABLE symbol
|
||||
key (`ErrorBrand`), which every `Object.entries`-based walk (copyIn/copyOut, spread,
|
||||
JSON.stringify) is blind to, so serialization is byte-identical to the old shape and the
|
||||
brand is lost on spread/boundary copies exactly like JS loses the prototype.
|
||||
`caughtErrorValue` produces `{ name, message }` wrappers via `createErrorValue`, so
|
||||
caught interpreter AND tool failures are `instanceof Error` and carry the `name` the
|
||||
equivalent real-JS failure would have (follow-up fix, user-directed - "closest to real
|
||||
JS"): `InterpreterRuntimeError` gained an `errorName` field ("Error" default) set
|
||||
fluently at throw sites via `.as(name)` - `JSON.parse` failures are `"SyntaxError"` (and
|
||||
now include the engine's position detail in the message; safe - derived from the
|
||||
program-supplied string), invalid regex patterns/flags `"SyntaxError"`, unknown
|
||||
identifiers and TDZ access `"ReferenceError"`, assignment to a constant `"TypeError"`,
|
||||
a bad `normalize` form `"RangeError"`; a host Error reaching the catch path directly
|
||||
keeps its own name when it is one of the standard seven. Tool failures and everything
|
||||
without a specific analogue stay `"Error"` - internal class names never leak. Specific
|
||||
names satisfy the specific `instanceof` (`e instanceof SyntaxError`), matching JS.
|
||||
The operator is handled in `evaluateBinaryExpression`
|
||||
BEFORE the data-only operand check (like `typeof`, it observes any lhs - promises and
|
||||
functions included); recognized rhs: the error constructors (a specific type matches its
|
||||
own brand or `Error`, never a sibling), `Date`/`RegExp`/`Map`/`Set` (sandbox classes),
|
||||
`Array`, `Object` (any object/function-ish value), `Promise` (`SandboxPromise`), and
|
||||
`Number`/`String`/`Boolean` (always false - no boxed values exist); anything else is a
|
||||
catchable error naming the recognized constructors.
|
||||
- **Array methods**: `splice` (mutating, returns the removed elements; insertions run
|
||||
`rejectCircularInsertion` like push/unshift; one-arg form removes to the end, undefined
|
||||
delete count removes nothing), `fill` (circular-checked value) and `copyWithin`
|
||||
(host-delegated), and `keys`/`values`/`entries` returning **arrays** (the Map/Set
|
||||
convention - for...of and spread work either way). The `retryableArrayMethods`
|
||||
"rewrite using map/filter" hint set emptied out and was deleted with its branch; unknown
|
||||
array properties still read `undefined`.
|
||||
- **String methods**: `localeCompare(that)` (locale/options arguments ignored - host
|
||||
default locale; the dominant use is a sort comparator), `normalize(form?)` (invalid form
|
||||
-> catchable error naming the four valid forms), `trimLeft`/`trimRight` as
|
||||
trimStart/trimEnd aliases.
|
||||
- **Actionable regex failures**: `toHostRegex` and `constructRegExp` now show the
|
||||
offending pattern (or flags) plus the engine reason (deduped "Invalid regular
|
||||
expression:" prefix via `regexFailureReason`) and a shared escaping hint
|
||||
(`escapeRegexHint`); flags failures list the valid flag letters; the
|
||||
replaceAll/matchAll missing-`g` errors spell out the exact `/pattern/g` to write and
|
||||
the single-match alternative.
|
||||
- **copyIn split (the important one)**: `copyIn(value, label, preserveSandboxValues =
|
||||
false)` - recursion moved to a private `copyBounded`; `boundedData` (every intra-sandbox
|
||||
checkpoint: `Object.*` helpers, coercion/Array.from/join inputs, template
|
||||
interpolation, expression-result checkpoints) is now `copyIn(value, label, true)`,
|
||||
which passes `SandboxDate`/`SandboxRegExp`/`SandboxMap`/`SandboxSet` through **by
|
||||
reference as leaves** (contents not walked - Map/Set members are validated at their
|
||||
mutation sites) while keeping the depth (`MAX_VALUE_DEPTH`), circularity,
|
||||
plain-objects-only, blocked-property, and data-only checks; un-awaited promises keep
|
||||
the await-hinting rejection in BOTH modes (deliberate - JS-parity pass-through was
|
||||
considered and skipped to preserve the nudge). The HOST boundary (final result,
|
||||
tool-call arguments, `JSON.stringify`, tool-result intake) uses the default mode and
|
||||
still serializes JSON forms (Date -> ISO, RegExp/Map/Set -> `{}`); host instances met on
|
||||
the preserving path are defensively wrapped into sandbox equivalents. Ripple: the
|
||||
`Object.*` helpers treat sandbox values as empty objects (`Object.keys(map)` -> `[]`,
|
||||
assign sources contribute nothing, hasOwn -> false - JS has no own enumerable props
|
||||
there), so interpreter internals (`.map`/`.time`/`.regex`) can never leak; the
|
||||
template-literal sandbox carve-out collapsed into `boundedData`. Object/array spread
|
||||
already preserved instances (reference copies, no checkpoint) - now tested.
|
||||
- **Console formatting**: `formatConsoleArgument` is total and deep
|
||||
(`formatConsoleValue`): numbers render via `String` (`NaN`/`Infinity`/`-Infinity`
|
||||
literally - never the JSON `null`; finite numbers match their JSON form), nested
|
||||
strings are JSON-quoted, sandbox values keep their friendly forms at ANY depth (ISO
|
||||
date, `/regex/flags`, `Map(n) [...]`, `Set(n) [...]`), opaque references become
|
||||
in-place `[CodeMode reference]` markers instead of collapsing the whole argument,
|
||||
cycles render `[Circular]` (reachable via Map/Set members, which mutation never
|
||||
checkpoints), and depth beyond `MAX_CONSOLE_DEPTH = 32` (fixed constant, not a knob)
|
||||
degrades to `...` - console can no longer fail a program. `console.table` guards with
|
||||
`containsOpaqueReference` (sandbox cells render, e.g. ISO dates) and its row/cell
|
||||
walkers treat sandbox values as scalar cells.
|
||||
- **Prose**: the instructions Syntax not-supported line dropped its `instanceof
|
||||
Error`/splice mentions (nothing else reworded); README updated (checkpoint
|
||||
preservation vs boundary serialization, error values/`instanceof`, new array/string
|
||||
methods, regex-failure behavior); `supportedSyntaxMessage` left untouched (it lists
|
||||
supported syntax, was already non-exhaustive, and stays accurate).
|
||||
- **Tests**: package suite 169 -> 209 (parity: Error/instanceof + real-JS error-name
|
||||
coverage, splice/fill/copyWithin/keys/values/entries, localeCompare/normalize/trim-alias
|
||||
describes; stdlib: checkpoint survival incl. tool-arg boundary pinning, stdlib
|
||||
`instanceof`, regex-message assertions; codemode: NaN/Infinity + nested/cyclic console
|
||||
rendering, table cells, caught-tool-failure `instanceof`); adapter suites unchanged
|
||||
(34 + 16, green); both packages `tsgo --noEmit` clean.
|
||||
|
||||
**Truncation layering - CodeMode truncation off in OpenCode** (user direction; resolves the
|
||||
section 4 outer-truncation item the OPPOSITE way from "kill the outer one"):
|
||||
|
||||
- `maxOutputBytes` lost its 32,000 default and now behaves exactly like the other two
|
||||
limits: absent = no truncation. All three limits are uniformly no-default - budgets are
|
||||
host policy. `ResolvedExecutionLimits.maxOutputBytes` is `number | undefined`;
|
||||
`boundOutput` only runs when the host set the limit. Explicit values validate as before
|
||||
(safe integer >= 0).
|
||||
- OpenCode continues to pass NO limits, which now also means no CodeMode truncation.
|
||||
`execute` is a normal `Tool.define` tool, so OpenCode's native tool-output truncation
|
||||
applies with no special-casing - verified by tracing `wrap()` (`tool.ts:130-144`,
|
||||
50KB/2000-line thresholds in `truncate.ts`, full output dumped to a file under
|
||||
`tool-output/`): the `metadata.truncated` self-truncation exemption never fires for
|
||||
`execute` (its metadata never sets that key). One truncation layer, the host's - and it
|
||||
is the richer one (file dump + explore/grep hint vs an inline marker).
|
||||
- Hosts without their own output bounding set `maxOutputBytes` explicitly; README table
|
||||
and prose updated, adapter comment rewritten. Tests: codemode +1 (absent limit -> 100KB
|
||||
value + 50KB log line pass through unbounded, `truncated` undefined); the adapter test
|
||||
that relied on the old default now asserts the oversized result reaches the shared
|
||||
wrapper un-truncated. Suites: 210 + 50, tsgo clean both.
|
||||
|
||||
**Docs polish** (post-API-review): stale `DiscoveryOptions` JSDoc fixed (claimed default
|
||||
4,000 and alphabetical cheapest-first - now 2,000 and round-robin, matching Fix 8/9 reality)
|
||||
and the README's incorrect "`effect` as a peer dependency" line corrected (`effect` is a
|
||||
regular dependency; hosts depend on it themselves because the API surface is Effect-typed).
|
||||
|
||||
**Registry promotion + permission-aware catalog** (the "promote to a proper tool service"
|
||||
restructure; fixes the section 4 permission-advertising bug):
|
||||
|
||||
- **The adapter moved** `src/session/code-mode.ts` -> `src/tool/code-mode.ts` and is now a
|
||||
registry-resident tool service on the TaskTool precedent: `CodeModeTool =
|
||||
Tool.define(CODE_MODE_TOOL, ...)` whose init depends on `MCP.Service`, `Agent.Service`,
|
||||
and `Session.Service`. It is yielded in `ToolRegistry.layer`, gated into `builtin` by
|
||||
`flags.experimentalCodeMode` (like the lsp/plan experiments), and `MCP.node` joined the
|
||||
registry's `node.deps` (`MCP.node` has no ToolRegistry dependency, so no cycle). The
|
||||
session-level special-casing in `session/tools.ts` (ad-hoc `SessionCodeMode.define` +
|
||||
append) is deleted; the early return that suppresses raw per-MCP registration when the
|
||||
flag is on stays session-side, keyed on the same flag+tool-count condition.
|
||||
- **Enablement** lives in `ToolRegistry.tools()` next to the WebSearchTool check: the MCP
|
||||
tool count is consulted once (an Effect) before the synchronous filter, and code mode
|
||||
passes the predicate iff `flags.experimentalCodeMode` && count > 0.
|
||||
- **Description split on the `describeTask` precedent**: the tool's static base
|
||||
description is a two-line summary; `describeCodeMode(agent)` in `registry.tools()`
|
||||
appends the full CodeMode instructions (workflow/rules/syntax + grouped catalog,
|
||||
`catalogInstructions` in the adapter) at the same composition point as task - so
|
||||
`plugin.trigger("tool.definition")` sees the base description first.
|
||||
- **Permission-aware catalog + dispatch** (the bug fix): the visibility predicate from
|
||||
`llm/request.ts` `resolveTools` is hoisted to `Permission.visibleTools(tools, ruleset)`
|
||||
(a record filter over `Permission.disabled` - only a hard `deny` with pattern `"*"`
|
||||
hides a tool; ask-level rules stay fully visible and prompt at call time) and
|
||||
`resolveTools` now uses it, so the two paths cannot drift. `describeCodeMode` filters
|
||||
with the merged agent+session ruleset that `SessionTools.resolve` passes into the
|
||||
registry before building the catalog/search index; `execute` rebuilds the runtime per
|
||||
execution from a fresh, filtered `mcp.tools()` snapshot using the same merged ruleset
|
||||
(`Agent.get(ctx.agent)` + `Session.get(ctx.sessionID)`, matching the merge
|
||||
`SessionTools.context` wires into `ctx.ask`) - a denied tool is not dispatchable
|
||||
even if the model guesses its name and yields the normal unknown-tool diagnostic.
|
||||
Documented gap (out of scope by design): per-message `user.tools[key] === false` arrives
|
||||
at request-prep after descriptions are built and has no child-call equivalent.
|
||||
- **Preserved behavior**: cancellation race + pre-aborted-signal guard, `toSandboxResult`
|
||||
unwrap order, attachment accumulation, `CODE_MODE_TOOL` at all title sites, no execution
|
||||
limits (native truncation only), `displayInput`, per-child `ctx.ask` gating (now wired
|
||||
through `Tool.Context` exactly like every registry tool).
|
||||
- **Explicit non-goal**: memoizing the catalog builder keyed on (ToolsChanged generation,
|
||||
permission ruleset) was considered and deliberately skipped - the per-turn rebuild is
|
||||
cheap (grouping + string rendering); revisit only if profiling shows it matters.
|
||||
- **Tests**: the two adapter suites moved to `test/tool/{code-mode,code-mode-integration}
|
||||
.test.ts` (mocked `MCP.Service`/`Agent.Service`/`Session.Service` replacing the direct
|
||||
`define(...)` construction; description assertions target `catalogInstructions`, the
|
||||
registry's composition input) and gained permission coverage: deny excluded from
|
||||
catalog/search, ask-level stays visible and callable, denied tool undispatchable
|
||||
(unknown-tool diagnostic), `Permission.visibleTools` semantics. `test/tool/
|
||||
registry.test.ts` gained four registry-level tests: registered with flag+MCP tools,
|
||||
excluded without MCP tools, excluded with flag off, and deny/ask catalog filtering
|
||||
through `registry.tools()`. Suites: 43 + 16 adapter tests, 16 registry tests, all green.
|
||||
|
||||
**Shared MCP invocation middle (`McpInvoke.invoke`)** (closes the section 4 "plugin hooks skip
|
||||
child calls" gap):
|
||||
|
||||
- `packages/opencode/src/mcp/invoke.ts` extracts the duplicated "invoke an MCP tool"
|
||||
middle into one shared `McpInvoke.invoke(input)`: plugin `tool.execute.before` hook ->
|
||||
permission ask (`{ permission: key, patterns: ["*"], always: ["*"] }` via the caller's
|
||||
`ctx.ask`) -> dispatch through the ai-sdk tool's execute inside the `Tool.execute`
|
||||
tracing span (`tool.name`/`tool.call_id`/`session.id`/`message.id` attributes) ->
|
||||
plugin `tool.execute.after` hook. It returns the RAW result the ai-sdk execute
|
||||
resolved with; each caller keeps its own shaping edge - the legacy per-MCP loop in
|
||||
`SessionTools.resolve` applies its existing model-facing shaping/truncation, code
|
||||
mode applies `toSandboxResult`. It lives under `src/mcp/` because both callers
|
||||
already depend on MCP and the function is about invoking an MCP-backed ai-sdk tool,
|
||||
not about sessions or code mode.
|
||||
- **After-hook payload**: fired inside `McpInvoke.invoke` with the raw MCP result -
|
||||
which is exactly what the legacy loop always passed (the raw `CallToolResult`, not
|
||||
the shaped `{title, output, metadata}`), so legacy behavior is preserved bit-for-bit
|
||||
and the hook payload cannot drift between callers. No callback/edge-firing design
|
||||
was needed.
|
||||
- **Synthetic child callID**: code-mode child calls pass `${parentCallID}/${n}` as the
|
||||
hook/span callID (`parentCallID` = the `execute` call's `ctx.callID`, falling back to
|
||||
the entry key; `n` = per-execution counter starting at 1, shared across all child
|
||||
calls in one program). callID is an opaque string - nothing parses it. The ai-sdk
|
||||
`toolCallId` (`options.toolCallId`) stays each caller's existing value
|
||||
(`ctx.callID ?? entry.key` for code mode).
|
||||
- **Child-scoped hook failures**: `CodeModeTool` (which now also yields
|
||||
`Plugin.Service`) wraps the whole child call - hooks, ask, dispatch - in
|
||||
`toCatchable` (the generalization of the old `askPermission` catchCause), so a plugin
|
||||
hook failure fails ONLY that child call as a catchable in-program `toolError`; other
|
||||
calls in the same program keep running and interruption still propagates as
|
||||
interruption. Legacy semantics unchanged: a hook failure fails the tool call.
|
||||
- **Tests**: `test/tool/code-mode.test.ts` +2 (child calls fire before/after with the
|
||||
MCP key and `parent/1`, `parent/2` ids, after hook carries the raw MCP result; a
|
||||
failing before hook is caught in-program, gates dispatch, and leaves the outer
|
||||
execute ok) - both code-mode harnesses gained a `Plugin.Service` mock (pass-through
|
||||
trigger by default, overridable). New `test/session/tools.test.ts` (3 tests) pins
|
||||
`SessionTools.resolve` at the real-registry seam (LayerNode.compile, fake MCP layer):
|
||||
flag on + MCP tools -> `execute` present, raw MCP keys suppressed; flag off -> raw
|
||||
keys present, `execute` absent; and the legacy raw-MCP execute fires before/after
|
||||
hooks keyed by the ai-sdk toolCallId with the raw result payload. Suites: adapter
|
||||
45 + 16, session/tool/permission all green; this package untouched (211 pass).
|
||||
|
||||
**Signature rendering + compound-assignment parity fixes** (externally reported, both
|
||||
verified real with failing tests before fixing):
|
||||
|
||||
- **Non-identifier property names in rendered signatures** (`src/tool.ts`): `renderSchema`
|
||||
emitted raw property names, so schema properties like `foo-bar`/`@type`/`x.y`/`123`
|
||||
rendered invalid TypeScript (`{ foo-bar?: string }`). Fixed with a `renderKey` helper -
|
||||
bare identifiers stay bare, everything else is `JSON.stringify`-quoted - applied in the
|
||||
single `field` closure both the compact and pretty renderings share. The
|
||||
`identifierSegment` regex now lives in `tool.ts` (exported) and `tool-runtime.ts`'s
|
||||
bracket-notation `toolExpression` imports it: one source of truth for "is this a bare
|
||||
identifier" across object keys and tool paths. Tests: `signature.test.ts` +4 (compact,
|
||||
pretty with JSDoc on a quoted key, JSON Schema input+output, Effect Schema struct).
|
||||
- **Numeric schema unions keep their real alternatives** (`src/tool.ts`): the old
|
||||
`anyOf`/`oneOf` renderer collapsed any union containing `{ type: "number" }` to just
|
||||
`number`, dropping real JSON Schema alternatives (`string | number`, `number | null`,
|
||||
etc.). The collapse is now restricted to Effect's number-schema artifact
|
||||
(`number | "NaN" | "Infinity" | "-Infinity"`, emitted as single-value string enums),
|
||||
while raw JSON Schema unions render every branch. Tests: `signature.test.ts` +3.
|
||||
- **Compound assignment now matches binary-operator semantics** (`src/codemode.ts`):
|
||||
`applyCompoundAssignment` did raw JS ops on interpreter wrapper objects, so `x += y`
|
||||
diverged from `x = x + y` (sandbox Date `d += 1` produced `"[object Object]1"`;
|
||||
`d -= 400` gave `NaN` instead of epoch arithmetic). The operator table + coercion moved
|
||||
verbatim out of `evaluateBinaryExpression` into a shared `applyBinaryOperator`;
|
||||
compound assignment validates against a `compoundOperators` set (`+=` ... `>>>=`) and
|
||||
dispatches through it (`operator.slice(0, -1)`). Logical assignments (`&&=`/`||=`/`??=`)
|
||||
keep their separate short-circuit path (`evaluateLogicalAssignment`), and both
|
||||
assignment call sites still wrap results in `boundedData`. Deliberate side effect:
|
||||
compound assignment now rejects opaque references, consistent with binary operators.
|
||||
Tests: `parity.test.ts` +5 (Date `+=` concat parity, Date `-=`/`/=` epoch parity,
|
||||
string `+=` object/array, member-target compound, 13-case operator sweep vs real JS).
|
||||
Package suite: 220 pass.
|
||||
|
||||
---
|
||||
|
||||
## 4. Remaining work (detailed TODO)
|
||||
|
||||
### Next DSL-expansion pass (done - see the DSL-expansion pass entry in section 3)
|
||||
|
||||
Batch these together - per user direction: important, but deliberately deferred to one
|
||||
focused interpreter-surface pass rather than picked off piecemeal.
|
||||
|
||||
- [x] Medium-tier JS parity items deferred from the original audit: caught errors are plain
|
||||
`{ name, message }` objects, not `instanceof Error` (and `Error` isn't a value -
|
||||
`x instanceof Error` is unsupported syntax); `splice` (still a
|
||||
"rewrite using map/filter" hint) and array `entries()/keys()/values()`;
|
||||
`localeCompare`/`normalize`/`trimLeft`/`trimRight`; friendlier regex-y error messages.
|
||||
(`fill`/`copyWithin` - which the hint set also covered - were implemented too since
|
||||
they are trivial host delegations, so the hint set is gone entirely.)
|
||||
- [x] `Date`/`Map`/`Set`/`RegExp` values passing through `Object.*` helpers and coercion
|
||||
checkpoints take their JSON forms (e.g. `Object.values({ d: date })` yields the ISO
|
||||
string, not the Date - calling `.getTime()` on it then fails). Currently deliberate
|
||||
(documented in README) but flagged as important: fix in this pass by letting sandbox
|
||||
values survive `Object.*`/spread checkpoints instead of JSON-serializing them.
|
||||
- [x] `console.log(NaN)` prints `"null"` (goes through the boundary chokepoint) - could
|
||||
special-case number formatting in `formatConsoleArgument`.
|
||||
- [x] Sandbox values nested inside logged containers print `[CodeMode reference]`
|
||||
(`console.log({ m: map })`) - could deep-format instead.
|
||||
|
||||
### Next iteration: text-result handling (deliberate follow-up, user-directed)
|
||||
|
||||
- [ ] Revisit how MCP text results reach the program. Today: `structuredContent` when the
|
||||
server sends it, else joined text as a plain string (the program JSON.parses it,
|
||||
guided by a workflow step). Considered and deferred: (a) conservative boundary
|
||||
auto-parse (text starting with `{`/`[` that parses cleanly becomes an object) -
|
||||
rejected for now as potentially confusing (type flips; program sees something other
|
||||
than what the tool sent); (b) raw-envelope passthrough with the envelope shape
|
||||
stamped into every output schema - rejected (more digging per call, verbose
|
||||
signatures). Result quality is dominated by whether servers declare output schemas;
|
||||
revisit once real usage shows which failure modes matter.
|
||||
|
||||
### Next iteration: stdlib surface (prioritized)
|
||||
|
||||
Current instructions say "usual Array/String/Object/Math/JSON methods," but the interpreter is
|
||||
intentionally a subset. Keep CodeMode focused on orchestration and data shaping, not a full host
|
||||
runtime, but close the high-friction gaps models are likely to reach for.
|
||||
|
||||
- [ ] **P0: tighten wording first** - change instructions/docs to say "common stdlib subset"
|
||||
until the surface is broader. This avoids misleading the model into assuming every JS
|
||||
helper exists.
|
||||
- [ ] **P1: URL parsing helpers** - add `URL` and `URLSearchParams`. These are high-value for
|
||||
tool orchestration (query strings, ids in URLs, API links), deterministic, and do not add
|
||||
ambient host authority.
|
||||
- [ ] **P2: Math completion** - add the missing standard deterministic `Math` methods
|
||||
(`sin`/`cos`/`tan`, inverse/hyperbolic variants, `atan2`, `log1p`, `expm1`, `imul`,
|
||||
`fround`, `clz32`, etc.). Decide explicitly on `Math.random`: likely acceptable because
|
||||
`Date.now()` is already exposed, but document the nondeterminism if enabled.
|
||||
- [ ] **P3: base64 helpers** - add string-only `atob`/`btoa` equivalents. Useful for API/tool
|
||||
payload cleanup and does not require opening the broader binary boundary.
|
||||
- [ ] **P4: small crypto helper** - consider `crypto.randomUUID()` only, not full `crypto`.
|
||||
UUID generation is a common orchestration need; broader crypto can wait until there is a
|
||||
concrete use case and a clear capability boundary.
|
||||
- [ ] **P5: text/binary primitives** - consider `TextEncoder`/`TextDecoder` first, then
|
||||
`ArrayBuffer`/typed arrays/`DataView`/`Blob`/`File` only with an explicit boundary design
|
||||
(serialization, size limits, and how values cross tool args/results). This is reasonable
|
||||
but lower priority than URL/base64 because CodeMode is still plain-data oriented.
|
||||
- [ ] **P6: date/formatting conveniences** - consider `Date` setters and common formatting
|
||||
helpers (`toUTCString`, maybe `Intl` later). Lower priority; most orchestration can use
|
||||
existing getters, `Date.parse`, `Date.UTC`, and ISO strings.
|
||||
- [ ] **P7: environment/config access** - do not expose raw `process.env` as a global ambient
|
||||
authority. If this becomes useful, add an explicit host-provided/whitelisted capability
|
||||
(for example a small env/config tool or injected read-only object) so secrets are not
|
||||
accidentally exposed to arbitrary CodeMode programs.
|
||||
|
||||
Explicit non-goals for now: `structuredClone`, `WeakMap`/`WeakSet`, and timers
|
||||
(`setTimeout`/`setInterval`/`queueMicrotask`). They do not materially improve the current tool
|
||||
orchestration use case.
|
||||
|
||||
### Wiring-review findings (subagent code review of the OpenCode integration, triaged)
|
||||
|
||||
Pre-PR fixes (user-approved cut):
|
||||
|
||||
- [x] **Cancellation does not interrupt the interpreter** - the no-limits rationale claimed
|
||||
"user cancel interrupts the execution fiber," but `tools.ts` runs tools via
|
||||
`run.promise` -> `Effect.runPromise` (`effect/bridge.ts:64-66`) with NO abort wiring;
|
||||
on cancel the ai-sdk abandons the promise, child MCP calls abort (they hold
|
||||
`ctx.abort`) but the interpreter fiber spun on - `while(true){}` or a try/catch
|
||||
loop was uncancellable with no timeout backstop. Verified by hand, not just the
|
||||
reviewer. FIXED in the adapter: `Effect.raceFirst(runtime.execute(code), cancelled)`
|
||||
where `cancelled` is an `Effect.callback` abort-signal watcher (listener removed on
|
||||
interruption) resuming with an `ok: false` "Execution cancelled." result - the abort
|
||||
winning the race interrupts the execution fiber (interpreter auto-yield makes busy
|
||||
loops preemptible, same mechanism as timeoutMs) and returning a value keeps the
|
||||
runner's post-abort `completeToolCall` bookkeeping on its normal path. A pre-aborted
|
||||
signal short-circuits at entry before the program starts (racing alone still lets
|
||||
the loser run its first steps). Tests: +2 adapter (child call triggers abort
|
||||
deterministically then the program enters `while(true){}` - would hang if
|
||||
interruption broke; pre-aborted signal runs nothing). Adapter suite 34 -> 36.
|
||||
(Wiring abort->interrupt into the shared `tools.ts` runner for ALL tools remains a
|
||||
worthwhile separate change.)
|
||||
- [x] **Permission-denied/disabled MCP tools are still advertised in the catalog** - the
|
||||
non-code-mode path filters them from the model's view (`llm/request.ts:208-213`);
|
||||
code mode builds the catalog from all of `mcp.tools()`, so the model is invited to
|
||||
call tools that can only fail at permission time, and per-message `tools[key]=false`
|
||||
disabling has no child-call equivalent. Fix: filter the catalog with the same
|
||||
ruleset.
|
||||
DONE (see the "Registry promotion + permission-aware catalog" entry in section 3): the
|
||||
shared `Permission.visibleTools` predicate filters both the appended
|
||||
catalog/description (`describeCodeMode`, agent ruleset) and the execute-time tool
|
||||
tree (merged agent+session ruleset) - hard-denied tools are neither advertised nor
|
||||
dispatchable. Ask-level tools stay visible/callable. Per-message
|
||||
`tools[key] === false` remains a documented gap by design (it arrives at
|
||||
request-prep, after descriptions are built).
|
||||
- [x] Style: `code-mode.ts` is the only `src/session` sibling without the
|
||||
`export * as ... from "./..."` self-reexport footer, forcing a star import at
|
||||
`tools.ts:26` (AGENTS.md violation). Add footer + import the projection.
|
||||
DONE: added `export * as SessionCodeMode from "./code-mode"` footer; `tools.ts` now
|
||||
imports the named `SessionCodeMode` projection.
|
||||
- [x] Trivial: latent `groupByServer` fallback bug - `key.slice(0, key.indexOf("_"))` is
|
||||
`slice(0, -1)` when no underscore (unreachable today; guard or drop); dead
|
||||
`CODE_MODE_TOOL` export (integration points hardcode `"execute"` - use it or inline
|
||||
it).
|
||||
DONE: no-underscore key now falls back to the whole key (test pins it); the four
|
||||
`title: "execute"` sites in `code-mode.ts` now reference `CODE_MODE_TOOL`.
|
||||
|
||||
Post-MVP (logged, not blocking an experimental flag):
|
||||
|
||||
- [x] **Plugin `tool.execute.before/after` hooks skip child calls** - legacy MCP
|
||||
registration fires them per tool (`tools.ts:419-441`); under code mode only the
|
||||
outer `execute` fires them, so auditing/intercepting plugins silently lose MCP
|
||||
coverage when the flag flips.
|
||||
DONE (see the "Shared MCP invocation middle" entry in section 3): both paths now run
|
||||
`McpInvoke.invoke` (`src/mcp/invoke.ts`) - hooks AND the `Tool.execute` span fire
|
||||
for child calls with synthetic `${parentCallID}/${n}` callIDs; hook failures are
|
||||
child-scoped, catchable in-program errors.
|
||||
- [x] Description/preview rebuilt every assistant turn - `registry.tools()` re-runs
|
||||
`groupByServer` + a throwaway `CodeMode.make(...).instructions()` per turn
|
||||
(`describeCodeMode`). DECIDED as an explicit non-goal: memoizing the catalog
|
||||
builder keyed on (ToolsChanged generation, permission ruleset) was considered and
|
||||
deliberately skipped - the per-turn rebuild is cheap (grouping + string
|
||||
rendering); revisit only if profiling shows it matters. A second `CodeMode.make`
|
||||
per execution is inherent (description precedes execution).
|
||||
- [ ] Child permission rejection round-trips through the defect channel - `ctx.ask`
|
||||
defect (`tools.ts:90` orDie) recovered via `catchCause` + `Cause.squash`
|
||||
(`code-mode.ts:238-245`). Works, interrupts preserved, but fragile coupling;
|
||||
exposing the typed rejection on `Tool.Context.ask` would be cleaner.
|
||||
- [ ] No collision guard on the `execute` tool id (a plugin/custom tool named `execute`
|
||||
is silently shadowed; a log line would do).
|
||||
- [ ] Style nits: triple-nested `yield*` in `tools.ts:101-107` argument position (bind
|
||||
first, like neighbors); single-use micro-helpers (`toJsonSchema` is a bare cast);
|
||||
comment density far above session-neighbor norm; adapter tests use raw
|
||||
`Effect.runPromise` + hand-built layers with `as any` instead of the
|
||||
`testEffect`/`LayerNode.compile` fixture pattern (`test/tool/grep.test.ts:25-31`)
|
||||
and star-import `Truncate`.
|
||||
- [ ] Reviewer observation worth keeping: MCP server instructions (`sys.mcp`,
|
||||
`session/system.ts:110-126`) still inject prose referencing server-native tool
|
||||
names that are no longer directly callable under code mode.
|
||||
|
||||
### Backlog / loose ends (non-blocking, any order)
|
||||
|
||||
- [ ] `evaluateUpdateExpression` (`++`/`--`) still uses raw `Number(current)`, so `d++` on a
|
||||
sandbox Date yields `NaN` where `d += 1` now uses epoch semantics (and real JS `d++`
|
||||
would give epoch+0 numeric). Pre-existing, out of scope of the compound-assignment
|
||||
parity fix; route it through `applyBinaryOperator` if it ever matters.
|
||||
- [ ] Media-only marker could name what it attached when MCP provides names: `image`/`audio`
|
||||
blocks carry no filename (mime + data only) so the generic
|
||||
`[N images attached to the result]` stays, but `resource`/`resource_link` blocks have
|
||||
URIs/names we could surface, e.g. `[2 files attached: chart.png, data.csv]`. Minor.
|
||||
- [x] Truncation layering decided (user direction): the OPPOSITE of killing the outer layer -
|
||||
CodeMode truncation off in OpenCode (`maxOutputBytes` lost its default; absent = no
|
||||
truncation, uniform with the other two limits), native tool-output truncation is the
|
||||
single active layer (verified: `execute` flows through `tool.ts` `wrap()` like any
|
||||
normal tool, no exemption). See the section 3 entry.
|
||||
- [x] Flaky wall-clock assertion removed from `test/promise.test.ts`: the parallelism test
|
||||
now relies solely on the deterministic `trace.maxActive > 1` counter (which proves
|
||||
true temporal overlap). The timeout tests were never flaky - 100ms timeout vs 60s
|
||||
tool sleeps (600x margin) with counter-based assertions.
|
||||
- [ ] Attachment propagation believed correct but unverified end-to-end at the OpenCode
|
||||
wiring layer (codemode strips -> `Tool.ExecuteResult.attachments` -> processor
|
||||
normalizes -> `FilePart`s visible to the model). Code-reviewed as sound; confirm with
|
||||
one interactive session (an image-returning MCP tool) when convenient. Same session
|
||||
can eyeball TUI child-call rendering via `metadata.toolCalls`.
|
||||
- [x] Commit hygiene: all work committed and pushed on `codemode-v2` as six commits, in
|
||||
generic-package + OpenCode-integration pairs (waves 0-5; Fixes 4-9; DSL pass +
|
||||
error names + truncation layering). Future work: commit only when explicitly asked;
|
||||
push with `--no-verify` per repo convention. The scratch `.opencode/opencode.jsonc`
|
||||
stays uncommitted.
|
||||
- [ ] MVP scope decided (user direction): the interactive e2e eyeball is NOT required -
|
||||
remaining pre-PR work is essentially just opening the PR. Attachment-propagation
|
||||
verification (below) stays parked as post-MVP.
|
||||
|
||||
---
|
||||
|
||||
## 5. Context and gotchas for whoever picks this up
|
||||
|
||||
- **Motivating failure (why forgiving semantics + prompting matter):** in a real transcript,
|
||||
the model wrote `me.result?.login ?? me.result` where the tool result was a JSON _string_ -
|
||||
the old strict interpreter threw (`String property 'login' is not available`); then the
|
||||
model returned a raw 105KB payload, which native truncation dumped to a file, costing a
|
||||
subagent round-trip to extract one number. Interpreter forgiveness stops the crashes;
|
||||
Wave 4 prompting stops the payload dumping. Both are needed.
|
||||
- Realistically **all MCP tools render `Promise<unknown>`** (no outputSchema), so the
|
||||
instructions prose is the only lever for result-shape behavior in the dominant case.
|
||||
- **`copyIn` has two roles, split by a mode flag** (DSL-expansion pass): host<->sandbox
|
||||
boundary (default mode - final result, tool arguments, `JSON.stringify`, tool-result
|
||||
intake; sandbox value types serialize to JSON forms) AND intra-sandbox data checkpoint
|
||||
(`boundedData` = `copyIn(value, label, true)` - sandbox value instances pass through by
|
||||
reference as leaves, everything else keeps the same plain-data validation). If you add a
|
||||
new value type, follow the Wave 1b-i pattern: class in `values.ts`, opaque-by-default via
|
||||
`isRuntimeReference`, explicit carve-outs, JSON form in `copyIn`'s boundary mode plus
|
||||
pass-through in its preserving mode, console formatting (`formatConsoleValue`), tests -
|
||||
and make sure the `Object.*` helpers treat it as an empty object so class fields never
|
||||
leak.
|
||||
- The interpreter throws synchronously inside `Effect.gen`/`Effect.sync` freely; everything is
|
||||
normalized by `catchCause` -> `normalizeError` into `Diagnostic` data. Program failures are
|
||||
**data, never Effect failures**; only interruption propagates.
|
||||
- `parseProgram` wraps source in `async function __codemode__() { ... }`, transpiles TS, then
|
||||
slices between the first `{` and last `}` - line/col diagnostics are offset accordingly
|
||||
(`sourceLocation`). Don't inject prologue code; it breaks the offsets.
|
||||
- OpenCode wraps every tool's output with auto-truncation (`Tool.define` wrapper,
|
||||
`truncate.output`, 2000 lines / 50KB, saves full output to disk and appends a hint) unless
|
||||
`metadata.truncated` is set. The `execute` tool currently rides that for free.
|
||||
- Effect version: both repos pin `effect@4.0.0-beta.83` via bun catalogs. This package uses
|
||||
v4-only APIs (`Schema.Decoder`, `Schema.toJsonSchemaDocument`, `Context.Service`,
|
||||
`Cause.hasInterruptsOnly`, `Effect.timeoutOrElse`). The effect-smol checkout referenced in
|
||||
the workspace is the implementation source of truth for v4 behavior questions.
|
||||
- File map (this package): `src/codemode.ts` - types/limits/parser/Interpreter/execute/make;
|
||||
`src/tool-runtime.ts` - tool tree, `copyIn`/`copyOut`, search/discovery, invoke path;
|
||||
`src/tool.ts` - `Tool.make` + JSON-Schema->TS rendering; `src/values.ts` - sandbox value
|
||||
types; `src/tool-error.ts` - `ToolError`; tests in `test/{codemode,parity,stdlib}.test.ts`.
|
||||
- OpenCode file map (integration points): `src/tool/code-mode.ts` (the adapter, now a
|
||||
registry tool service - `CodeModeTool` + `catalogInstructions`; formerly
|
||||
`src/session/code-mode.ts`); `src/tool/registry.ts` (`describeCodeMode`, enablement in
|
||||
`tools()`, `MCP.node` dep); `src/session/tools.ts` (raw-MCP-registration suppression
|
||||
when the flag is on); `src/permission/index.ts` (`Permission.visibleTools`, the shared
|
||||
visibility predicate, also used by `src/session/llm/request.ts` `resolveTools`);
|
||||
`src/mcp/index.ts` (`MCP.tools()`/`MCP.defs()`); `src/mcp/catalog.ts` (`convertTool`,
|
||||
`server_tool` naming); `src/tool/tool.ts` (`ExecuteResult.attachments`, truncation
|
||||
wrapper); `src/session/message-v2.ts` (attachments -> vision);
|
||||
`packages/tui/src/routes/session/index.tsx` (`Execute` progress component);
|
||||
`src/effect/runtime-flags.ts` (feature flag).
|
||||
Program results and tool arguments are JSON-like data. Dates become ISO strings at host boundaries; RegExp, Map, and
|
||||
Set values become `{}` as they do under JSON serialization. Promise and runtime reference values cannot cross the
|
||||
boundary.
|
||||
|
||||
Unknown host failures and invalid outputs are sanitized. `ToolError` is the explicit channel for a safe message that a
|
||||
tool wants the model to see. Diagnostic categories distinguish parsing, unsupported syntax, unknown tools, invalid
|
||||
data, tool failures, limits, timeouts, and execution failures.
|
||||
|
||||
Files and other attachment content stay outside the interpreter. A host may collect them while child tools execute and
|
||||
attach them to the outer result, but the program receives only the structured tool output.
|
||||
|
||||
### V2 OpenCode adapter
|
||||
|
||||
This section describes the `v2` branch integration. On `dev`, CodeMode is integrated through
|
||||
`packages/opencode/src/tool/code-mode.ts`, where nested MCP calls run the `tool.execute.before` and
|
||||
`tool.execute.after` plugin hooks.
|
||||
|
||||
CodeMode is integrated into V2 through `packages/core/src/tool/registry.ts` and
|
||||
`packages/core/src/tool/execute.ts`:
|
||||
|
||||
- Core has one canonical `Tool` representation. Location-scoped producers register direct or deferred tools through
|
||||
`Tools.Service`.
|
||||
- Each model step snapshots effective registrations, applies catalog visibility filtering, and exposes direct tools
|
||||
normally.
|
||||
- When visible deferred tools exist, Core reserves and materializes one `execute` tool. Grouped deferred tools become
|
||||
CodeMode namespaces instead of flattened model-facing names.
|
||||
- Each nested call checks that its captured registration is still current before dispatching it.
|
||||
- Authorization and side-effect ordering remain responsibilities of the leaf tool. Catalog visibility is not execution
|
||||
authorization.
|
||||
- Structured child output enters the interpreter. File parts are collected host-side and attached to the outer result.
|
||||
- Nested call statuses are returned as final `execute` metadata for the TUI.
|
||||
- `execute` is the one model-facing tool invocation. Nested calls reuse its invocation context and do not independently
|
||||
run registry hooks or model-output bounding; this keeps complete intermediate structured values available for
|
||||
in-program filtering. The outer `execute` settlement is the single model-output bounding boundary.
|
||||
- Core supplies no CodeMode timeout or tool-call limit. User cancellation interrupts the outer invocation and its
|
||||
supervised children; the outer settlement applies Core's normal output-retention policy.
|
||||
|
||||
MCP tools use this canonical path: they register as grouped tools and are deferred while CodeMode is enabled. Existing
|
||||
output schemas are preserved in generated signatures. Direct Core tools remain direct and are not ambient globals
|
||||
inside CodeMode.
|
||||
|
||||
## Intentionally Unsupported
|
||||
|
||||
These are product boundaries rather than DSL backlog:
|
||||
|
||||
- Ambient filesystem, process, environment, network, credential, or application access. External work must go through
|
||||
supplied tools.
|
||||
- Modules, imports, dynamic imports, `eval`, arbitrary host globals, npm packages, and prototype mutation.
|
||||
- Generic permission prompts, authorization policy, durable pause/resume, replay, storage, or exactly-once external
|
||||
side effects. Hosts and tools own those concerns.
|
||||
- Heuristic parsing of text tool results as JSON. A result should not silently change type based on its contents.
|
||||
|
||||
The OpenAPI adapter may gain more transports and encodings, but it must continue skipping operations it cannot
|
||||
represent accurately rather than guessing semantics.
|
||||
|
||||
## Decisions and Rationale
|
||||
|
||||
| Decision | Rationale |
|
||||
| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Keep an owned tree-walking interpreter. | The product need is bounded tool orchestration, not arbitrary JavaScript. Owning the language surface keeps authority and behavior explicit. |
|
||||
| Treat schemas as the model-facing interface. | Signatures drive correct calls; Effect Schema also provides the runtime validation boundary, while JSON Schema supports adapter interoperability. |
|
||||
| Keep authority host-owned. | CodeMode can only confine programs to supplied tools. The host chooses those tools, and each tool enforces its own authorization and side-effect policy. |
|
||||
| Use progressive catalog disclosure plus search. | Large tool sets should not consume the prompt, but every namespace must remain discoverable and speculative search calls should remain valid. |
|
||||
| Start tool promises eagerly and supervise them. | This preserves normal call-time parallelism while giving each call run-once settlement and interruption safety. |
|
||||
| Keep files outside the sandbox value space. | Models should compose structured data without routing binary payloads through generated code or context. |
|
||||
| Treat `execute` as the model-facing invocation boundary. | Nested calls are implementation details of one orchestration program. Reusing the outer context and bounding only the final result preserves complete intermediate data without inventing durable child-call identities. |
|
||||
| Return expected failures as data. | Models need actionable diagnostics without exposing private host causes; host interruption and defects must still propagate correctly. |
|
||||
| Leave execution-limit defaults to hosts. | Appropriate budgets depend on the surrounding product and its own cancellation, retention, and output-bounding policies. |
|
||||
| Skip unsupported OpenAPI operations. | Incorrect parameter encoding, authentication, or transport behavior is worse than a precise `skipped` reason. |
|
||||
|
||||
## Remaining Work
|
||||
|
||||
Keep only material unresolved work here. Small isolated defects should be GitHub issues; adapter-only work belongs in
|
||||
the adapter TODO. Delete entries when completed.
|
||||
|
||||
### DSL expansion
|
||||
|
||||
The supported JavaScript subset should grow when common model-generated code improves tool orchestration. These are
|
||||
current omissions to implement, not intentional product boundaries.
|
||||
|
||||
- [ ] Design proper multi-stage promise pipelines. Supporting `.then`, `.catch`, and `.finally` should preserve promise
|
||||
assimilation, cancellation, failure handling, and concurrent per-item pipelines rather than adding syntax-only
|
||||
shims. Consider `Promise.any` in the same pass.
|
||||
- [ ] Support async iteration and `for await...of`. Define behavior first for the runtime's supported promise and
|
||||
collection values, then extend it to bounded host streams when a stream boundary exists.
|
||||
- [ ] Support callback-bearing standard-library variants that models commonly generate: the mapper argument to
|
||||
`Array.from(...)` and replacers for `JSON.stringify(...)`, including Effect-aware callbacks where needed.
|
||||
- [ ] Close basic `Object` parity gaps: let `Object.values`/`Object.entries` accept arrays, make `Object.assign` validate
|
||||
and mutate its target, add `Object.is`, and let `Object.fromEntries` consume every supported iterable.
|
||||
- [ ] Add deterministic modern collection conveniences where they improve orchestration: `Object.groupBy`, Set
|
||||
composition methods, and `Array.prototype.toSpliced`.
|
||||
- [ ] Complete the deterministic `Math` surface beyond the current arithmetic, rounding, root, power, and logarithm
|
||||
helpers. Decide separately whether nondeterministic `Math.random` belongs in the runtime.
|
||||
- [ ] Refine diagnostics so user throws, expected tool failures, unexpected host/tool defects, and genuine interpreter
|
||||
defects are distinguishable without leaking private causes.
|
||||
|
||||
### Tool and result contracts
|
||||
|
||||
- [ ] Design explicit tagged representations and size rules before allowing Blob, File, ArrayBuffer, typed arrays, or
|
||||
host streams to cross the sandbox boundary.
|
||||
- [ ] Define one consistent policy for tool path segments named `__proto__`, `constructor`, or `prototype`. They must
|
||||
either be safely callable, rejected before catalog generation, or use one documented escaping rule.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode-ai/codemode",
|
||||
"version": "0.0.1",
|
||||
"version": "1.17.14",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
+60
-4027
@@ -1,26 +1,10 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Effect, Exit, Fiber, Schema, Semaphore } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
import {
|
||||
copyIn,
|
||||
copyOut,
|
||||
isBlockedMember,
|
||||
ToolReference,
|
||||
ToolRuntime,
|
||||
ToolRuntimeError,
|
||||
type HostTools,
|
||||
type SafeObject,
|
||||
type ToolCall,
|
||||
type ToolDescription,
|
||||
type Services,
|
||||
} from "./tool-runtime.js"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { executeWithLimits } from "./interpreter/runtime.js"
|
||||
import { type HostTools, type Services, type ToolDescription, ToolRuntime } from "./tool-runtime.js"
|
||||
import type { Definition } from "./tool.js"
|
||||
import { ToolError } from "./tool-error.js"
|
||||
import { isSandboxValue, SandboxDate, SandboxMap, SandboxPromise, SandboxRegExp, SandboxSet } from "./values.js"
|
||||
|
||||
/** A tool call admitted during an execution. */
|
||||
export type { ToolCall, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
|
||||
export { ToolError, toolError } from "./tool-error.js"
|
||||
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
|
||||
|
||||
/** Resource budgets enforced independently during each CodeMode program execution. */
|
||||
export type ExecutionLimits = {
|
||||
@@ -28,35 +12,23 @@ export type ExecutionLimits = {
|
||||
readonly timeoutMs?: number
|
||||
/** Maximum number of tool calls admitted by the runtime. No default: absent means unlimited. */
|
||||
readonly maxToolCalls?: number
|
||||
/**
|
||||
* Maximum UTF-8 bytes of model-facing output: the serialized result value plus captured
|
||||
* logs. Excess output is truncated with an explanatory marker instead of failing. No
|
||||
* default: absent means no truncation (for hosts with their own output bounding).
|
||||
*/
|
||||
/** Maximum UTF-8 bytes of model-facing output. No default: absent means no truncation. */
|
||||
readonly maxOutputBytes?: number
|
||||
}
|
||||
|
||||
/** Controls how much of the tool catalog is inlined in agent instructions. */
|
||||
export type DiscoveryOptions = {
|
||||
/**
|
||||
* Estimated-token budget (chars/4, default 2000) for inlined full tool signatures in agent
|
||||
* instructions. Signatures that fit are inlined round-robin across namespaces; every
|
||||
* namespace is always listed with its tool count regardless of budget, and
|
||||
* `tools.$codemode.search` is always registered.
|
||||
*/
|
||||
readonly maxInlineCatalogTokens?: number
|
||||
/** Approximate token budget (chars/4, default 2000) for full catalog entries. */
|
||||
readonly catalogBudget?: number
|
||||
}
|
||||
|
||||
type ToolTree<R = never> = {
|
||||
readonly [name: string]: Definition<R> | ToolTree<R>
|
||||
}
|
||||
|
||||
type ResolvedExecutionLimits = {
|
||||
/** Undefined means no timeout. */
|
||||
export type ResolvedExecutionLimits = {
|
||||
readonly timeoutMs: number | undefined
|
||||
/** Undefined means unlimited tool calls. */
|
||||
readonly maxToolCalls: number | undefined
|
||||
/** Undefined means no output truncation. */
|
||||
readonly maxOutputBytes: number | undefined
|
||||
}
|
||||
|
||||
@@ -74,50 +46,20 @@ export type ExecuteOptions<Tools extends Record<string, unknown> = {}> = {
|
||||
onToolCallEnd?: (call: ToolRuntime.ToolCallEnded) => Effect.Effect<void, never, Services<Tools>>
|
||||
}
|
||||
|
||||
/** A normalized program diagnostic safe to return across an agent tool boundary. */
|
||||
export type Diagnostic = {
|
||||
readonly kind: DiagnosticKind
|
||||
readonly message: string
|
||||
readonly location?: { readonly line: number; readonly column: number }
|
||||
readonly suggestions?: ReadonlyArray<string>
|
||||
}
|
||||
|
||||
/** A JSON value that can cross the confined interpreter boundary. */
|
||||
export type DataValue = Schema.Json
|
||||
|
||||
/** Successful execution after the result has crossed the plain-data boundary. */
|
||||
export type ExecuteSuccess = {
|
||||
readonly ok: true
|
||||
readonly value: DataValue
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
/** Present when the value or logs were truncated to fit `maxOutputBytes`. */
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<ToolCall>
|
||||
}
|
||||
|
||||
/** Failed execution with calls admitted before the diagnostic was produced. */
|
||||
export type ExecuteFailure = {
|
||||
readonly ok: false
|
||||
readonly error: Diagnostic
|
||||
readonly logs?: ReadonlyArray<string>
|
||||
/** Present when the logs were truncated to fit `maxOutputBytes`. */
|
||||
readonly truncated?: boolean
|
||||
readonly toolCalls: ReadonlyArray<ToolCall>
|
||||
}
|
||||
|
||||
/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */
|
||||
export type ExecuteResult = ExecuteSuccess | ExecuteFailure
|
||||
|
||||
/** Reusable CodeMode configuration shared by `execute` and `agentTool`. */
|
||||
export type CodeModeOptions<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Tools extends Record<string, unknown> = {}> = Omit<ExecuteOptions<Tools>, "code"> & {
|
||||
/** Progressive-disclosure configuration for the agent-facing tool catalog. */
|
||||
readonly discovery?: DiscoveryOptions
|
||||
}
|
||||
|
||||
/** Input schema for the single agent-facing tool produced by `runtime.agentTool()`. */
|
||||
export const ExecuteInputSchema = Schema.Struct({ code: Schema.String })
|
||||
/** Schema for a host tool input containing CodeMode source. */
|
||||
export const Input = Schema.Struct({ code: Schema.String })
|
||||
export type Input = typeof Input.Type
|
||||
|
||||
const DiagnosticKindSchema = Schema.Literals([
|
||||
export const DiagnosticKind = Schema.Literals([
|
||||
"ParseError",
|
||||
"UnsupportedSyntax",
|
||||
"UnknownTool",
|
||||
@@ -129,337 +71,51 @@ const DiagnosticKindSchema = Schema.Literals([
|
||||
"ToolFailure",
|
||||
"ExecutionFailure",
|
||||
])
|
||||
/** Stable categories produced by program, schema, tool, and limit failures. */
|
||||
export type DiagnosticKind = typeof DiagnosticKind.Type
|
||||
|
||||
/** Structured success or diagnostic result schema returned by CodeMode execution. */
|
||||
export const ExecuteResultSchema = Schema.Union([
|
||||
Schema.Struct({
|
||||
ok: Schema.Literal(true),
|
||||
value: Schema.Json,
|
||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
truncated: Schema.optionalKey(Schema.Boolean),
|
||||
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
|
||||
}),
|
||||
Schema.Struct({
|
||||
ok: Schema.Literal(false),
|
||||
error: Schema.Struct({
|
||||
kind: DiagnosticKindSchema,
|
||||
message: Schema.String,
|
||||
location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })),
|
||||
suggestions: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
}),
|
||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
truncated: Schema.optionalKey(Schema.Boolean),
|
||||
toolCalls: Schema.Array(Schema.Struct({ name: Schema.String })),
|
||||
}),
|
||||
])
|
||||
export const Diagnostic = Schema.Struct({
|
||||
kind: DiagnosticKind,
|
||||
message: Schema.String,
|
||||
location: Schema.optionalKey(Schema.Struct({ line: Schema.Number, column: Schema.Number })),
|
||||
suggestions: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
})
|
||||
/** A normalized program diagnostic safe to return across an agent tool boundary. */
|
||||
export type Diagnostic = typeof Diagnostic.Type
|
||||
|
||||
/** Agent-facing projection of a configured CodeMode runtime. */
|
||||
export type AgentToolDefinition<R = never> = {
|
||||
readonly name: "code"
|
||||
readonly description: string
|
||||
readonly input: typeof ExecuteInputSchema
|
||||
readonly output: typeof ExecuteResultSchema
|
||||
readonly execute: (input: { readonly code: string }) => Effect.Effect<ExecuteResult, never, R>
|
||||
}
|
||||
const ToolCallSchema = Schema.Struct({ name: Schema.String })
|
||||
export const Success = Schema.Struct({
|
||||
ok: Schema.Literal(true),
|
||||
value: Schema.Json,
|
||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
truncated: Schema.optionalKey(Schema.Boolean),
|
||||
toolCalls: Schema.Array(ToolCallSchema),
|
||||
})
|
||||
/** Successful execution after the result has crossed the plain-data boundary. */
|
||||
export type Success = typeof Success.Type
|
||||
|
||||
export const Failure = Schema.Struct({
|
||||
ok: Schema.Literal(false),
|
||||
error: Diagnostic,
|
||||
logs: Schema.optionalKey(Schema.Array(Schema.String)),
|
||||
truncated: Schema.optionalKey(Schema.Boolean),
|
||||
toolCalls: Schema.Array(ToolCallSchema),
|
||||
})
|
||||
/** Failed execution with calls admitted before the diagnostic was produced. */
|
||||
export type Failure = typeof Failure.Type
|
||||
|
||||
/** Schema for the structured success or diagnostic returned by CodeMode execution. */
|
||||
export const Result = Schema.Union([Success, Failure])
|
||||
/** Result of executing a CodeMode program. Program failures are data, not Effect failures. */
|
||||
export type Result = typeof Result.Type
|
||||
|
||||
/** Reusable confined runtime over one explicit tool tree. */
|
||||
export type CodeModeRuntime<R = never> = {
|
||||
/** Lists schema-described tool paths provided by the host. */
|
||||
export type Runtime<R = never> = {
|
||||
readonly catalog: () => ReadonlyArray<ToolDescription>
|
||||
/** Builds model-facing syntax guidance and visible tool signatures. */
|
||||
readonly instructions: () => string
|
||||
/** Projects the configured runtime as one agent-facing `code` tool. */
|
||||
readonly agentTool: () => AgentToolDefinition<R>
|
||||
/** Executes a program using this runtime's configured host tools. */
|
||||
readonly execute: (code: string) => Effect.Effect<ExecuteResult, never, R>
|
||||
readonly execute: (code: string) => Effect.Effect<Result, never, R>
|
||||
}
|
||||
|
||||
type SourcePosition = {
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
type SourceLocation = {
|
||||
start: SourcePosition
|
||||
end: SourcePosition
|
||||
}
|
||||
|
||||
type AstNode = {
|
||||
type: string
|
||||
loc?: SourceLocation
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type ProgramNode = AstNode & {
|
||||
type: "Program"
|
||||
body: Array<AstNode>
|
||||
}
|
||||
|
||||
type Binding = {
|
||||
mutable: boolean
|
||||
value: unknown
|
||||
// Absent means initialized. `false` marks a parameter binding seeded into its scope but not
|
||||
// yet bound, so a default that forward-references a later parameter sees a TDZ error (as in JS)
|
||||
// rather than silently resolving to an outer binding of the same name.
|
||||
initialized?: boolean
|
||||
}
|
||||
|
||||
type StatementResult =
|
||||
| { kind: "none" }
|
||||
| { kind: "value"; value: unknown }
|
||||
| { kind: "return"; value: unknown }
|
||||
| { kind: "break" }
|
||||
| { kind: "continue" }
|
||||
|
||||
type MemberReference = {
|
||||
target: SafeObject | Array<unknown>
|
||||
key: string | number
|
||||
}
|
||||
|
||||
class CodeModeFunction {
|
||||
constructor(
|
||||
readonly parameters: ReadonlyArray<AstNode>,
|
||||
readonly body: AstNode,
|
||||
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
||||
) {}
|
||||
}
|
||||
|
||||
class IntrinsicReference {
|
||||
constructor(
|
||||
readonly receiver: unknown,
|
||||
readonly name: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
class ComputedValue {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
class PromiseNamespace {}
|
||||
|
||||
type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject"
|
||||
|
||||
class PromiseMethodReference {
|
||||
constructor(readonly name: PromiseMethodName) {}
|
||||
}
|
||||
|
||||
// A built-in global namespace (`Object`, `Math`, `JSON`, `Array`, ...); members resolve to a
|
||||
// GlobalMethodReference, except known constants (e.g. `Math.PI`) which resolve to a value.
|
||||
type GlobalNamespaceName = "Object" | "Math" | "JSON" | "Array" | "console" | "Date" | "RegExp" | "Map" | "Set"
|
||||
|
||||
class GlobalNamespace {
|
||||
constructor(readonly name: GlobalNamespaceName) {}
|
||||
}
|
||||
|
||||
class GlobalMethodReference {
|
||||
constructor(
|
||||
readonly namespace: GlobalNamespaceName | "Number" | "String",
|
||||
readonly name: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
class CoercionFunction {
|
||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
|
||||
}
|
||||
|
||||
class ProgramThrow {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
class ErrorConstructorReference {
|
||||
constructor(readonly name: string) {}
|
||||
}
|
||||
|
||||
// Non-enumerable so spread/copyOut preserve the plain `{ name, message }` data shape.
|
||||
const ErrorBrand: unique symbol = Symbol("codemode.error")
|
||||
|
||||
const brandError = (errorValue: SafeObject, name: string): SafeObject => {
|
||||
Object.defineProperty(errorValue, ErrorBrand, { value: name })
|
||||
return errorValue
|
||||
}
|
||||
|
||||
const createErrorValue = (name: string, message: string): SafeObject =>
|
||||
brandError(Object.assign(Object.create(null) as SafeObject, { name, message }), name)
|
||||
|
||||
const errorBrandName = (value: unknown): string | undefined =>
|
||||
value !== null && typeof value === "object"
|
||||
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
|
||||
: undefined
|
||||
|
||||
/** Stable categories produced by program, schema, tool, and limit failures. */
|
||||
export type DiagnosticKind =
|
||||
| "ParseError"
|
||||
| "UnsupportedSyntax"
|
||||
| "UnknownTool"
|
||||
| "InvalidToolInput"
|
||||
| "InvalidToolOutput"
|
||||
| "InvalidDataValue"
|
||||
| "ToolCallLimitExceeded"
|
||||
| "TimeoutExceeded"
|
||||
| "ToolFailure"
|
||||
| "ExecutionFailure"
|
||||
|
||||
const arrayMethods = new Set([
|
||||
"map",
|
||||
"filter",
|
||||
"find",
|
||||
"findIndex",
|
||||
"findLast",
|
||||
"findLastIndex",
|
||||
"some",
|
||||
"every",
|
||||
"includes",
|
||||
"join",
|
||||
"reduce",
|
||||
"reduceRight",
|
||||
"flatMap",
|
||||
"forEach",
|
||||
"sort",
|
||||
"toSorted",
|
||||
"slice",
|
||||
"concat",
|
||||
"indexOf",
|
||||
"lastIndexOf",
|
||||
"at",
|
||||
"flat",
|
||||
"reverse",
|
||||
"toReversed",
|
||||
"with",
|
||||
"push",
|
||||
"pop",
|
||||
"shift",
|
||||
"unshift",
|
||||
"splice",
|
||||
"fill",
|
||||
"copyWithin",
|
||||
"keys",
|
||||
"values",
|
||||
"entries",
|
||||
])
|
||||
|
||||
const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])
|
||||
|
||||
const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"])
|
||||
|
||||
const stringMethods = new Set([
|
||||
"toLowerCase",
|
||||
"toUpperCase",
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
"indexOf",
|
||||
"lastIndexOf",
|
||||
"replace",
|
||||
"replaceAll",
|
||||
"repeat",
|
||||
"padStart",
|
||||
"padEnd",
|
||||
"charAt",
|
||||
"charCodeAt",
|
||||
"codePointAt",
|
||||
"at",
|
||||
"concat",
|
||||
"toString",
|
||||
"match",
|
||||
"matchAll",
|
||||
"search",
|
||||
"localeCompare",
|
||||
"normalize",
|
||||
])
|
||||
|
||||
const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"])
|
||||
|
||||
const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"])
|
||||
|
||||
const stringStatics = new Set(["fromCharCode", "fromCodePoint"])
|
||||
|
||||
const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||
|
||||
const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "resolve", "reject"])
|
||||
|
||||
const errorConstructors = new Set([
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"ReferenceError",
|
||||
"EvalError",
|
||||
"URIError",
|
||||
])
|
||||
|
||||
const valueConstructors = new Set(["Date", "RegExp", "Map", "Set"])
|
||||
|
||||
const dateMethods = new Set([
|
||||
"getTime",
|
||||
"valueOf",
|
||||
"toISOString",
|
||||
"toJSON",
|
||||
"toString",
|
||||
"getFullYear",
|
||||
"getMonth",
|
||||
"getDate",
|
||||
"getDay",
|
||||
"getHours",
|
||||
"getMinutes",
|
||||
"getSeconds",
|
||||
"getMilliseconds",
|
||||
"getUTCFullYear",
|
||||
"getUTCMonth",
|
||||
"getUTCDate",
|
||||
"getUTCDay",
|
||||
"getUTCHours",
|
||||
"getUTCMinutes",
|
||||
"getUTCSeconds",
|
||||
"getUTCMilliseconds",
|
||||
"getTimezoneOffset",
|
||||
])
|
||||
const dateStatics = new Set(["now", "parse", "UTC"])
|
||||
|
||||
const regexpMethods = new Set(["test", "exec", "toString"])
|
||||
// Read-only host regex fields surfaced as plain values.
|
||||
const regexpProperties = new Set([
|
||||
"source",
|
||||
"flags",
|
||||
"lastIndex",
|
||||
"global",
|
||||
"ignoreCase",
|
||||
"multiline",
|
||||
"sticky",
|
||||
"unicode",
|
||||
"dotAll",
|
||||
])
|
||||
|
||||
const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
||||
const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
||||
|
||||
const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||
|
||||
const supportedSyntaxMessage =
|
||||
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
|
||||
|
||||
const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError(
|
||||
`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
|
||||
/** How many eagerly forked tool calls may run at once. Fixed; not a configurable knob. */
|
||||
const TOOL_CALL_CONCURRENCY = 8
|
||||
|
||||
/** Console formatting recursion ceiling; deeper values render as "...". Fixed; not a knob. */
|
||||
const MAX_CONSOLE_DEPTH = 32
|
||||
|
||||
const validateLimit = <Value extends number | undefined>(
|
||||
name: keyof ExecutionLimits,
|
||||
value: Value,
|
||||
@@ -471,3656 +127,33 @@ const validateLimit = <Value extends number | undefined>(
|
||||
return value
|
||||
}
|
||||
|
||||
// No limit has a default: absent means no timeout / unlimited calls / no output truncation -
|
||||
// budgets are host policy, not library policy. A host without its own output bounding should
|
||||
// pass maxOutputBytes explicitly, or oversized results flood model context.
|
||||
const resolveExecutionLimits = (limits?: ExecutionLimits): ResolvedExecutionLimits => ({
|
||||
timeoutMs: validateLimit("timeoutMs", limits?.timeoutMs, 1),
|
||||
maxToolCalls: validateLimit("maxToolCalls", limits?.maxToolCalls, 0),
|
||||
maxOutputBytes: validateLimit("maxOutputBytes", limits?.maxOutputBytes, 0),
|
||||
})
|
||||
|
||||
class InterpreterRuntimeError extends Error {
|
||||
readonly node?: AstNode
|
||||
/**
|
||||
* The constructor name a program observes when it catches this failure (`caught.name`, and
|
||||
* the brand behind `caught instanceof SyntaxError` etc.). "Error" unless the failing
|
||||
* operation names a standard type in real JS - e.g. JSON.parse and invalid regex patterns
|
||||
* throw SyntaxError, an unknown identifier is a ReferenceError, a bad normalize form is a
|
||||
* RangeError.
|
||||
*/
|
||||
errorName: string = "Error"
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
node?: AstNode,
|
||||
readonly kind: DiagnosticKind = "ExecutionFailure",
|
||||
readonly suggestions?: ReadonlyArray<string>,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "InterpreterRuntimeError"
|
||||
|
||||
if (node) {
|
||||
this.node = node
|
||||
}
|
||||
}
|
||||
|
||||
as(errorName: string): this {
|
||||
this.errorName = errorName
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null
|
||||
|
||||
const asNode = (value: unknown, context: string): AstNode => {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`)
|
||||
}
|
||||
|
||||
return value as AstNode
|
||||
}
|
||||
|
||||
const getArray = (node: AstNode, key: string): Array<unknown> => {
|
||||
const value = node[key]
|
||||
if (!Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const getString = (node: AstNode, key: string): string => {
|
||||
const value = node[key]
|
||||
if (typeof value !== "string") {
|
||||
throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const getBoolean = (node: AstNode, key: string): boolean => {
|
||||
const value = node[key]
|
||||
if (typeof value !== "boolean") {
|
||||
throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node)
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => {
|
||||
const value = node[key]
|
||||
if (value === undefined || value === null) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return asNode(value, key)
|
||||
}
|
||||
|
||||
const getNode = (node: AstNode, key: string): AstNode => {
|
||||
const value = node[key]
|
||||
return asNode(value, key)
|
||||
}
|
||||
|
||||
const parseProgram = (code: string): ProgramNode => {
|
||||
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
|
||||
if (diagnostic) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||
undefined,
|
||||
"ParseError",
|
||||
)
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
const parsed = parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
}) as unknown
|
||||
|
||||
if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
|
||||
throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
|
||||
}
|
||||
|
||||
return parsed as ProgramNode
|
||||
}
|
||||
|
||||
const formatLocation = (node?: AstNode): string => {
|
||||
if (!node || !node.loc) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const location = sourceLocation(node)
|
||||
return ` (line ${location.line}, col ${location.column})`
|
||||
}
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
const publicErrorMessage = (message: string): string =>
|
||||
message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "<redacted-path>")
|
||||
|
||||
const normalizeError = (error: unknown): Diagnostic => {
|
||||
if (error instanceof InterpreterRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: `${error.message}${formatLocation(error.node)}`,
|
||||
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
|
||||
...(error.suggestions ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: error.message,
|
||||
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolError) {
|
||||
return { kind: "ToolFailure", message: publicErrorMessage(error.message) }
|
||||
}
|
||||
|
||||
if (error instanceof ProgramThrow) {
|
||||
const value = error.value
|
||||
let message: string
|
||||
if (containsRuntimeReference(value)) {
|
||||
// A thrown tool/function reference must not leak its internal structure.
|
||||
message = "a non-data value"
|
||||
} else if (typeof value === "string") {
|
||||
message = value
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { message?: unknown }).message === "string"
|
||||
) {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
||||
} catch {
|
||||
message = String(value)
|
||||
}
|
||||
}
|
||||
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
|
||||
}
|
||||
|
||||
if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: "Execution exceeded the maximum nesting depth.",
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
|
||||
message: publicErrorMessage(error.message),
|
||||
}
|
||||
}
|
||||
|
||||
// A non-Error thrown by a host tool (raw string / number / Symbol) still routes through
|
||||
// path redaction so filesystem paths can never leak through the catch-all branch.
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: publicErrorMessage(String(error)),
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers.
|
||||
const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
if (thrown instanceof ProgramThrow) return thrown.value
|
||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
||||
const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error"
|
||||
return createErrorValue(name, normalizeError(thrown).message)
|
||||
}
|
||||
|
||||
const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true)
|
||||
|
||||
const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalNamespace ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
|
||||
const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsRuntimeReference(item, seen))
|
||||
: Object.values(value).some((item) => containsRuntimeReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// Like containsRuntimeReference, but sandbox value types (Date/RegExp/Map/Set) count as data:
|
||||
// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive
|
||||
// coercion) rather than rejecting them as opaque interpreter machinery.
|
||||
const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isSandboxValue(value)) return false
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsOpaqueReference(item, seen))
|
||||
: Object.values(value).some((item) => containsOpaqueReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// `typeof` never throws in JS; map every interpreter value to its JS-visible category.
|
||||
// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly
|
||||
// like a real JS promise.
|
||||
const typeofValue = (value: unknown): string => {
|
||||
if (
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof ErrorConstructorReference
|
||||
)
|
||||
return "function"
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
if (value instanceof GlobalNamespace) {
|
||||
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
|
||||
}
|
||||
return typeof value
|
||||
}
|
||||
|
||||
// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any
|
||||
// left-hand value (opaque references included) without coercing it. Error checks use the
|
||||
// error brand: `instanceof Error` accepts every branded error; a specific error type matches
|
||||
// its own brand only (as in JS, where TypeError instances are also Error instances).
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
if (rhs instanceof ErrorConstructorReference) {
|
||||
const brand = errorBrandName(lhs)
|
||||
return brand !== undefined && (rhs.name === "Error" || brand === rhs.name)
|
||||
}
|
||||
if (rhs instanceof GlobalNamespace) {
|
||||
switch (rhs.name) {
|
||||
case "Date":
|
||||
return lhs instanceof SandboxDate
|
||||
case "RegExp":
|
||||
return lhs instanceof SandboxRegExp
|
||||
case "Map":
|
||||
return lhs instanceof SandboxMap
|
||||
case "Set":
|
||||
return lhs instanceof SandboxSet
|
||||
case "Array":
|
||||
return Array.isArray(lhs)
|
||||
case "Object":
|
||||
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
}
|
||||
}
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise
|
||||
// Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so
|
||||
// `x instanceof Number` is always false - exactly what it is for primitives in JS.
|
||||
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
|
||||
return false
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, Array, Object, or Promise.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
// A regex engine failure message without the engine's own "Invalid regular expression:"
|
||||
// prefix, so composed diagnostics read as one sentence instead of stuttering the phrase.
|
||||
const regexFailureReason = (error: unknown): string =>
|
||||
(error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "")
|
||||
|
||||
const escapeRegexHint =
|
||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||
|
||||
// A string method's pattern argument as a host regex: a sandbox regex passes its own host
|
||||
// instance through (so `g` lastIndex semantics follow the spec across calls); a string becomes
|
||||
// a pattern, exactly as String.prototype.match/matchAll/search do (`extraFlags` adds matchAll's
|
||||
// implicit `g`). Invalid patterns fail as catchable program errors that say what was wrong
|
||||
// with the pattern and how to fix it.
|
||||
const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||
if (arg instanceof SandboxRegExp) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
return new RegExp(arg, extraFlags)
|
||||
} catch (error) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
// A host match result as a sandbox value: a plain array of the full match and captures, with
|
||||
// `index` and named `groups` attached as own array properties (readable, and dropped at data
|
||||
// boundaries exactly like JSON.stringify drops them in JS). `input` is omitted - it duplicates
|
||||
// the whole subject string per match.
|
||||
const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
|
||||
const result: Array<unknown> = Array.from(match, (group) => group)
|
||||
if (match.index !== undefined) (result as Record<string, unknown> & Array<unknown>).index = match.index
|
||||
if (match.groups) {
|
||||
const groups: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, group] of Object.entries(match.groups)) {
|
||||
if (!isBlockedMember(key)) groups[key] = group
|
||||
}
|
||||
;(result as Record<string, unknown> & Array<unknown>).groups = groups
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toLowerCase":
|
||||
result = value.toLowerCase()
|
||||
break
|
||||
case "toUpperCase":
|
||||
result = value.toUpperCase()
|
||||
break
|
||||
case "trim":
|
||||
result = value.trim()
|
||||
break
|
||||
// trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them.
|
||||
case "trimStart":
|
||||
case "trimLeft":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
case "trimRight":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options arguments are ignored: comparison runs with the host default locale, and
|
||||
// the common use is a sort comparator where any consistent order works.
|
||||
case "localeCompare":
|
||||
result = value.localeCompare(str(0))
|
||||
break
|
||||
case "normalize": {
|
||||
const form = optStr(0)
|
||||
try {
|
||||
result = value.normalize(form)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
node,
|
||||
).as("RangeError")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "split": {
|
||||
if (args.length === 0) {
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
result = value.split((args[0] as SandboxRegExp).regex, optNum(1))
|
||||
break
|
||||
}
|
||||
const requestedLimit = optNum(1)
|
||||
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
break
|
||||
}
|
||||
case "slice":
|
||||
result = value.slice(optNum(0), optNum(1))
|
||||
break
|
||||
case "includes":
|
||||
result = value.includes(str(0), optNum(1))
|
||||
break
|
||||
case "startsWith":
|
||||
result = value.startsWith(str(0), optNum(1))
|
||||
break
|
||||
case "endsWith":
|
||||
result = value.endsWith(str(0), optNum(1))
|
||||
break
|
||||
case "indexOf":
|
||||
result = value.indexOf(str(0), optNum(1))
|
||||
break
|
||||
case "lastIndexOf":
|
||||
result = value.lastIndexOf(str(0), optNum(1))
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof CodeModeFunction || args[1] instanceof CodeModeFunction) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${name} does not support function replacers in CodeMode; use match/matchAll and rebuild the string instead.`,
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
const pattern = (args[0] as SandboxRegExp).regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
||||
break
|
||||
}
|
||||
if (name === "replace") {
|
||||
result = value.replace(str(0), str(1))
|
||||
break
|
||||
}
|
||||
result = value.replaceAll(str(0), str(1))
|
||||
break
|
||||
}
|
||||
case "match": {
|
||||
const pattern = toHostRegex(args[0], name, node)
|
||||
const matched = value.match(pattern)
|
||||
if (matched === null) return null
|
||||
// A global match is a plain array of matched strings; a non-global match carries
|
||||
// index/groups own properties, so bypass the copying data checkpoint to keep them.
|
||||
if (pattern.global) return boundedData(matched, "String.match result")
|
||||
return matchToValue(matched)
|
||||
}
|
||||
case "matchAll": {
|
||||
const pattern = toHostRegex(args[0], name, node, "g")
|
||||
if (!pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
// Materialized as an array (not an iterator); each entry is a match array with
|
||||
// index/groups own properties. Match count is bounded by the subject length.
|
||||
return Array.from(value.matchAll(pattern), matchToValue)
|
||||
}
|
||||
case "search": {
|
||||
result = value.search(toHostRegex(args[0], name, node))
|
||||
break
|
||||
}
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
case "padStart":
|
||||
result = value.padStart(num(0), optStr(1))
|
||||
break
|
||||
case "padEnd":
|
||||
result = value.padEnd(num(0), optStr(1))
|
||||
break
|
||||
case "charAt":
|
||||
result = value.charAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "at":
|
||||
result = value.at(optNum(0) ?? 0)
|
||||
break
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "substr":
|
||||
result = value.substr(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
// JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value
|
||||
// (normalized to null only at the data boundary - see copyOut), so return it as-is.
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "codePointAt":
|
||||
result = value.codePointAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "toString":
|
||||
result = value
|
||||
break
|
||||
case "concat": {
|
||||
result = value.concat(...args.map((_, index) => str(index)))
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `String.${name} result`)
|
||||
}
|
||||
|
||||
const invokeNumberMethod = (value: number, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const optNum = (index: number): number | undefined => {
|
||||
const arg = args[index]
|
||||
if (arg === undefined) return undefined
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
|
||||
return arg
|
||||
}
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toFixed":
|
||||
result = value.toFixed(optNum(0))
|
||||
break
|
||||
case "toExponential":
|
||||
result = value.toExponential(optNum(0))
|
||||
break
|
||||
case "toPrecision": {
|
||||
const digits = optNum(0)
|
||||
result = digits === undefined ? value.toString() : value.toPrecision(digits)
|
||||
break
|
||||
}
|
||||
case "toString": {
|
||||
const radix = optNum(0)
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node)
|
||||
}
|
||||
result = value.toString(radix)
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `Number.${name} result`)
|
||||
}
|
||||
|
||||
// JavaScript's String(...) without tripping over CodeMode's null-prototype data objects.
|
||||
const coerceToString = (value: unknown): string => {
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
// Sandbox values stringify deterministically: Date as ISO (not the host's locale/timezone
|
||||
// toString), RegExp as its literal form, Map/Set with their JS Object.prototype tags.
|
||||
if (value instanceof SandboxDate)
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
|
||||
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof SandboxMap) return "[object Map]"
|
||||
if (value instanceof SandboxSet) return "[object Set]"
|
||||
if (typeof value === "object") {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
|
||||
: "[object Object]"
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
/** Compound assignment operators (`x op= y`), each applying the binary operator `op`. */
|
||||
const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
|
||||
|
||||
const coerceToNumber = (value: unknown): number => {
|
||||
if (value instanceof SandboxDate) return value.time
|
||||
if (isSandboxValue(value)) return Number.NaN
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
||||
}
|
||||
|
||||
const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
||||
// Sandbox values coerce before the data checkpoint (which would JSON-serialize them):
|
||||
// Number(date) is its time value, String(date) its ISO form, Boolean(x) is true.
|
||||
const raw = args[0]
|
||||
if (isSandboxValue(raw)) {
|
||||
if (ref.name === "Boolean") return true
|
||||
if (ref.name === "Number") return coerceToNumber(raw)
|
||||
if (ref.name === "String") return coerceToString(raw)
|
||||
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||
return parseFloat(coerceToString(raw))
|
||||
}
|
||||
const value = boundedData(args[0], `${ref.name} input`)
|
||||
if (ref.name === "Number") return coerceToNumber(value)
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "parseInt") {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number")
|
||||
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
return coerceToString(value)
|
||||
}
|
||||
|
||||
const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const value = boundedData(args[0], `Object.${name} input`)
|
||||
// Sandbox values (Date/RegExp/Map/Set) have no own enumerable properties in JS, so the
|
||||
// Object.* helpers see them as empty objects - never their interpreter internals.
|
||||
if (isSandboxValue(value)) return {}
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
|
||||
out[key] = item
|
||||
}
|
||||
switch (name) {
|
||||
case "keys": {
|
||||
// Object.keys(array) yields index strings (["0", "1", ...]) exactly as in JS; objects
|
||||
// yield their own enumerable keys. (Tool references never reach here - the interpreter
|
||||
// resolves them against the host tool tree first.)
|
||||
const value = boundedData(args[0], "Object.keys input")
|
||||
if (isSandboxValue(value)) return []
|
||||
if (Array.isArray(value)) return Object.keys(value)
|
||||
if (value === null || typeof value !== "object") {
|
||||
throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node)
|
||||
}
|
||||
return Object.keys(value)
|
||||
}
|
||||
case "values":
|
||||
return Object.values(requireObject())
|
||||
case "entries":
|
||||
return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
||||
case "hasOwn":
|
||||
return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "assign": {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const source of args) {
|
||||
if (source === null || source === undefined) continue
|
||||
const value = boundedData(source, "Object.assign input")
|
||||
// A sandbox value source contributes nothing (no own enumerable properties in JS).
|
||||
if (isSandboxValue(value)) continue
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value))
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
case "fromEntries": {
|
||||
// A Map is the idiomatic fromEntries source; use its entries directly (the data
|
||||
// checkpoint would serialize a Map to {}).
|
||||
if (args[0] instanceof SandboxMap) {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const [key, item] of (args[0] as SandboxMap).map.entries()) guardedSet(out, coerceToString(key), item)
|
||||
return out
|
||||
}
|
||||
const pairs = boundedData(args[0], "Object.fromEntries input")
|
||||
if (!Array.isArray(pairs))
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const pair of pairs) {
|
||||
if (!Array.isArray(pair))
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
|
||||
guardedSet(out, String(pair[0]), pair[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
const nums = args.map((arg) => {
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
|
||||
return arg
|
||||
})
|
||||
const [a = Number.NaN, b = Number.NaN] = nums
|
||||
switch (name) {
|
||||
case "max":
|
||||
return Math.max(...nums)
|
||||
case "min":
|
||||
return Math.min(...nums)
|
||||
case "abs":
|
||||
return Math.abs(a)
|
||||
case "floor":
|
||||
return Math.floor(a)
|
||||
case "ceil":
|
||||
return Math.ceil(a)
|
||||
case "round":
|
||||
return Math.round(a)
|
||||
case "trunc":
|
||||
return Math.trunc(a)
|
||||
case "sign":
|
||||
return Math.sign(a)
|
||||
case "sqrt":
|
||||
return Math.sqrt(a)
|
||||
case "cbrt":
|
||||
return Math.cbrt(a)
|
||||
case "pow":
|
||||
return Math.pow(a, b)
|
||||
case "hypot":
|
||||
return Math.hypot(...nums)
|
||||
case "log":
|
||||
return Math.log(a)
|
||||
case "log2":
|
||||
return Math.log2(a)
|
||||
case "log10":
|
||||
return Math.log10(a)
|
||||
case "exp":
|
||||
return Math.exp(a)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
const replacer = args[1]
|
||||
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"JSON.stringify replacers are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
// copyIn first so only Data Values serialize, never a CodeModeFunction/ToolReference.
|
||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
|
||||
}
|
||||
case "parse": {
|
||||
const text = args[0]
|
||||
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch (error) {
|
||||
// The engine reason is derived from the program-supplied string (token/position), so
|
||||
// it is safe to surface - and the position is exactly what a model needs to fix it.
|
||||
throw new InterpreterRuntimeError(
|
||||
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
return copyIn(parsed, "JSON.parse result")
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "isArray":
|
||||
return Array.isArray(args[0])
|
||||
case "of":
|
||||
return [...args]
|
||||
case "from": {
|
||||
if (args.length > 1) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
// Map/Set materialize directly (the data checkpoint would serialize them to {}).
|
||||
if (args[0] instanceof SandboxMap)
|
||||
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
|
||||
const source = boundedData(args[0], "Array.from input")
|
||||
if (typeof source === "string") return Array.from(source)
|
||||
if (Array.isArray(source)) return [...source]
|
||||
if (
|
||||
source !== null &&
|
||||
typeof source === "object" &&
|
||||
typeof (source as { length?: unknown }).length === "number"
|
||||
) {
|
||||
return Array.from(source as ArrayLike<unknown>)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node)
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeNumberStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const value = args[0]
|
||||
switch (name) {
|
||||
case "isInteger":
|
||||
return Number.isInteger(value)
|
||||
case "isFinite":
|
||||
return Number.isFinite(value)
|
||||
case "isNaN":
|
||||
return Number.isNaN(value)
|
||||
case "isSafeInteger":
|
||||
return Number.isSafeInteger(value)
|
||||
case "parseInt": {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number")
|
||||
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
case "parseFloat":
|
||||
return parseFloat(coerceToString(value))
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeStringStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const codes = args.map((arg) => {
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
|
||||
return arg
|
||||
})
|
||||
switch (name) {
|
||||
case "fromCharCode":
|
||||
return String.fromCharCode(...codes)
|
||||
case "fromCodePoint":
|
||||
return String.fromCodePoint(...codes)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
switch (name) {
|
||||
case "now":
|
||||
return Date.now()
|
||||
case "parse":
|
||||
return Date.parse(coerceToString(args[0]))
|
||||
case "UTC": {
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return Date.UTC(...(parts as Parameters<typeof Date.UTC>))
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
|
||||
const hosted = new Date(value.time)
|
||||
switch (name) {
|
||||
case "getTime":
|
||||
case "valueOf":
|
||||
return value.time
|
||||
case "toISOString": {
|
||||
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node)
|
||||
return hosted.toISOString()
|
||||
}
|
||||
// toJSON of an invalid date is null in JS (never a throw); toString stays ISO for
|
||||
// determinism across host timezones/locales.
|
||||
case "toJSON":
|
||||
return Number.isFinite(value.time) ? hosted.toISOString() : null
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
case "getFullYear":
|
||||
return hosted.getFullYear()
|
||||
case "getMonth":
|
||||
return hosted.getMonth()
|
||||
case "getDate":
|
||||
return hosted.getDate()
|
||||
case "getDay":
|
||||
return hosted.getDay()
|
||||
case "getHours":
|
||||
return hosted.getHours()
|
||||
case "getMinutes":
|
||||
return hosted.getMinutes()
|
||||
case "getSeconds":
|
||||
return hosted.getSeconds()
|
||||
case "getMilliseconds":
|
||||
return hosted.getMilliseconds()
|
||||
case "getUTCFullYear":
|
||||
return hosted.getUTCFullYear()
|
||||
case "getUTCMonth":
|
||||
return hosted.getUTCMonth()
|
||||
case "getUTCDate":
|
||||
return hosted.getUTCDate()
|
||||
case "getUTCDay":
|
||||
return hosted.getUTCDay()
|
||||
case "getUTCHours":
|
||||
return hosted.getUTCHours()
|
||||
case "getUTCMinutes":
|
||||
return hosted.getUTCMinutes()
|
||||
case "getUTCSeconds":
|
||||
return hosted.getUTCSeconds()
|
||||
case "getUTCMilliseconds":
|
||||
return hosted.getUTCMilliseconds()
|
||||
case "getTimezoneOffset":
|
||||
return hosted.getTimezoneOffset()
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeRegExpMethod = (value: SandboxRegExp, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
// test/exec run on the sandbox regex's own host instance, so `g`-flag lastIndex advances
|
||||
// across calls per the spec.
|
||||
case "test":
|
||||
return value.regex.test(coerceToString(args[0]))
|
||||
case "exec": {
|
||||
const matched = value.regex.exec(coerceToString(args[0]))
|
||||
if (matched === null) return null
|
||||
return matchToValue(matched)
|
||||
}
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (ref.namespace === "console")
|
||||
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
|
||||
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Date") {
|
||||
if (!dateStatics.has(ref.name))
|
||||
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
|
||||
return invokeDateStatic(ref.name, args, node)
|
||||
}
|
||||
if (ref.namespace === "RegExp" || ref.namespace === "Map" || ref.namespace === "Set") {
|
||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
||||
}
|
||||
return invokeJsonMethod(ref.name, args, node)
|
||||
}
|
||||
|
||||
// Iterable spread sources: arrays, strings (code points), Maps (entry pairs), and Sets (values).
|
||||
const spreadItems = (spread: unknown): Array<unknown> | undefined => {
|
||||
if (Array.isArray(spread)) return spread
|
||||
if (typeof spread === "string") return Array.from(spread)
|
||||
if (spread instanceof SandboxMap)
|
||||
return Array.from(spread.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
if (spread instanceof SandboxSet) return Array.from(spread.set.values())
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run.
|
||||
const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<string> => {
|
||||
switch (pattern.type) {
|
||||
case "Identifier":
|
||||
out.push(getString(pattern, "name"))
|
||||
break
|
||||
case "AssignmentPattern":
|
||||
collectPatternNames(getNode(pattern, "left"), out)
|
||||
break
|
||||
case "RestElement":
|
||||
collectPatternNames(getNode(pattern, "argument"), out)
|
||||
break
|
||||
case "ArrayPattern":
|
||||
for (const element of getArray(pattern, "elements")) {
|
||||
if (element !== null) collectPatternNames(asNode(element, "elements"), out)
|
||||
}
|
||||
break
|
||||
case "ObjectPattern":
|
||||
for (const property of getArray(pattern, "properties")) {
|
||||
const prop = asNode(property, "properties")
|
||||
collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out)
|
||||
}
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
class Interpreter<R> {
|
||||
private scopes: Array<Map<string, Binding>>
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
// Enumerable namespace/tool names at a node of the host tool tree, threaded from
|
||||
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
|
||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
private readonly logs: Array<string>
|
||||
private lastValue: unknown
|
||||
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
|
||||
private readonly callPermits: Semaphore.Semaphore
|
||||
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
|
||||
// program completion drains these (like a runtime waiting on in-flight work at exit) and
|
||||
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
|
||||
private readonly pendingSettlements = new Set<SandboxPromise>()
|
||||
|
||||
constructor(
|
||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
logs: Array<string> = [],
|
||||
) {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = [globalScope]
|
||||
this.invokeTool = invokeTool
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
this.lastValue = undefined
|
||||
this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
||||
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
|
||||
globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") })
|
||||
globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") })
|
||||
globalScope.set("String", { mutable: false, value: new CoercionFunction("String") })
|
||||
globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") })
|
||||
globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") })
|
||||
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
||||
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
||||
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
||||
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
|
||||
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
||||
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
||||
globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") })
|
||||
// Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
|
||||
// (with or without `new`) constructs a branded { name, message } error object.
|
||||
for (const name of errorConstructors) {
|
||||
globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) })
|
||||
}
|
||||
// NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data
|
||||
// boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`.
|
||||
globalScope.set("NaN", { mutable: false, value: NaN })
|
||||
globalScope.set("Infinity", { mutable: false, value: Infinity })
|
||||
}
|
||||
|
||||
run(program: ProgramNode): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
// Run the program body in its own module scope on top of the builtin global scope, so
|
||||
// top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like
|
||||
// JS module scope, instead of colliding with the seeded globals.
|
||||
this.pushScope()
|
||||
return Effect.gen(function* () {
|
||||
self.hoistFunctions(program.body)
|
||||
let value: unknown = undefined
|
||||
let returned = false
|
||||
for (const statement of program.body) {
|
||||
const result = yield* self.evaluateStatement(statement)
|
||||
|
||||
if (result.kind === "return") {
|
||||
value = result.value
|
||||
returned = true
|
||||
break
|
||||
}
|
||||
|
||||
if (result.kind === "break" || result.kind === "continue") {
|
||||
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
if (!returned) value = self.lastValue
|
||||
|
||||
// The program body runs inside an implicit async function, so a returned promise
|
||||
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
|
||||
// without an explicit await, exactly as in JS.
|
||||
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
||||
yield* self.drainPendingSettlements()
|
||||
return value
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
// Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so
|
||||
// their work completes before the execution ends - mirroring a JS runtime waiting on
|
||||
// in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection
|
||||
// diagnostic (interrupted calls, e.g. Promise.race losers, are ignored).
|
||||
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const promise of [...self.pendingSettlements]) {
|
||||
const exit = yield* self.observePromise(promise)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
|
||||
const failure = normalizeError(Cause.squash(exit.cause))
|
||||
throw new InterpreterRuntimeError(
|
||||
`Unhandled rejection from an un-awaited tool call: ${failure.message}`,
|
||||
undefined,
|
||||
failure.kind,
|
||||
["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Eagerly starts a tool call on a supervised child fiber (so the execution timeout and
|
||||
// scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
|
||||
// first-class promise value. `startImmediately` makes the runtime admit the call - charging
|
||||
// the tool-call budget and firing onToolCallStart - at the call site, before any await.
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
const self = this
|
||||
return Effect.map(
|
||||
Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), {
|
||||
startImmediately: true,
|
||||
}),
|
||||
(fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
self.pendingSettlements.add(promise)
|
||||
return promise
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
|
||||
// Fiber settlement is idempotent, so observing the same promise repeatedly (await twice,
|
||||
// Promise.all([p, p])) never re-runs the underlying call.
|
||||
private observePromise(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
this.pendingSettlements.delete(promise)
|
||||
return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void)
|
||||
}
|
||||
|
||||
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
|
||||
// observes it exactly like a synchronous throw at the await site.
|
||||
private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect<unknown, unknown, never> {
|
||||
const self = this
|
||||
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
|
||||
}
|
||||
|
||||
private unwrapPromiseExit(
|
||||
promise: SandboxPromise | undefined,
|
||||
exit: Exit.Exit<unknown, unknown>,
|
||||
node?: AstNode,
|
||||
): Effect.Effect<unknown, unknown> {
|
||||
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
||||
// A call Promise.race interrupted after losing settles as a catchable program failure;
|
||||
// any other interruption is execution teardown (timeout/host) and must keep propagating
|
||||
// as interruption rather than becoming program-visible data.
|
||||
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
|
||||
return Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Effect.failCause(exit.cause)
|
||||
}
|
||||
|
||||
private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
switch (node.type) {
|
||||
case "ExpressionStatement":
|
||||
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value }))
|
||||
case "VariableDeclaration":
|
||||
return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" }))
|
||||
case "ReturnStatement": {
|
||||
const argumentNode = getOptionalNode(node, "argument")
|
||||
return argumentNode
|
||||
? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value }))
|
||||
: Effect.succeed({ kind: "return", value: undefined })
|
||||
}
|
||||
case "BlockStatement":
|
||||
return this.evaluateBlock(node)
|
||||
case "IfStatement":
|
||||
return this.evaluateIfStatement(node)
|
||||
case "SwitchStatement":
|
||||
return this.evaluateSwitchStatement(node)
|
||||
case "WhileStatement":
|
||||
return this.evaluateWhileStatement(node)
|
||||
case "DoWhileStatement":
|
||||
return this.evaluateDoWhileStatement(node)
|
||||
case "ForStatement":
|
||||
return this.evaluateForStatement(node)
|
||||
case "ForOfStatement":
|
||||
return this.evaluateForOfStatement(node)
|
||||
case "ForInStatement":
|
||||
return this.evaluateForInStatement(node)
|
||||
case "BreakStatement":
|
||||
return Effect.succeed(this.evaluateBreakStatement(node))
|
||||
case "ContinueStatement":
|
||||
return Effect.succeed(this.evaluateContinueStatement(node))
|
||||
case "ThrowStatement":
|
||||
return this.evaluateThrowStatement(node)
|
||||
case "TryStatement":
|
||||
return this.evaluateTryStatement(node)
|
||||
case "EmptyStatement":
|
||||
return Effect.succeed({ kind: "none" })
|
||||
case "FunctionDeclaration":
|
||||
return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions
|
||||
default:
|
||||
throw unsupportedSyntax(node.type, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateBlock(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const body = getArray(node, "body")
|
||||
self.hoistFunctions(body)
|
||||
|
||||
for (const statementValue of body) {
|
||||
const statement = asNode(statementValue, "body")
|
||||
const result = yield* self.evaluateStatement(statement)
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind !== "none") {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private createFunction(node: AstNode): CodeModeFunction {
|
||||
if (node.generator === true) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Generator functions are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
return new CodeModeFunction(
|
||||
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
||||
getNode(node, "body"),
|
||||
this.scopes.slice(),
|
||||
)
|
||||
}
|
||||
|
||||
// Function declarations are hoisted: bound in their scope before the body runs, so a
|
||||
// program can call a helper defined further down (matching JavaScript).
|
||||
private hoistFunctions(statements: Array<unknown>): void {
|
||||
for (const statementValue of statements) {
|
||||
if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue
|
||||
const node = statementValue as AstNode
|
||||
this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const testNode = getNode(node, "test")
|
||||
const consequentNode = getNode(node, "consequent")
|
||||
const alternateNode = getOptionalNode(node, "alternate")
|
||||
|
||||
return Effect.flatMap(this.evaluateExpression(testNode), (test) =>
|
||||
test
|
||||
? this.evaluateStatement(consequentNode)
|
||||
: alternateNode
|
||||
? this.evaluateStatement(alternateNode)
|
||||
: Effect.succeed({ kind: "none" }),
|
||||
)
|
||||
}
|
||||
|
||||
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const self = this
|
||||
this.pushScope()
|
||||
return Effect.gen(function* () {
|
||||
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
||||
if (containsOpaqueReference(discriminant)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Switch discriminants must be data values in CodeMode.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
|
||||
let defaultIndex: number | undefined
|
||||
let selected: number | undefined
|
||||
for (const [index, branch] of cases.entries()) {
|
||||
const test = getOptionalNode(branch, "test")
|
||||
if (!test) {
|
||||
defaultIndex = index
|
||||
continue
|
||||
}
|
||||
const candidate = yield* self.evaluateExpression(test)
|
||||
if (containsOpaqueReference(candidate)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Switch case values must be data values in CodeMode.",
|
||||
test,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
if (candidate === discriminant) {
|
||||
selected = index
|
||||
break
|
||||
}
|
||||
}
|
||||
const start = selected ?? defaultIndex
|
||||
if (start === undefined) return { kind: "none" } satisfies StatementResult
|
||||
for (let index = start; index < cases.length; index += 1) {
|
||||
for (const statementValue of getArray(cases[index]!, "consequent")) {
|
||||
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
|
||||
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
|
||||
if (result.kind === "return" || result.kind === "continue") return result
|
||||
if (result.kind === "value") self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const testNode = getNode(node, "test")
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (yield* self.evaluateExpression(testNode)) {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateDoWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const bodyNode = getNode(node, "body")
|
||||
const testNode = getNode(node, "test")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
do {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
} while (yield* self.evaluateExpression(testNode))
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateForStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const initNode = getOptionalNode(node, "init")
|
||||
const testNode = getOptionalNode(node, "test")
|
||||
const updateNode = getOptionalNode(node, "update")
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
if (initNode) {
|
||||
if (initNode.type === "VariableDeclaration") {
|
||||
yield* self.evaluateVariableDeclaration(initNode)
|
||||
} else {
|
||||
yield* self.evaluateExpression(initNode)
|
||||
}
|
||||
}
|
||||
|
||||
const perIterationBindings =
|
||||
initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var"
|
||||
? Array.from(self.currentScope().keys())
|
||||
: []
|
||||
|
||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||
let iterationScope: Map<string, Binding> | undefined
|
||||
if (perIterationBindings.length > 0) {
|
||||
iterationScope = new Map(
|
||||
perIterationBindings.map((name) => {
|
||||
const binding = self.currentScope().get(name)!
|
||||
return [name, { ...binding }]
|
||||
}),
|
||||
)
|
||||
self.scopes.push(iterationScope)
|
||||
}
|
||||
const result = yield* self.evaluateStatement(bodyNode).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (iterationScope) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (iterationScope) {
|
||||
const loopScope = self.currentScope()
|
||||
for (const name of perIterationBindings) {
|
||||
loopScope.set(name, { ...iterationScope.get(name)! })
|
||||
}
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
yield* self.evaluateExpression(updateNode)
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private evaluateForOfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
if (getBoolean(node, "await")) {
|
||||
throw new InterpreterRuntimeError("for await...of is not supported.", node)
|
||||
}
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const left = getNode(node, "left")
|
||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const body = getNode(node, "body")
|
||||
|
||||
// Arrays iterate in place; strings iterate code points; Maps iterate [key, value]
|
||||
// pairs and Sets iterate values over a snapshot (mutation during iteration is safe).
|
||||
const iterable = Array.isArray(right) ? right : spreadItems(right)
|
||||
if (iterable === undefined) {
|
||||
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
|
||||
}
|
||||
|
||||
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||
let assignmentName: string | undefined
|
||||
|
||||
if (left.type === "VariableDeclaration") {
|
||||
const declarations = getArray(left, "declarations")
|
||||
if (declarations.length !== 1) {
|
||||
throw new InterpreterRuntimeError("for...of supports one declared binding.", left)
|
||||
}
|
||||
|
||||
const declarator = asNode(declarations[0], "declarations[0]")
|
||||
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||
} else if (left.type === "Identifier") {
|
||||
assignmentName = getString(left, "name")
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||
}
|
||||
|
||||
for (const value of iterable) {
|
||||
if (declaration) {
|
||||
self.pushScope()
|
||||
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
|
||||
} else if (assignmentName) {
|
||||
self.setIdentifierValue(assignmentName, value, left)
|
||||
}
|
||||
|
||||
const result = yield* self.evaluateStatement(body).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" }
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" }
|
||||
})
|
||||
}
|
||||
|
||||
// Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool
|
||||
// references: plain data objects enumerate their own keys, arrays their index strings (plus
|
||||
// any own non-index properties, e.g. match results' index/groups - exactly Object.keys in
|
||||
// JS), and a tool reference the namespace/tool names at its path in the host tool tree.
|
||||
// Returns undefined for everything else so callers can raise a contextual error.
|
||||
private enumerableKeys(value: unknown): Array<string> | undefined {
|
||||
if (value instanceof ToolReference) {
|
||||
return [...this.toolKeys(value.path)]
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Object.keys(value)
|
||||
}
|
||||
if (value !== null && typeof value === "object" && !isRuntimeReference(value)) {
|
||||
return Object.keys(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const left = getNode(node, "left")
|
||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const body = getNode(node, "body")
|
||||
|
||||
// Keys are snapshotted up front (mutation during iteration is safe): plain objects
|
||||
// enumerate their own keys, arrays their index strings, and tool references the
|
||||
// namespace/tool names at that node - the same enumeration Object.keys performs.
|
||||
// Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather
|
||||
// than real JS's surprising behavior (indices for strings, zero iterations for
|
||||
// Maps/Sets/null): the hint points at the constructs that do what the program means.
|
||||
const keys = self.enumerableKeys(right)
|
||||
if (keys === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||
let assignmentName: string | undefined
|
||||
|
||||
if (left.type === "VariableDeclaration") {
|
||||
const declarations = getArray(left, "declarations")
|
||||
if (declarations.length !== 1) {
|
||||
throw new InterpreterRuntimeError("for...in supports one declared binding.", left)
|
||||
}
|
||||
|
||||
const declarator = asNode(declarations[0], "declarations[0]")
|
||||
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||
} else if (left.type === "Identifier") {
|
||||
assignmentName = getString(left, "name")
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (declaration) {
|
||||
self.pushScope()
|
||||
yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left)
|
||||
} else if (assignmentName) {
|
||||
self.setIdentifierValue(assignmentName, key, left)
|
||||
}
|
||||
|
||||
const result = yield* self.evaluateStatement(body).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" }
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" }
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateBreakStatement(node: AstNode): StatementResult {
|
||||
const labelNode = getOptionalNode(node, "label")
|
||||
|
||||
if (labelNode) {
|
||||
throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node)
|
||||
}
|
||||
|
||||
return { kind: "break" }
|
||||
}
|
||||
|
||||
private evaluateContinueStatement(node: AstNode): StatementResult {
|
||||
const labelNode = getOptionalNode(node, "label")
|
||||
|
||||
if (labelNode) {
|
||||
throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node)
|
||||
}
|
||||
|
||||
return { kind: "continue" }
|
||||
}
|
||||
|
||||
private evaluateThrowStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const argument = getNode(node, "argument")
|
||||
return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value)))
|
||||
}
|
||||
|
||||
private evaluateTryStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const body = getNode(node, "block")
|
||||
const handler = getOptionalNode(node, "handler")
|
||||
const finalizer = getOptionalNode(node, "finalizer")
|
||||
const self = this
|
||||
|
||||
const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), {
|
||||
onFailure: (cause) => {
|
||||
if (cause.reasons.some(Cause.isInterruptReason) || !handler) {
|
||||
return Effect.failCause(cause)
|
||||
}
|
||||
|
||||
// The program sees a plain { message } error (or the thrown value itself) - see
|
||||
// caughtErrorValue, shared with Promise.allSettled rejection reasons.
|
||||
const caught = caughtErrorValue(Cause.squash(cause))
|
||||
const parameter = getOptionalNode(handler, "param")
|
||||
self.pushScope()
|
||||
return Effect.gen(function* () {
|
||||
if (parameter) yield* self.declarePattern(parameter, caught, true, handler)
|
||||
return yield* self.evaluateStatement(getNode(handler, "body"))
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
},
|
||||
onSuccess: Effect.succeed,
|
||||
})
|
||||
|
||||
if (!finalizer) return attempted
|
||||
|
||||
const isAbrupt = (result: StatementResult): boolean =>
|
||||
result.kind === "return" || result.kind === "break" || result.kind === "continue"
|
||||
|
||||
return Effect.matchCauseEffect(attempted, {
|
||||
onFailure: (cause) =>
|
||||
cause.reasons.some(Cause.isInterruptReason)
|
||||
? Effect.failCause(cause)
|
||||
: Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause),
|
||||
),
|
||||
onSuccess: (result) =>
|
||||
Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateVariableDeclaration(node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
const kind = getString(node, "kind")
|
||||
const declarations = getArray(node, "declarations")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const declarationValue of declarations) {
|
||||
const declaration = asNode(declarationValue, "declarations")
|
||||
|
||||
if (declaration.type !== "VariableDeclarator") {
|
||||
throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
|
||||
}
|
||||
|
||||
const init = getOptionalNode(declaration, "init")
|
||||
const value = init ? yield* self.evaluateExpression(init) : undefined
|
||||
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private declarePattern(
|
||||
pattern: AstNode,
|
||||
value: unknown,
|
||||
mutable: boolean,
|
||||
node: AstNode,
|
||||
): Effect.Effect<void, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (pattern.type === "Identifier") {
|
||||
self.declare(getString(pattern, "name"), value, mutable, node)
|
||||
return
|
||||
}
|
||||
|
||||
// Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined.
|
||||
if (pattern.type === "AssignmentPattern") {
|
||||
const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value
|
||||
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node)
|
||||
return
|
||||
}
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object destructuring requires a data object value.",
|
||||
pattern,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
const consumed = new Set<string>()
|
||||
for (const propertyValue of getArray(pattern, "properties")) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
// Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys.
|
||||
if (property.type === "RestElement") {
|
||||
const rest: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, item] of Object.entries(value as SafeObject)) {
|
||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
||||
}
|
||||
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
property.type !== "Property" ||
|
||||
getBoolean(property, "computed") ||
|
||||
getString(property, "kind") !== "init"
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
|
||||
}
|
||||
|
||||
const keyNode = getNode(property, "key")
|
||||
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
|
||||
if (isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
|
||||
}
|
||||
consumed.add(key)
|
||||
yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pattern.type === "ArrayPattern") {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
|
||||
}
|
||||
|
||||
for (const [index, item] of getArray(pattern, "elements").entries()) {
|
||||
if (item === null) continue
|
||||
const element = asNode(item, `elements[${index}]`)
|
||||
// Array rest: `[head, ...tail]` - binds the remaining elements (must be last).
|
||||
if (element.type === "RestElement") {
|
||||
yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
|
||||
break
|
||||
}
|
||||
yield* self.declarePattern(element, value[index], mutable, pattern)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
switch (node.type) {
|
||||
case "Literal": {
|
||||
// A regex literal parses as a Literal node carrying { pattern, flags }; construct the
|
||||
// sandbox regex from those (the host `value` instance is never exposed).
|
||||
const regex = node.regex
|
||||
if (isRecord(regex) && typeof regex.pattern === "string") {
|
||||
return Effect.sync(() =>
|
||||
this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node),
|
||||
)
|
||||
}
|
||||
return Effect.sync(() => boundedData(node.value, "Literal"))
|
||||
}
|
||||
case "Identifier":
|
||||
return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node))
|
||||
case "BinaryExpression":
|
||||
return this.evaluateBinaryExpression(node)
|
||||
case "LogicalExpression":
|
||||
return this.evaluateLogicalExpression(node)
|
||||
case "UnaryExpression":
|
||||
return this.evaluateUnaryExpression(node)
|
||||
case "AssignmentExpression":
|
||||
return this.evaluateAssignmentExpression(node)
|
||||
case "CallExpression":
|
||||
return this.evaluateCallExpression(node)
|
||||
case "ArrowFunctionExpression":
|
||||
case "FunctionExpression":
|
||||
return Effect.sync(() => this.createFunction(node))
|
||||
case "MemberExpression":
|
||||
return this.readMember(node)
|
||||
case "ChainExpression":
|
||||
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) =>
|
||||
value === OptionalShortCircuit ? undefined : value,
|
||||
)
|
||||
case "ObjectExpression":
|
||||
return this.evaluateObjectExpression(node)
|
||||
case "ArrayExpression":
|
||||
return this.evaluateArrayExpression(node)
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node)
|
||||
case "ConditionalExpression":
|
||||
return this.evaluateConditionalExpression(node)
|
||||
case "UpdateExpression":
|
||||
return this.evaluateUpdateExpression(node)
|
||||
case "AwaitExpression": {
|
||||
// `await` resolves a promise value; awaiting anything else is a passthrough no-op,
|
||||
// matching real JS semantics for non-thenables.
|
||||
const self = this
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
||||
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
|
||||
)
|
||||
}
|
||||
case "NewExpression":
|
||||
return this.evaluateNewExpression(node)
|
||||
default:
|
||||
throw unsupportedSyntax(node.type, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateNewExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callee = getNode(node, "callee")
|
||||
if (callee.type !== "Identifier") {
|
||||
throw unsupportedSyntax("NewExpression", node)
|
||||
}
|
||||
const name = getString(callee, "name")
|
||||
const argNodes = getArray(node, "arguments")
|
||||
const self = this
|
||||
if (name === "Promise") {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (errorConstructors.has(name)) {
|
||||
return Effect.gen(function* () {
|
||||
const arg =
|
||||
argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
|
||||
return createErrorValue(name, arg === undefined ? "" : coerceToString(arg))
|
||||
})
|
||||
}
|
||||
if (valueConstructors.has(name)) {
|
||||
return Effect.gen(function* () {
|
||||
const args = yield* self.evaluateCallArguments(argNodes)
|
||||
switch (name) {
|
||||
case "Date":
|
||||
return self.constructDate(args)
|
||||
case "RegExp":
|
||||
return self.constructRegExp(args, node)
|
||||
case "Map":
|
||||
return self.constructMap(args[0], node)
|
||||
default:
|
||||
return self.constructSet(args[0], node)
|
||||
}
|
||||
})
|
||||
}
|
||||
throw unsupportedSyntax("NewExpression", node)
|
||||
}
|
||||
|
||||
private constructDate(args: Array<unknown>): SandboxDate {
|
||||
if (args.length === 0) return new SandboxDate(Date.now())
|
||||
if (args.length === 1) {
|
||||
const arg = args[0]
|
||||
if (arg instanceof SandboxDate) return new SandboxDate(arg.time)
|
||||
if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime())
|
||||
if (typeof arg === "string") return new SandboxDate(Date.parse(arg))
|
||||
return new SandboxDate(Number.NaN)
|
||||
}
|
||||
// new Date(year, month, day?, hours?, ...) - local-time component form.
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return new SandboxDate(new Date(...(parts as [number, number])).getTime())
|
||||
}
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): SandboxRegExp {
|
||||
const first = args[0]
|
||||
const pattern =
|
||||
first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const flagsArg = args[1]
|
||||
if (flagsArg !== undefined && typeof flagsArg !== "string") {
|
||||
throw new InterpreterRuntimeError(
|
||||
`RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "")
|
||||
try {
|
||||
return new SandboxRegExp(pattern, flags)
|
||||
} catch (error) {
|
||||
// Say which part was rejected and how to fix it, instead of passing the engine
|
||||
// message through bare. A flags failure names the flags; a pattern failure gets the
|
||||
// escaping hint (the usual cause is an unescaped metacharacter in a built-up string).
|
||||
const reason = regexFailureReason(error)
|
||||
throw new InterpreterRuntimeError(
|
||||
/flag/i.test(reason)
|
||||
? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.`
|
||||
: `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`,
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
}
|
||||
|
||||
private constructMap(init: unknown, node: AstNode): SandboxMap {
|
||||
const target = new SandboxMap()
|
||||
if (init === undefined || init === null) return target
|
||||
const entries = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxMap
|
||||
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
: undefined
|
||||
if (entries === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Map(...) expects an array of [key, value] pairs, a Map, or no argument.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
for (const pair of entries) {
|
||||
if (!Array.isArray(pair)) {
|
||||
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node)
|
||||
}
|
||||
target.map.set(pair[0], pair[1])
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private constructSet(init: unknown, node: AstNode): SandboxSet {
|
||||
const target = new SandboxSet()
|
||||
if (init === undefined || init === null) return target
|
||||
const items = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxSet
|
||||
? Array.from(init.set.values())
|
||||
: typeof init === "string"
|
||||
? Array.from(init)
|
||||
: undefined
|
||||
if (items === undefined) {
|
||||
throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node)
|
||||
}
|
||||
for (const item of items) target.set.add(item)
|
||||
return target
|
||||
}
|
||||
|
||||
private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const lhs = yield* self.evaluateExpression(getNode(node, "left"))
|
||||
const rhs = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
// Like `typeof`, `instanceof` observes any value without coercing it (a promise or
|
||||
// function operand is a legitimate question, not an error), so it is handled before
|
||||
// the data-only operand check.
|
||||
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
|
||||
return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result")
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a binary operator to two already-evaluated operands with CodeMode's coercion
|
||||
* semantics. Shared by binary expressions and compound assignment (`x op= y` must behave
|
||||
* exactly like `x = x op y`, coercion included).
|
||||
*/
|
||||
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
|
||||
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
|
||||
throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue")
|
||||
}
|
||||
// Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host
|
||||
// "No default value" TypeError when an operator coerces them. Coerce to their JS string
|
||||
// form first (as String(x) / template literals do) so operators behave like JavaScript.
|
||||
// A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value
|
||||
// for arithmetic and ordering - so `end - start` and `a < b` work as in JS.
|
||||
// Identity (=== / !==) and the right operand of `in` keep their raw object value.
|
||||
const coerceOperand = (operand: unknown): unknown => {
|
||||
if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time
|
||||
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
}
|
||||
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
|
||||
const l = coerceOperand(lhs)
|
||||
const r = coerceOperand(rhs)
|
||||
switch (operator) {
|
||||
case "+":
|
||||
return (l as string) + (r as string)
|
||||
case "-":
|
||||
return (l as number) - (r as number)
|
||||
case "*":
|
||||
return (l as number) * (r as number)
|
||||
case "/":
|
||||
return (l as number) / (r as number)
|
||||
case "%":
|
||||
return (l as number) % (r as number)
|
||||
case "**":
|
||||
return (l as number) ** (r as number)
|
||||
// Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces.
|
||||
case "==":
|
||||
return bothObjects ? lhs === rhs : l == r
|
||||
case "===":
|
||||
return lhs === rhs
|
||||
case "!=":
|
||||
return bothObjects ? lhs !== rhs : l != r
|
||||
case "!==":
|
||||
return lhs !== rhs
|
||||
case "<":
|
||||
return (l as string) < (r as string)
|
||||
case "<=":
|
||||
return (l as string) <= (r as string)
|
||||
case ">":
|
||||
return (l as string) > (r as string)
|
||||
case ">=":
|
||||
return (l as string) >= (r as string)
|
||||
case "&":
|
||||
return (l as number) & (r as number)
|
||||
case "|":
|
||||
return (l as number) | (r as number)
|
||||
case "^":
|
||||
return (l as number) ^ (r as number)
|
||||
case "<<":
|
||||
return (l as number) << (r as number)
|
||||
case ">>":
|
||||
return (l as number) >> (r as number)
|
||||
case ">>>":
|
||||
return (l as number) >>> (r as number)
|
||||
case "in":
|
||||
if (rhs === null || typeof rhs !== "object") {
|
||||
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
// Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...).
|
||||
return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateLogicalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => {
|
||||
if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left)
|
||||
if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right"))
|
||||
if (operator === "??")
|
||||
return left !== null && left !== undefined
|
||||
? Effect.succeed(left)
|
||||
: this.evaluateExpression(getNode(node, "right"))
|
||||
throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const argument = getNode(node, "argument")
|
||||
// `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so
|
||||
// feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before
|
||||
// evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw.
|
||||
if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) {
|
||||
return Effect.succeed("undefined")
|
||||
}
|
||||
return Effect.map(this.evaluateExpression(argument), (value) => {
|
||||
// `typeof` and `!` never throw in JS - they observe any value (functions and runtime
|
||||
// references included) without coercing it, so feature detection and negation work.
|
||||
if (operator === "typeof") return typeofValue(value)
|
||||
if (operator === "!") return !value
|
||||
if (containsOpaqueReference(value)) {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
|
||||
}
|
||||
// Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value
|
||||
// (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to
|
||||
// their JS string form first (see evaluateBinaryExpression).
|
||||
const operand =
|
||||
value instanceof SandboxDate
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
: value
|
||||
let result: unknown
|
||||
switch (operator) {
|
||||
case "+":
|
||||
result = +(operand as number)
|
||||
break
|
||||
case "-":
|
||||
result = -(operand as number)
|
||||
break
|
||||
case "~":
|
||||
result = ~(operand as number)
|
||||
break
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
}
|
||||
return boundedData(result, "Unary expression result")
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateAssignmentExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const left = getNode(node, "left")
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (operator === "??=" || operator === "||=" || operator === "&&=") {
|
||||
return yield* self.evaluateLogicalAssignment(node, left, operator)
|
||||
}
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
if (operator === "=") return self.setIdentifierValue(name, rightValue, left)
|
||||
const next = boundedData(
|
||||
self.applyCompoundAssignment(operator, self.getIdentifierValue(name, left), rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
return self.setIdentifierValue(name, next, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
if (operator === "=") return yield* self.writeMember(left, rightValue)
|
||||
return yield* self.modifyMember(left, (current) => {
|
||||
const next = boundedData(
|
||||
self.applyCompoundAssignment(operator, current, rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
return Effect.succeed({ write: true, next, result: next })
|
||||
})
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateLogicalAssignment(
|
||||
node: AstNode,
|
||||
left: AstNode,
|
||||
operator: string,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
const shouldAssign = (current: unknown): boolean =>
|
||||
operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
return Effect.gen(function* () {
|
||||
const current = self.getIdentifierValue(name, left)
|
||||
if (!shouldAssign(current)) return current
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
return self.setIdentifierValue(name, rightValue, left)
|
||||
})
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
// Resolve the member exactly once; evaluate the RHS only if we actually assign.
|
||||
return self.modifyMember(left, (current) =>
|
||||
shouldAssign(current)
|
||||
? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({
|
||||
write: true,
|
||||
next: rightValue,
|
||||
result: rightValue,
|
||||
}))
|
||||
: Effect.succeed({ write: false, next: current, result: current }),
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
}
|
||||
|
||||
private evaluateUpdateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const argument = getNode(node, "argument")
|
||||
const prefix = getBoolean(node, "prefix")
|
||||
|
||||
const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
|
||||
|
||||
if (increment === undefined) {
|
||||
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = getString(argument, "name")
|
||||
const current = Number(this.getIdentifierValue(name, argument))
|
||||
const next = current + increment
|
||||
this.setIdentifierValue(name, next, argument)
|
||||
return prefix ? next : current
|
||||
})
|
||||
}
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = Number(current)
|
||||
const next = value + increment
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
}
|
||||
|
||||
private evaluateCallExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callee = getNode(node, "callee")
|
||||
const argNodes = getArray(node, "arguments")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const callable = yield* self.evaluateExpression(callee)
|
||||
if (callable === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit
|
||||
|
||||
const args = yield* self.evaluateCallArguments(argNodes)
|
||||
|
||||
if (callable instanceof ToolReference) {
|
||||
if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee)
|
||||
// An un-awaited tool call is a first-class promise value; the call itself starts now.
|
||||
return yield* self.createToolCallPromise(callable.path, args)
|
||||
}
|
||||
if (callable instanceof PromiseMethodReference) {
|
||||
return yield* self.invokePromiseMethod(callable, args, node)
|
||||
}
|
||||
if (callable instanceof CodeModeFunction) {
|
||||
return yield* self.invokeFunction(callable, args)
|
||||
}
|
||||
if (callable instanceof IntrinsicReference) {
|
||||
return yield* self.invokeIntrinsic(callable, args, node)
|
||||
}
|
||||
if (callable instanceof GlobalMethodReference) {
|
||||
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
|
||||
if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
|
||||
return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node)
|
||||
}
|
||||
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
||||
}
|
||||
if (callable instanceof CoercionFunction) {
|
||||
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
|
||||
}
|
||||
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
|
||||
if (callable instanceof ErrorConstructorReference) {
|
||||
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
|
||||
}
|
||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||
})
|
||||
}
|
||||
|
||||
// Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate
|
||||
// namespace/tool names from the host tool tree - the discovery idiom a model reaches for
|
||||
// first. Every other Object helper cannot produce data from a tool reference, so it fails
|
||||
// with a pointer at the working idioms instead of the generic plain-objects-only message.
|
||||
private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown {
|
||||
if (name === "keys") {
|
||||
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
|
||||
if (!consoleMethods.has(name))
|
||||
throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
|
||||
this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node)))
|
||||
return undefined
|
||||
}
|
||||
|
||||
private formatConsoleMessage(name: string, args: Array<unknown>, node: AstNode): string {
|
||||
if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0])
|
||||
if (name === "table") return this.formatConsoleTable(args[0], args[1], node)
|
||||
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
|
||||
return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}`
|
||||
}
|
||||
|
||||
// Console arguments format deeply and totally: values render as a debugger would show them
|
||||
// rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox
|
||||
// values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...],
|
||||
// Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place,
|
||||
// and plain objects/arrays render JSON-style. Formatting never fails the program: cycles
|
||||
// render "[Circular]" and extreme depth degrades to "...".
|
||||
private formatConsoleArgument(value: unknown): string {
|
||||
if (value === undefined) return "undefined"
|
||||
// A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue).
|
||||
if (typeof value === "string") return value
|
||||
return this.formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
private formatConsoleValue(value: unknown, seen: Set<object>, depth: number): string {
|
||||
// Nested undefined renders as null, matching what JSON boundary output would show.
|
||||
if (value === null || value === undefined) return "null"
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
// String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form.
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof SandboxDate) return coerceToString(value)
|
||||
if (value instanceof SandboxRegExp) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof SandboxMap) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof SandboxSet) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (isRuntimeReference(value)) return "[CodeMode reference]"
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]`
|
||||
}
|
||||
return `{${Object.entries(value)
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`)
|
||||
.join(",")}}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string {
|
||||
if (value === undefined) return "undefined"
|
||||
// Sandbox values are legitimate table data (cells render their friendly forms); only
|
||||
// truly opaque references (functions, tools, promises) collapse to the marker.
|
||||
if (containsOpaqueReference(value)) return "[CodeMode reference]"
|
||||
const data = boundedData(value, "console.table argument")
|
||||
const columns = this.consoleTableColumns(columnsArgument, node)
|
||||
const rows = this.consoleTableRows(data, columns)
|
||||
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
|
||||
const header = ["(index)", ...keys].join("\t")
|
||||
return [
|
||||
header,
|
||||
...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray<string> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (containsRuntimeReference(value)) return undefined
|
||||
const columns = copyOut(copyIn(value, "console.table columns"), true)
|
||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||
}
|
||||
|
||||
private consoleTableRows(
|
||||
data: unknown,
|
||||
columns: ReadonlyArray<string> | undefined,
|
||||
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
private consoleTableValues(value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
}
|
||||
return { Value: value }
|
||||
}
|
||||
|
||||
private formatConsoleTableCell(value: unknown): string {
|
||||
if (value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
return this.formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
private evaluateCallArguments(argNodes: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const args: Array<unknown> = []
|
||||
for (const [index, arg] of argNodes.entries()) {
|
||||
const argNode = asNode(arg, `arguments[${index}]`)
|
||||
if (argNode.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
|
||||
const items = spreadItems(spread)
|
||||
if (items === undefined)
|
||||
throw new InterpreterRuntimeError(
|
||||
"Spread arguments require an array, string, Map, or Set in CodeMode.",
|
||||
argNode,
|
||||
)
|
||||
args.push(...items)
|
||||
} else {
|
||||
args.push(yield* self.evaluateExpression(argNode))
|
||||
}
|
||||
}
|
||||
return args
|
||||
})
|
||||
}
|
||||
|
||||
// Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable
|
||||
// collection) mixing promise values and plain data - built inline, beforehand, via spread,
|
||||
// whatever - because tool calls already run eagerly on their own fibers; the combinators
|
||||
// only observe settlements. Joining is therefore sequential (no extra fibers) without
|
||||
// costing parallelism, and the concurrency cap stays where the work is: the fork semaphore.
|
||||
private invokePromiseMethod(
|
||||
ref: PromiseMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
if (ref.name === "resolve") {
|
||||
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
|
||||
// promise already fulfilled with the value.
|
||||
const value = args[0]
|
||||
return Effect.succeed(
|
||||
value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
|
||||
)
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
|
||||
}
|
||||
|
||||
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
|
||||
if (items === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
switch (ref.name) {
|
||||
case "all": {
|
||||
// Mark every promise element observed up-front (Promise.all handles all of its
|
||||
// members' failures, as in JS), then join in index order; the first failure rejects
|
||||
// the whole call while unrelated in-flight members keep running.
|
||||
const settles = items.map((item) =>
|
||||
item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const values: Array<unknown> = []
|
||||
for (const settle of settles) values.push(yield* settle)
|
||||
return values
|
||||
})
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
|
||||
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const { exit, promise } = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
|
||||
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
const thrown = raceInterrupted
|
||||
? new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
)
|
||||
: Cause.squash(exit.cause)
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(thrown),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
})
|
||||
}
|
||||
case "race": {
|
||||
if (items.length === 0) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
const observations = items.map((item, index) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
||||
const winner = yield* Effect.raceAll(observations)
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
|
||||
item.interrupted = true
|
||||
yield* Fiber.interrupt(item.fiber)
|
||||
}
|
||||
const winningItem = items[winner.index]
|
||||
return yield* self.unwrapPromiseExit(
|
||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
||||
winner.exit,
|
||||
node,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.suspend(() => {
|
||||
const savedScopes = self.scopes
|
||||
self.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||
// references another parameter resolves to that (uninitialized) param rather than
|
||||
// silently falling through to an outer binding of the same name - matching JS.
|
||||
const paramScope = self.currentScope()
|
||||
for (const parameter of fn.parameters) {
|
||||
for (const name of collectPatternNames(parameter)) {
|
||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||
}
|
||||
}
|
||||
for (const [index, parameter] of fn.parameters.entries()) {
|
||||
if (parameter.type === "RestElement") {
|
||||
yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
|
||||
break
|
||||
}
|
||||
yield* self.declarePattern(parameter, args[index], true, parameter)
|
||||
}
|
||||
|
||||
if (fn.body.type === "BlockStatement") {
|
||||
const result = yield* self.evaluateStatement(fn.body)
|
||||
return result.kind === "return" || result.kind === "value" ? result.value : undefined
|
||||
}
|
||||
|
||||
return yield* self.evaluateExpression(fn.body)
|
||||
})
|
||||
return run.pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
self.scopes = savedScopes
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private invokeIntrinsic(
|
||||
ref: IntrinsicReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
if (typeof ref.receiver === "string") {
|
||||
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (typeof ref.receiver === "number") {
|
||||
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return this.invokeArrayMethod(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxDate) {
|
||||
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxRegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxMap) {
|
||||
return this.invokeMapMethod(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxSet) {
|
||||
return this.invokeSetMethod(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
|
||||
// Runs a Map/Set callback (forEach) accepting a user function or a builtin coercion,
|
||||
// mirroring the array-method callback contract.
|
||||
private applyCollectionCallback(
|
||||
callback: unknown,
|
||||
name: string,
|
||||
node: AstNode,
|
||||
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
|
||||
if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
}
|
||||
return (callbackArgs) =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
private invokeMapMethod(
|
||||
target: SandboxMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
switch (name) {
|
||||
case "get":
|
||||
return Effect.succeed(target.map.get(args[0]))
|
||||
case "has":
|
||||
return Effect.succeed(target.map.has(args[0]))
|
||||
case "set":
|
||||
return Effect.sync(() => {
|
||||
target.map.set(args[0], args[1])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.map.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.map.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.map.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.map.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
||||
case "forEach": {
|
||||
const apply = this.applyCollectionCallback(args[0], "Map.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
// Snapshot iteration, matching the array-method callback contract.
|
||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private invokeSetMethod(
|
||||
target: SandboxSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
switch (name) {
|
||||
case "has":
|
||||
return Effect.succeed(target.set.has(args[0]))
|
||||
case "add":
|
||||
return Effect.sync(() => {
|
||||
target.set.add(args[0])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.set.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.set.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.set.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
||||
case "forEach": {
|
||||
const apply = this.applyCollectionCallback(args[0], "Set.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private invokeArrayMethod(
|
||||
target: Array<unknown>,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const optNumber = (value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number")
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
||||
return value
|
||||
}
|
||||
switch (name) {
|
||||
case "join": {
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
}
|
||||
const input = boundedData(target, "Array.join input") as Array<unknown>
|
||||
return Effect.succeed(
|
||||
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
|
||||
)
|
||||
}
|
||||
case "includes":
|
||||
if (args.length === 0 || args.length > 2)
|
||||
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
|
||||
case "indexOf":
|
||||
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
|
||||
case "lastIndexOf":
|
||||
return Effect.succeed(
|
||||
args[1] === undefined
|
||||
? target.lastIndexOf(args[0])
|
||||
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
|
||||
)
|
||||
case "at":
|
||||
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
|
||||
case "slice":
|
||||
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
|
||||
case "concat":
|
||||
return Effect.succeed(target.concat(...args))
|
||||
case "flat":
|
||||
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
|
||||
case "reverse":
|
||||
return Effect.succeed([...target].reverse())
|
||||
case "sort":
|
||||
case "toSorted":
|
||||
return this.sortArray(target, args[0], node)
|
||||
case "toReversed":
|
||||
return Effect.succeed([...target].reverse())
|
||||
case "with": {
|
||||
const index = optNumber(args[0], "index") ?? 0
|
||||
const resolved = index < 0 ? target.length + index : index
|
||||
if (resolved < 0 || resolved >= target.length) {
|
||||
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
|
||||
}
|
||||
const copied = [...target]
|
||||
copied[resolved] = args[1]
|
||||
return Effect.succeed(copied)
|
||||
}
|
||||
case "push": {
|
||||
// Validate before mutating (so no rollback is needed): inserting a container into
|
||||
// itself would create a cycle no later walk could survive.
|
||||
for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node)
|
||||
target.push(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "unshift": {
|
||||
for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node)
|
||||
target.unshift(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "pop":
|
||||
return Effect.succeed(target.pop())
|
||||
case "shift":
|
||||
return Effect.succeed(target.shift())
|
||||
case "splice": {
|
||||
// Mutates in place and returns the removed elements, exactly like JS: one argument
|
||||
// removes to the end, an undefined delete count removes nothing.
|
||||
if (args.length === 0) return Effect.succeed(target.splice(0, 0))
|
||||
const start = optNumber(args[0], "start") ?? 0
|
||||
if (args.length === 1) return Effect.succeed(target.splice(start))
|
||||
const deleteCount = optNumber(args[1], "delete count") ?? 0
|
||||
const inserted = args.slice(2)
|
||||
for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node)
|
||||
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
|
||||
}
|
||||
case "fill": {
|
||||
this.rejectCircularInsertion(target, args[0], "Array.fill result", node)
|
||||
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
|
||||
}
|
||||
case "copyWithin":
|
||||
return Effect.succeed(
|
||||
target.copyWithin(
|
||||
optNumber(args[0], "target index") ?? 0,
|
||||
optNumber(args[1], "start") ?? 0,
|
||||
optNumber(args[2], "end"),
|
||||
),
|
||||
)
|
||||
// keys/values/entries return arrays (not iterators), matching the Map/Set convention;
|
||||
// they work with for...of and spread either way.
|
||||
case "keys":
|
||||
return Effect.succeed(Array.from(target.keys()))
|
||||
case "values":
|
||||
return Effect.succeed([...target])
|
||||
case "entries":
|
||||
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
|
||||
}
|
||||
|
||||
const callback = args[0]
|
||||
if (!(callback instanceof CodeModeFunction) && !(callback instanceof CoercionFunction)) {
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
|
||||
}
|
||||
const self = this
|
||||
// Accept a user arrow function or a builtin coercion callable (Boolean/String/Number), so the
|
||||
// idioms `filter(Boolean)` / `map(String)` / `map(Number)` work as in JS. Coercions are
|
||||
// synchronous; only CodeModeFunctions can await tool calls.
|
||||
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: self.invokeFunction(callback, callbackArgs)
|
||||
return Effect.gen(function* () {
|
||||
// Iterate a snapshot taken at call time so a callback that mutates the array can't
|
||||
// self-extend the loop - matching JS, where elements appended during iteration are not visited.
|
||||
const items = target.slice()
|
||||
switch (name) {
|
||||
case "map": {
|
||||
const values: Array<unknown> = []
|
||||
for (const [index, item] of items.entries()) values.push(yield* apply([item, index, items]))
|
||||
return values
|
||||
}
|
||||
case "flatMap": {
|
||||
const values: Array<unknown> = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
const mapped = yield* apply([item, index, items])
|
||||
if (Array.isArray(mapped)) values.push(...mapped)
|
||||
else values.push(mapped)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "filter": {
|
||||
const values: Array<unknown> = []
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) values.push(item)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "find":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) return item
|
||||
}
|
||||
return undefined
|
||||
case "findIndex":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) return index
|
||||
}
|
||||
return -1
|
||||
case "some":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (yield* apply([item, index, items])) return true
|
||||
}
|
||||
return false
|
||||
case "every":
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (!(yield* apply([item, index, items]))) return false
|
||||
}
|
||||
return true
|
||||
case "forEach":
|
||||
for (const [index, item] of items.entries()) yield* apply([item, index, items])
|
||||
return undefined
|
||||
case "reduce": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = 0
|
||||
} else {
|
||||
if (items.length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
||||
accumulator = items[0]
|
||||
start = 1
|
||||
}
|
||||
for (let index = start; index < items.length; index += 1) {
|
||||
accumulator = yield* apply([accumulator, items[index], index, items])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "reduceRight": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = items.length - 1
|
||||
} else {
|
||||
if (items.length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
||||
accumulator = items[items.length - 1]
|
||||
start = items.length - 2
|
||||
}
|
||||
for (let index = start; index >= 0; index -= 1) {
|
||||
accumulator = yield* apply([accumulator, items[index], index, items])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "findLast":
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([items[index], index, items])) return items[index]
|
||||
}
|
||||
return undefined
|
||||
case "findLastIndex":
|
||||
for (let index = items.length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([items[index], index, items])) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
private sortArray(
|
||||
target: Array<unknown>,
|
||||
comparator: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
|
||||
}
|
||||
if (!(comparator instanceof CodeModeFunction)) {
|
||||
return Effect.sync(() =>
|
||||
[...target].sort((a, b) => {
|
||||
const left = coerceToString(a)
|
||||
const right = coerceToString(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
const self = this
|
||||
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, 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> = []
|
||||
let leftIndex = 0
|
||||
let rightIndex = 0
|
||||
while (leftIndex < left.length && rightIndex < right.length) {
|
||||
// Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host
|
||||
// crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element.
|
||||
const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
|
||||
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
|
||||
else merged.push(right[rightIndex++])
|
||||
}
|
||||
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
|
||||
})
|
||||
}
|
||||
// Per spec, undefined elements sort to the end and the comparator is never called on them.
|
||||
const defined = target.filter((item) => item !== undefined)
|
||||
const undefinedCount = target.length - defined.length
|
||||
return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
|
||||
}
|
||||
|
||||
private evaluateObjectExpression(node: AstNode): Effect.Effect<Record<string, unknown>, unknown, R> {
|
||||
const objectValue: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
||||
const properties = getArray(node, "properties")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const propertyValue of properties) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||
// JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common
|
||||
// `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values
|
||||
// (Date/RegExp/Map/Set) have no own enumerable properties in JS, so they are no-ops too.
|
||||
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object spread requires a data object in CodeMode.",
|
||||
property,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) {
|
||||
if (isBlockedMember(key))
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
|
||||
objectValue[key] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (property.type !== "Property") {
|
||||
throw new InterpreterRuntimeError("Only standard object properties are supported.", property)
|
||||
}
|
||||
|
||||
if (getString(property, "kind") !== "init") {
|
||||
throw new InterpreterRuntimeError("Only init object properties are supported.", property)
|
||||
}
|
||||
|
||||
const keyNode = getNode(property, "key")
|
||||
const valueNode = getNode(property, "value")
|
||||
const computed = getBoolean(property, "computed")
|
||||
|
||||
let key: PropertyKey
|
||||
|
||||
if (computed) {
|
||||
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
|
||||
} else if (keyNode.type === "Identifier") {
|
||||
key = getString(keyNode, "name")
|
||||
} else if (keyNode.type === "Literal") {
|
||||
key = self.toPropertyKey(keyNode.value, keyNode)
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
|
||||
}
|
||||
|
||||
if (isBlockedMember(String(key))) {
|
||||
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode)
|
||||
}
|
||||
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
|
||||
}
|
||||
|
||||
return objectValue
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateArrayExpression(node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const elements = getArray(node, "elements")
|
||||
const values: Array<unknown> = []
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const elementValue of elements) {
|
||||
if (elementValue === null) {
|
||||
values.push(undefined)
|
||||
continue
|
||||
}
|
||||
const element = asNode(elementValue, "elements")
|
||||
if (element.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
|
||||
const items = spreadItems(spread)
|
||||
if (items === undefined)
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array spread requires an array, string, Map, or Set in CodeMode.",
|
||||
element,
|
||||
)
|
||||
values.push(...items)
|
||||
} else {
|
||||
values.push(yield* self.evaluateExpression(element))
|
||||
}
|
||||
}
|
||||
return values
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode): Effect.Effect<string, unknown, R> {
|
||||
const quasis = getArray(node, "quasis")
|
||||
const expressions = getArray(node, "expressions")
|
||||
|
||||
let output = ""
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (let index = 0; index < quasis.length; index += 1) {
|
||||
const quasi = asNode(quasis[index], "quasis")
|
||||
const rawValue = quasi.value
|
||||
|
||||
if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") {
|
||||
throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
|
||||
}
|
||||
|
||||
output += rawValue.cooked
|
||||
|
||||
if (index < expressions.length) {
|
||||
const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions"))
|
||||
// The preserving checkpoint keeps sandbox values intact, so coerceToString renders
|
||||
// them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk.
|
||||
output += coerceToString(boundedData(raw, "Template interpolation"))
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateConditionalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) =>
|
||||
this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")),
|
||||
)
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
|
||||
// `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation
|
||||
// so compound assignment inherits the same coercion semantics (Dates, data objects, ...).
|
||||
// Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=)
|
||||
// short-circuit and are handled by evaluateLogicalAssignment before reaching here.
|
||||
if (!compoundOperators.has(operator)) {
|
||||
throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
|
||||
}
|
||||
|
||||
private getMemberReference(
|
||||
node: AstNode,
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
| PromiseMethodReference
|
||||
| IntrinsicReference
|
||||
| GlobalMethodReference
|
||||
| ComputedValue
|
||||
| typeof OptionalShortCircuit
|
||||
| undefined,
|
||||
unknown,
|
||||
R
|
||||
> {
|
||||
const objectNode = getNode(node, "object")
|
||||
const propertyNode = getNode(node, "property")
|
||||
const computed = getBoolean(node, "computed")
|
||||
const optional = node.optional === true
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const objectValue = yield* self.evaluateExpression(objectNode)
|
||||
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit
|
||||
|
||||
const key = computed
|
||||
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
: propertyNode.type === "Identifier"
|
||||
? getString(propertyNode, "name")
|
||||
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
|
||||
if (objectValue instanceof PromiseNamespace) {
|
||||
if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) {
|
||||
return new PromiseMethodReference(key as PromiseMethodName)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`,
|
||||
propertyNode,
|
||||
)
|
||||
}
|
||||
|
||||
if (objectValue instanceof GlobalNamespace) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
|
||||
propertyNode,
|
||||
)
|
||||
}
|
||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||
}
|
||||
return new GlobalMethodReference(objectValue.name, key)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return new ComputedValue(objectValue.length)
|
||||
if (typeof key === "number") return new ComputedValue(objectValue[key])
|
||||
if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)])
|
||||
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
// Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`),
|
||||
// instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string
|
||||
// tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a
|
||||
// real string still reaches here.) Only the method allowlist above yields callables.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "number") {
|
||||
if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
// Unknown property on a number reads as `undefined`, matching JS, rather than throwing.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
// Number / String expose a small allowlist of statics; everything else stays opaque.
|
||||
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
|
||||
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||
return new ComputedValue((Number as unknown as Record<string, number>)[key])
|
||||
}
|
||||
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
||||
}
|
||||
|
||||
// Sandbox value types expose their method/property allowlists; any other key reads as
|
||||
// `undefined`, consistent with unknown-property reads on strings/numbers/arrays.
|
||||
if (objectValue instanceof SandboxDate) {
|
||||
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxRegExp) {
|
||||
if (typeof key === "string" && regexpProperties.has(key)) {
|
||||
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
|
||||
}
|
||||
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxMap) {
|
||||
if (key === "size") return new ComputedValue(objectValue.map.size)
|
||||
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxSet) {
|
||||
if (key === "size") return new ComputedValue(objectValue.set.size)
|
||||
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
|
||||
// reading `undefined` here would hide the missing await, so both paths get an explicit,
|
||||
// await-hinting error instead of the forgiving unknown-property fallthrough.
|
||||
if (objectValue instanceof SandboxPromise) {
|
||||
if (key === "then" || key === "catch" || key === "finally") {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`,
|
||||
propertyNode,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
"This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.",
|
||||
objectNode,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
if (isRuntimeReference(objectValue)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"CodeMode runtime references are opaque and do not expose properties.",
|
||||
objectNode,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof objectValue !== "object" || objectValue === null) {
|
||||
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
|
||||
}
|
||||
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode)
|
||||
}
|
||||
|
||||
if (Array.isArray(objectValue)) {
|
||||
if (
|
||||
key !== "length" &&
|
||||
!(typeof key === "string" && arrayMethods.has(key)) &&
|
||||
typeof key !== "number" &&
|
||||
!/^\d+$/.test(key)
|
||||
) {
|
||||
// Own non-index properties read through (match results carry index/groups); like JS,
|
||||
// they are readable in place and dropped by JSON at data boundaries.
|
||||
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
|
||||
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
|
||||
}
|
||||
// Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`),
|
||||
// instead of throwing - so defensive access under optional chaining behaves as expected.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
return { target: objectValue, key }
|
||||
}
|
||||
|
||||
return { target: objectValue as SafeObject, key }
|
||||
})
|
||||
}
|
||||
|
||||
private readMember(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.map(this.getMemberReference(node), (reference) => {
|
||||
if (reference === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if (reference instanceof ComputedValue) return reference.value
|
||||
if (
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseMethodReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference
|
||||
)
|
||||
return reference
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
return new IntrinsicReference(reference.target, reference.key)
|
||||
}
|
||||
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
|
||||
}
|
||||
return reference.target[String(reference.key)]
|
||||
})
|
||||
}
|
||||
|
||||
private writeMember(node: AstNode, value: unknown): Effect.Effect<unknown, unknown, R> {
|
||||
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
|
||||
}
|
||||
|
||||
// Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression
|
||||
// runs once), then lets `compute` decide whether to write - enabling compound assignment,
|
||||
// updates, plain writes, and short-circuiting logical assignment to share one safe path.
|
||||
private modifyMember(
|
||||
node: AstNode,
|
||||
compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
if (
|
||||
reference === OptionalShortCircuit ||
|
||||
reference instanceof ComputedValue ||
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseMethodReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
|
||||
}
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (reference.key === "length")
|
||||
throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
|
||||
}
|
||||
}
|
||||
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
|
||||
const current = (reference.target as Record<PropertyKey, unknown>)[key]
|
||||
const { write, next, result } = yield* compute(current)
|
||||
if (write) self.assignToReference(reference, key, next, node)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// Rejects inserting a value that (transitively) contains the container it is being inserted
|
||||
// into - the mutation that would create a circular structure no later walk could survive.
|
||||
private rejectCircularInsertion(
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void {
|
||||
if (value === container)
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
|
||||
seen.add(value)
|
||||
const items = Array.isArray(value) ? value : Object.values(value)
|
||||
for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen)
|
||||
seen.delete(value)
|
||||
}
|
||||
|
||||
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
|
||||
if (Array.isArray(reference.target)) {
|
||||
const target = reference.target
|
||||
const index = key as number
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array assignment index must be a non-negative integer.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
this.rejectCircularInsertion(target, next, "Array assignment result", node)
|
||||
target[index] = next
|
||||
return
|
||||
}
|
||||
const target = reference.target as SafeObject
|
||||
const objectKey = key as string
|
||||
this.rejectCircularInsertion(target, next, "Object assignment result", node)
|
||||
target[objectKey] = next
|
||||
}
|
||||
|
||||
private toPropertyKey(value: unknown, node: AstNode): string | number {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
|
||||
}
|
||||
|
||||
private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
||||
const scope = this.currentScope()
|
||||
|
||||
// A pre-seeded parameter slot (initialized === false) is being bound for the first time;
|
||||
// anything else already present is a genuine duplicate declaration.
|
||||
const existing = scope.get(name)
|
||||
if (existing && existing.initialized !== false) {
|
||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||
}
|
||||
|
||||
scope.set(name, { mutable, value, initialized: true })
|
||||
}
|
||||
|
||||
private getIdentifierValue(name: string, node: AstNode): unknown {
|
||||
const binding = this.resolveBinding(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
// A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ.
|
||||
if (binding.initialized === false) {
|
||||
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
return binding.value
|
||||
}
|
||||
|
||||
private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown {
|
||||
const binding = this.resolveBinding(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
if (!binding.mutable) {
|
||||
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
|
||||
}
|
||||
|
||||
binding.value = value
|
||||
return value
|
||||
}
|
||||
|
||||
private resolveBinding(name: string): Binding | undefined {
|
||||
for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
|
||||
const scope = this.scopes[index]
|
||||
const binding = scope?.get(name)
|
||||
|
||||
if (binding) {
|
||||
return binding
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private currentScope(): Map<string, Binding> {
|
||||
const scope = this.scopes[this.scopes.length - 1]
|
||||
|
||||
if (!scope) {
|
||||
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
private pushScope(): void {
|
||||
this.scopes.push(new Map())
|
||||
}
|
||||
|
||||
private popScope(): void {
|
||||
this.scopes.pop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes one Effect-native CodeMode program without constructing a reusable runtime.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = yield* CodeMode.execute({
|
||||
* tools: { lookup },
|
||||
* code: `return await tools.lookup({ id: "order_42" })`,
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
|
||||
const hooks = {
|
||||
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
|
||||
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
|
||||
}
|
||||
const tools = ToolRuntime.make(
|
||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
||||
limits.maxToolCalls,
|
||||
hooks,
|
||||
searchIndex,
|
||||
)
|
||||
const logs: Array<string> = []
|
||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||
|
||||
if (options.code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
||||
toolCalls: tools.calls,
|
||||
})
|
||||
}
|
||||
|
||||
const operation = Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||
return {
|
||||
ok: true,
|
||||
value: result,
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies ExecuteResult
|
||||
}).pipe((program) => {
|
||||
const timeoutMs = limits.timeoutMs
|
||||
if (timeoutMs === undefined) return program
|
||||
return program.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeoutMs,
|
||||
orElse: () =>
|
||||
Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies ExecuteResult),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return operation.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.interrupt
|
||||
: Effect.succeed({
|
||||
ok: false,
|
||||
error: normalizeError(Cause.squash(cause)),
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies ExecuteResult),
|
||||
),
|
||||
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
|
||||
)
|
||||
}
|
||||
|
||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
|
||||
|
||||
// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte
|
||||
// sequence decodes to a replacement character, which is dropped).
|
||||
const utf8Truncate = (value: string, maxBytes: number): string => {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
if (bytes.byteLength <= maxBytes) return value
|
||||
const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes)))
|
||||
return text.endsWith("\uFFFD") ? text.slice(0, -1) : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`.
|
||||
* Oversized values are replaced by their truncated serialized text with an explanatory marker,
|
||||
* and logs are kept from the start until the remaining budget is exhausted. Truncation never
|
||||
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
|
||||
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
|
||||
*/
|
||||
const boundOutput = (result: ExecuteResult, maxOutputBytes: number): ExecuteResult => {
|
||||
let truncated = false
|
||||
|
||||
let value: DataValue = null
|
||||
let valueBytes = 0
|
||||
if (result.ok) {
|
||||
const serialized = JSON.stringify(result.value) ?? "null"
|
||||
const bytes = utf8ByteLength(serialized)
|
||||
if (bytes > maxOutputBytes) {
|
||||
truncated = true
|
||||
value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]`
|
||||
valueBytes = maxOutputBytes
|
||||
} else {
|
||||
value = result.value
|
||||
valueBytes = bytes
|
||||
}
|
||||
}
|
||||
|
||||
const logs = result.logs ?? []
|
||||
const kept: Array<string> = []
|
||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
||||
let logBytes = 0
|
||||
for (const line of logs) {
|
||||
const lineBytes = utf8ByteLength(line) + 1
|
||||
if (logBytes + lineBytes > logBudget) break
|
||||
logBytes += lineBytes
|
||||
kept.push(line)
|
||||
}
|
||||
if (kept.length < logs.length) {
|
||||
truncated = true
|
||||
kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`)
|
||||
}
|
||||
|
||||
if (!truncated) return result
|
||||
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
||||
return result.ok
|
||||
? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
}
|
||||
|
||||
/** Executes one Effect-native CodeMode program without constructing a reusable runtime. */
|
||||
export const execute = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
): Effect.Effect<ExecuteResult, never, Services<Tools>> => {
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
return executeWithLimits(options, resolveExecutionLimits(options.limits), ToolRuntime.searchIndex(tools))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Effect-native runtime over explicit, schema-described tools.
|
||||
*
|
||||
* Use `execute` for host-driven execution or `agentTool` to expose one confined code tool to an
|
||||
* agent framework. Tool requirements remain in the returned Effect environment.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const runtime = CodeMode.make({ tools: { orders: { lookup } } })
|
||||
* const code = runtime.agentTool()
|
||||
* ```
|
||||
*/
|
||||
/** Creates an Effect-native runtime over explicit, schema-described tools. */
|
||||
export const make = <const Tools extends Record<string, unknown> = {}>(
|
||||
options: CodeModeOptions<Tools> = {} as CodeModeOptions<Tools>,
|
||||
): CodeModeRuntime<Services<Tools>> => {
|
||||
options: Options<Tools> = {} as Options<Tools>,
|
||||
): Runtime<Services<Tools>> => {
|
||||
const tools = (options.tools ?? {}) as HostTools<Services<Tools>>
|
||||
ToolRuntime.assertValidTools(tools)
|
||||
const limits = resolveExecutionLimits(options.limits)
|
||||
const discovery = ToolRuntime.discoveryPlan(tools, options.discovery?.maxInlineCatalogTokens)
|
||||
const executeProgram = (code: string) => executeWithLimits<Tools>({ ...options, code }, limits, discovery.searchIndex)
|
||||
const catalog = discovery.catalog
|
||||
const instructions = discovery.instructions
|
||||
const prepared = ToolRuntime.prepare(tools, options.discovery?.catalogBudget)
|
||||
|
||||
return {
|
||||
catalog: () => catalog,
|
||||
instructions: () => instructions,
|
||||
agentTool: () => ({
|
||||
name: "code",
|
||||
description: instructions,
|
||||
input: ExecuteInputSchema,
|
||||
output: ExecuteResultSchema,
|
||||
execute: ({ code }) => executeProgram(code),
|
||||
}),
|
||||
execute: executeProgram,
|
||||
catalog: () => prepared.catalog,
|
||||
instructions: () => prepared.instructions,
|
||||
execute: (code) => executeWithLimits<Tools>({ ...options, code }, limits, prepared.searchIndex),
|
||||
}
|
||||
}
|
||||
|
||||
/** Constructors for one-shot and reusable CodeMode execution. */
|
||||
export const CodeMode = { make, execute }
|
||||
|
||||
@@ -1,21 +1,4 @@
|
||||
export { ToolError, CodeMode, ExecuteInputSchema, ExecuteResultSchema, toolError } from "./codemode.js"
|
||||
export { Tool } from "./tool.js"
|
||||
export type { Definition as ToolDefinition, JsonSchema, ToolSchema } from "./tool.js"
|
||||
export type { ToolCallEnded, ToolCallHooks } from "./tool-runtime.js"
|
||||
export type {
|
||||
AgentToolDefinition,
|
||||
CodeModeOptions,
|
||||
CodeModeRuntime,
|
||||
DataValue,
|
||||
Diagnostic,
|
||||
DiagnosticKind,
|
||||
DiscoveryOptions,
|
||||
ExecuteFailure,
|
||||
ExecuteOptions,
|
||||
ExecuteResult,
|
||||
ExecuteSuccess,
|
||||
ExecutionLimits,
|
||||
ToolCall,
|
||||
ToolCallStarted,
|
||||
ToolDescription,
|
||||
} from "./codemode.js"
|
||||
export * as CodeMode from "./codemode.js"
|
||||
export * as Tool from "./tool.js"
|
||||
export * as OpenAPI from "./openapi/index.js"
|
||||
export { ToolError, toolError } from "./tool-error.js"
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { SafeObject } from "../tool-runtime.js"
|
||||
import type { SandboxURL } from "../values.js"
|
||||
|
||||
export type SourcePosition = {
|
||||
line: number
|
||||
column: number
|
||||
}
|
||||
|
||||
export type SourceLocation = {
|
||||
start: SourcePosition
|
||||
end: SourcePosition
|
||||
}
|
||||
|
||||
export type AstNode = {
|
||||
type: string
|
||||
loc?: SourceLocation
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export type ProgramNode = AstNode & {
|
||||
type: "Program"
|
||||
body: Array<AstNode>
|
||||
}
|
||||
|
||||
export type Binding = {
|
||||
mutable: boolean
|
||||
value: unknown
|
||||
initialized?: boolean
|
||||
}
|
||||
|
||||
export type StatementResult =
|
||||
| { kind: "none" }
|
||||
| { kind: "value"; value: unknown }
|
||||
| { kind: "return"; value: unknown }
|
||||
| { kind: "break" }
|
||||
| { kind: "continue" }
|
||||
|
||||
export type MemberReference = {
|
||||
target: SafeObject | Array<unknown> | SandboxURL
|
||||
key: string | number
|
||||
}
|
||||
|
||||
export class CodeModeFunction {
|
||||
constructor(
|
||||
readonly parameters: ReadonlyArray<AstNode>,
|
||||
readonly body: AstNode,
|
||||
readonly capturedScopes: ReadonlyArray<Map<string, Binding>>,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class IntrinsicReference {
|
||||
constructor(
|
||||
readonly receiver: unknown,
|
||||
readonly name: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class ComputedValue {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
export class PromiseNamespace {}
|
||||
|
||||
export type PromiseMethodName = "all" | "allSettled" | "race" | "resolve" | "reject"
|
||||
|
||||
export class PromiseMethodReference {
|
||||
constructor(readonly name: PromiseMethodName) {}
|
||||
}
|
||||
|
||||
export type GlobalNamespaceName =
|
||||
| "Object"
|
||||
| "Math"
|
||||
| "JSON"
|
||||
| "Array"
|
||||
| "console"
|
||||
| "Date"
|
||||
| "RegExp"
|
||||
| "Map"
|
||||
| "Set"
|
||||
| "URL"
|
||||
| "URLSearchParams"
|
||||
|
||||
export class GlobalNamespace {
|
||||
constructor(readonly name: GlobalNamespaceName) {}
|
||||
}
|
||||
|
||||
export class GlobalMethodReference {
|
||||
constructor(
|
||||
readonly namespace: GlobalNamespaceName | "Number" | "String",
|
||||
readonly name: string,
|
||||
) {}
|
||||
}
|
||||
|
||||
export class CoercionFunction {
|
||||
constructor(readonly name: "Number" | "String" | "Boolean" | "parseInt" | "parseFloat") {}
|
||||
}
|
||||
|
||||
export class UriFunction {
|
||||
constructor(readonly name: "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent") {}
|
||||
}
|
||||
|
||||
export class ProgramThrow {
|
||||
constructor(readonly value: unknown) {}
|
||||
}
|
||||
|
||||
export class ErrorConstructorReference {
|
||||
constructor(readonly name: string) {}
|
||||
}
|
||||
|
||||
export type DiagnosticKind =
|
||||
| "ParseError"
|
||||
| "UnsupportedSyntax"
|
||||
| "UnknownTool"
|
||||
| "InvalidToolInput"
|
||||
| "InvalidToolOutput"
|
||||
| "InvalidDataValue"
|
||||
| "ToolCallLimitExceeded"
|
||||
| "TimeoutExceeded"
|
||||
| "ToolFailure"
|
||||
| "ExecutionFailure"
|
||||
|
||||
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
|
||||
|
||||
export const supportedSyntaxMessage =
|
||||
"Supported orchestration syntax: tools.* calls (they return promises - resolve them with await), data literals, destructuring, optional chaining, template literals, conditionals, switch, loops (incl. for...of and for...in over object/array/tools keys), arrow functions, spread, try/catch, array methods (map/filter/find/findIndex/some/every/reduce/flatMap/forEach/sort/slice/concat/indexOf/lastIndexOf/at/flat/reverse/includes/join), string methods (incl. match/matchAll/replace/split with regular expressions), Date/RegExp/Map/Set/URL/URLSearchParams, URI encoding helpers, Object/Math/JSON helpers, captured console.log/warn/error/dir/table, and Promise.all/allSettled/race/resolve/reject over arrays mixing promises and plain values for parallel tool calls (promise chaining with .then/.catch is not supported - use await with try/catch)."
|
||||
|
||||
export class InterpreterRuntimeError extends Error {
|
||||
readonly node?: AstNode
|
||||
errorName: string = "Error"
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
node?: AstNode,
|
||||
readonly kind: DiagnosticKind = "ExecutionFailure",
|
||||
readonly suggestions?: ReadonlyArray<string>,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "InterpreterRuntimeError"
|
||||
if (node) this.node = node
|
||||
}
|
||||
|
||||
as(errorName: string): this {
|
||||
this.errorName = errorName
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
|
||||
new InterpreterRuntimeError(
|
||||
`Syntax '${kind}' is not supported in CodeMode. ${supportedSyntaxMessage}`,
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null
|
||||
|
||||
export const asNode = (value: unknown, context: string): AstNode => {
|
||||
if (!isRecord(value) || typeof value.type !== "string") {
|
||||
throw new InterpreterRuntimeError(`Invalid AST node while reading ${context}.`)
|
||||
}
|
||||
return value as AstNode
|
||||
}
|
||||
|
||||
export const getArray = (node: AstNode, key: string): Array<unknown> => {
|
||||
const value = node[key]
|
||||
if (!Array.isArray(value)) throw new InterpreterRuntimeError(`Expected '${key}' to be an array.`, node)
|
||||
return value
|
||||
}
|
||||
|
||||
export const getString = (node: AstNode, key: string): string => {
|
||||
const value = node[key]
|
||||
if (typeof value !== "string") throw new InterpreterRuntimeError(`Expected '${key}' to be a string.`, node)
|
||||
return value
|
||||
}
|
||||
|
||||
export const getBoolean = (node: AstNode, key: string): boolean => {
|
||||
const value = node[key]
|
||||
if (typeof value !== "boolean") throw new InterpreterRuntimeError(`Expected '${key}' to be a boolean.`, node)
|
||||
return value
|
||||
}
|
||||
|
||||
export const getOptionalNode = (node: AstNode, key: string): AstNode | undefined => {
|
||||
const value = node[key]
|
||||
if (value === undefined || value === null) return undefined
|
||||
return asNode(value, key)
|
||||
}
|
||||
|
||||
export const getNode = (node: AstNode, key: string): AstNode => asNode(node[key], key)
|
||||
|
||||
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),
|
||||
})
|
||||
|
||||
export const formatLocation = (node?: AstNode): string => {
|
||||
if (!node?.loc) return ""
|
||||
const location = sourceLocation(node)
|
||||
return ` (line ${location.line}, col ${location.column})`
|
||||
}
|
||||
@@ -0,0 +1,3492 @@
|
||||
import { parse } from "acorn"
|
||||
import { Cause, Effect, Exit, Fiber, Semaphore } from "effect"
|
||||
import { DiagnosticCategory, ModuleKind, ScriptTarget, flattenDiagnosticMessageText, transpileModule } from "typescript"
|
||||
import {
|
||||
copyIn,
|
||||
copyOut,
|
||||
isBlockedMember,
|
||||
ToolReference,
|
||||
ToolRuntime,
|
||||
ToolRuntimeError,
|
||||
type HostTools,
|
||||
type SafeObject,
|
||||
type Services,
|
||||
} from "../tool-runtime.js"
|
||||
import { ToolError } from "../tool-error.js"
|
||||
import type {
|
||||
DataValue,
|
||||
Diagnostic,
|
||||
DiagnosticKind,
|
||||
ExecuteOptions,
|
||||
ResolvedExecutionLimits,
|
||||
Result,
|
||||
} from "../codemode.js"
|
||||
import {
|
||||
type AstNode,
|
||||
asNode,
|
||||
type Binding,
|
||||
CodeModeFunction,
|
||||
CoercionFunction,
|
||||
ComputedValue,
|
||||
ErrorConstructorReference,
|
||||
GlobalMethodReference,
|
||||
GlobalNamespace,
|
||||
type GlobalNamespaceName,
|
||||
formatLocation,
|
||||
getArray,
|
||||
getBoolean,
|
||||
getNode,
|
||||
getOptionalNode,
|
||||
getString,
|
||||
IntrinsicReference,
|
||||
InterpreterRuntimeError,
|
||||
isRecord,
|
||||
type MemberReference,
|
||||
OptionalShortCircuit,
|
||||
PromiseMethodReference,
|
||||
type PromiseMethodName,
|
||||
PromiseNamespace,
|
||||
ProgramThrow,
|
||||
type ProgramNode,
|
||||
type StatementResult,
|
||||
sourceLocation,
|
||||
supportedSyntaxMessage,
|
||||
unsupportedSyntax,
|
||||
UriFunction,
|
||||
} from "./model.js"
|
||||
import { arrayMethods, mapMethods, setMethods, spreadItems } from "../stdlib/collections.js"
|
||||
import { consoleMethods, MAX_CONSOLE_DEPTH } from "../stdlib/console.js"
|
||||
import { dateMethods, dateStatics, invokeDateMethod, invokeDateStatic } from "../stdlib/date.js"
|
||||
import { invokeJsonMethod } from "../stdlib/json.js"
|
||||
import { invokeMathMethod, mathConstants } from "../stdlib/math.js"
|
||||
import {
|
||||
invokeNumberMethod,
|
||||
invokeNumberStatic,
|
||||
numberConstants,
|
||||
numberMethods,
|
||||
numberStatics,
|
||||
} from "../stdlib/number.js"
|
||||
import { invokeObjectMethod } from "../stdlib/object.js"
|
||||
import { promiseStatics, TOOL_CALL_CONCURRENCY } from "../stdlib/promise.js"
|
||||
import {
|
||||
escapeRegexHint,
|
||||
invokeRegExpMethod,
|
||||
matchToValue,
|
||||
regexpMethods,
|
||||
regexpProperties,
|
||||
regexFailureReason,
|
||||
toHostRegex,
|
||||
} from "../stdlib/regexp.js"
|
||||
import { invokeStringStatic, stringMethods, stringStatics } from "../stdlib/string.js"
|
||||
import {
|
||||
urlMethods,
|
||||
urlProperties,
|
||||
urlSearchParamsMethods,
|
||||
urlStatics,
|
||||
urlWritableProperties,
|
||||
invokeUriFunction,
|
||||
invokeURLMethod,
|
||||
invokeURLStatic,
|
||||
uriArgument,
|
||||
urlArgument,
|
||||
} from "../stdlib/url.js"
|
||||
import {
|
||||
boundedData,
|
||||
coerceToNumber,
|
||||
coerceToString,
|
||||
compoundOperators,
|
||||
createErrorValue,
|
||||
errorBrandName,
|
||||
errorConstructors,
|
||||
invokeCoercion,
|
||||
valueConstructors,
|
||||
} from "../stdlib/value.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxPromise,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
} from "../values.js"
|
||||
|
||||
const parseProgram = (code: string): ProgramNode => {
|
||||
const transpiled = transpileModule(`async function __codemode__() {\n${code}\n}`, {
|
||||
reportDiagnostics: true,
|
||||
compilerOptions: {
|
||||
target: ScriptTarget.ESNext,
|
||||
module: ModuleKind.ESNext,
|
||||
},
|
||||
})
|
||||
const diagnostic = transpiled.diagnostics?.find((item) => item.category === DiagnosticCategory.Error)
|
||||
|
||||
if (diagnostic) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Failed to parse TypeScript: ${flattenDiagnosticMessageText(diagnostic.messageText, "\n")}`,
|
||||
undefined,
|
||||
"ParseError",
|
||||
)
|
||||
}
|
||||
|
||||
const bodyStart = transpiled.outputText.indexOf("{") + 1
|
||||
const bodyEnd = transpiled.outputText.lastIndexOf("}")
|
||||
const executableCode = transpiled.outputText.slice(bodyStart, bodyEnd)
|
||||
const parsed = parse(executableCode, {
|
||||
ecmaVersion: "latest",
|
||||
sourceType: "script",
|
||||
allowReturnOutsideFunction: true,
|
||||
allowAwaitOutsideFunction: true,
|
||||
locations: true,
|
||||
}) as unknown
|
||||
|
||||
if (!isRecord(parsed) || parsed.type !== "Program" || !Array.isArray(parsed.body)) {
|
||||
throw new InterpreterRuntimeError("Failed to parse script as a Program node.")
|
||||
}
|
||||
|
||||
return parsed as ProgramNode
|
||||
}
|
||||
|
||||
const publicErrorMessage = (message: string): string =>
|
||||
message.replace(/\/(?:Users|home|private|tmp|var\/folders)\/[^\s"'`]+/g, "<redacted-path>")
|
||||
|
||||
const normalizeError = (error: unknown): Diagnostic => {
|
||||
if (error instanceof InterpreterRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: `${error.message}${formatLocation(error.node)}`,
|
||||
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
|
||||
...(error.suggestions ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolRuntimeError) {
|
||||
return {
|
||||
kind: error.kind,
|
||||
message: error.message,
|
||||
...(error.suggestions.length > 0 ? { suggestions: error.suggestions } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof ToolError) {
|
||||
return { kind: "ToolFailure", message: publicErrorMessage(error.message) }
|
||||
}
|
||||
|
||||
if (error instanceof ProgramThrow) {
|
||||
const value = error.value
|
||||
let message: string
|
||||
if (containsRuntimeReference(value)) {
|
||||
// A thrown tool/function reference must not leak its internal structure.
|
||||
message = "a non-data value"
|
||||
} else if (typeof value === "string") {
|
||||
message = value
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === "object" &&
|
||||
typeof (value as { message?: unknown }).message === "string"
|
||||
) {
|
||||
message = (value as { message: string }).message
|
||||
} else {
|
||||
try {
|
||||
message = JSON.stringify(copyOut(value)) ?? String(value)
|
||||
} catch {
|
||||
message = String(value)
|
||||
}
|
||||
}
|
||||
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
|
||||
}
|
||||
|
||||
if (error instanceof RangeError && /call stack|recursion/i.test(error.message)) {
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: "Execution exceeded the maximum nesting depth.",
|
||||
}
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
kind: error.name === "SyntaxError" ? "ParseError" : "ExecutionFailure",
|
||||
message: publicErrorMessage(error.message),
|
||||
}
|
||||
}
|
||||
|
||||
// A non-Error thrown by a host tool (raw string / number / Symbol) still routes through
|
||||
// path redaction so filesystem paths can never leak through the catch-all branch.
|
||||
return {
|
||||
kind: "ExecutionFailure",
|
||||
message: publicErrorMessage(String(error)),
|
||||
}
|
||||
}
|
||||
|
||||
// Shared by catch bindings, Promise.allSettled rejection reasons, and Promise.race losers.
|
||||
const caughtErrorValue = (thrown: unknown): unknown => {
|
||||
if (thrown instanceof ProgramThrow) return thrown.value
|
||||
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(thrown.errorName, thrown.message)
|
||||
const name = thrown instanceof Error && errorConstructors.has(thrown.name) ? thrown.name : "Error"
|
||||
return createErrorValue(name, normalizeError(thrown).message)
|
||||
}
|
||||
|
||||
const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalNamespace ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof SandboxPromise ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof UriFunction ||
|
||||
value instanceof ErrorConstructorReference ||
|
||||
isSandboxValue(value)
|
||||
|
||||
const containsRuntimeReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsRuntimeReference(item, seen))
|
||||
: Object.values(value).some((item) => containsRuntimeReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// Like containsRuntimeReference, but sandbox standard-library values count as data:
|
||||
// operators and switch treat them as ordinary object operands (identity equality, ToPrimitive
|
||||
// coercion) rather than rejecting them as opaque interpreter machinery.
|
||||
const containsOpaqueReference = (value: unknown, seen = new Set<object>()): boolean => {
|
||||
if (isSandboxValue(value)) return false
|
||||
if (isRuntimeReference(value)) return true
|
||||
if (value === null || typeof value !== "object") return false
|
||||
if (seen.has(value)) return false
|
||||
seen.add(value)
|
||||
const contains = Array.isArray(value)
|
||||
? value.some((item) => containsOpaqueReference(item, seen))
|
||||
: Object.values(value).some((item) => containsOpaqueReference(item, seen))
|
||||
seen.delete(value)
|
||||
return contains
|
||||
}
|
||||
|
||||
// `typeof` never throws in JS; map every interpreter value to its JS-visible category.
|
||||
// A SandboxPromise falls through to the final `typeof value` and reports "object", exactly
|
||||
// like a real JS promise.
|
||||
const typeofValue = (value: unknown): string => {
|
||||
if (
|
||||
value instanceof CodeModeFunction ||
|
||||
value instanceof CoercionFunction ||
|
||||
value instanceof IntrinsicReference ||
|
||||
value instanceof GlobalMethodReference ||
|
||||
value instanceof PromiseMethodReference ||
|
||||
value instanceof PromiseNamespace ||
|
||||
value instanceof ErrorConstructorReference
|
||||
)
|
||||
return "function"
|
||||
if (value instanceof UriFunction) return "function"
|
||||
if (value instanceof ToolReference) return value.path.length > 0 ? "function" : "object"
|
||||
if (value instanceof GlobalNamespace) {
|
||||
return value.name === "Math" || value.name === "JSON" || value.name === "console" ? "object" : "function"
|
||||
}
|
||||
return typeof value
|
||||
}
|
||||
|
||||
// `x instanceof C` against the constructors CodeMode knows. Like `typeof`, it observes any
|
||||
// left-hand value (opaque references included) without coercing it. Error checks use the
|
||||
// error brand: `instanceof Error` accepts every branded error; a specific error type matches
|
||||
// its own brand only (as in JS, where TypeError instances are also Error instances).
|
||||
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
|
||||
if (rhs instanceof ErrorConstructorReference) {
|
||||
const brand = errorBrandName(lhs)
|
||||
return brand !== undefined && (rhs.name === "Error" || brand === rhs.name)
|
||||
}
|
||||
if (rhs instanceof GlobalNamespace) {
|
||||
switch (rhs.name) {
|
||||
case "Date":
|
||||
return lhs instanceof SandboxDate
|
||||
case "RegExp":
|
||||
return lhs instanceof SandboxRegExp
|
||||
case "Map":
|
||||
return lhs instanceof SandboxMap
|
||||
case "Set":
|
||||
return lhs instanceof SandboxSet
|
||||
case "URL":
|
||||
return lhs instanceof SandboxURL
|
||||
case "URLSearchParams":
|
||||
return lhs instanceof SandboxURLSearchParams
|
||||
case "Array":
|
||||
return Array.isArray(lhs)
|
||||
case "Object":
|
||||
return lhs !== null && (typeof lhs === "object" || typeofValue(lhs) === "function")
|
||||
}
|
||||
}
|
||||
if (rhs instanceof PromiseNamespace) return lhs instanceof SandboxPromise
|
||||
// Number/String/Boolean wrap primitives in JS; no boxed values exist in CodeMode, so
|
||||
// `x instanceof Number` is always false - exactly what it is for primitives in JS.
|
||||
if (rhs instanceof CoercionFunction && (rhs.name === "Number" || rhs.name === "String" || rhs.name === "Boolean")) {
|
||||
return false
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
"The right-hand side of 'instanceof' must be a constructor CodeMode knows: Error (or a specific error type like TypeError), Date, RegExp, Map, Set, URL, URLSearchParams, Array, Object, or Promise.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
const invokeStringMethod = (value: string, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const str = (index: number): string => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "string")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a string.`, node)
|
||||
return arg
|
||||
}
|
||||
const num = (index: number): number => {
|
||||
const arg = args[index]
|
||||
if (typeof arg !== "number")
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument ${index + 1} to be a number.`, node)
|
||||
return arg
|
||||
}
|
||||
const optNum = (index: number): number | undefined => (args[index] === undefined ? undefined : num(index))
|
||||
const optStr = (index: number): string | undefined => (args[index] === undefined ? undefined : str(index))
|
||||
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toLowerCase":
|
||||
result = value.toLowerCase()
|
||||
break
|
||||
case "toUpperCase":
|
||||
result = value.toUpperCase()
|
||||
break
|
||||
case "trim":
|
||||
result = value.trim()
|
||||
break
|
||||
// trimLeft/trimRight are the legacy aliases of trimStart/trimEnd, kept because models write them.
|
||||
case "trimStart":
|
||||
case "trimLeft":
|
||||
result = value.trimStart()
|
||||
break
|
||||
case "trimEnd":
|
||||
case "trimRight":
|
||||
result = value.trimEnd()
|
||||
break
|
||||
// Locale/options arguments are ignored: comparison runs with the host default locale, and
|
||||
// the common use is a sort comparator where any consistent order works.
|
||||
case "localeCompare":
|
||||
result = value.localeCompare(str(0))
|
||||
break
|
||||
case "normalize": {
|
||||
const form = optStr(0)
|
||||
try {
|
||||
result = value.normalize(form)
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
node,
|
||||
).as("RangeError")
|
||||
}
|
||||
break
|
||||
}
|
||||
case "split": {
|
||||
if (args.length === 0) {
|
||||
result = [value]
|
||||
break
|
||||
}
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
result = value.split((args[0] as SandboxRegExp).regex, optNum(1))
|
||||
break
|
||||
}
|
||||
const requestedLimit = optNum(1)
|
||||
result = value.split(str(0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
break
|
||||
}
|
||||
case "slice":
|
||||
result = value.slice(optNum(0), optNum(1))
|
||||
break
|
||||
case "includes":
|
||||
result = value.includes(str(0), optNum(1))
|
||||
break
|
||||
case "startsWith":
|
||||
result = value.startsWith(str(0), optNum(1))
|
||||
break
|
||||
case "endsWith":
|
||||
result = value.endsWith(str(0), optNum(1))
|
||||
break
|
||||
case "indexOf":
|
||||
result = value.indexOf(str(0), optNum(1))
|
||||
break
|
||||
case "lastIndexOf":
|
||||
result = value.lastIndexOf(str(0), optNum(1))
|
||||
break
|
||||
case "replace":
|
||||
case "replaceAll": {
|
||||
if (args[0] instanceof SandboxRegExp) {
|
||||
const pattern = (args[0] as SandboxRegExp).regex
|
||||
const replacement = str(1)
|
||||
if (name === "replaceAll" && !pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
result = name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
||||
break
|
||||
}
|
||||
if (name === "replace") {
|
||||
result = value.replace(str(0), str(1))
|
||||
break
|
||||
}
|
||||
result = value.replaceAll(str(0), str(1))
|
||||
break
|
||||
}
|
||||
case "match": {
|
||||
const pattern = toHostRegex(args[0], name, node)
|
||||
const matched = value.match(pattern)
|
||||
if (matched === null) return null
|
||||
// A global match is a plain array of matched strings; a non-global match carries
|
||||
// index/groups own properties, so bypass the copying data checkpoint to keep them.
|
||||
if (pattern.global) return boundedData(matched, "String.match result")
|
||||
return matchToValue(matched)
|
||||
}
|
||||
case "matchAll": {
|
||||
const pattern = toHostRegex(args[0], name, node, "g")
|
||||
if (!pattern.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
// Materialized as an array (not an iterator); each entry is a match array with
|
||||
// index/groups own properties. Match count is bounded by the subject length.
|
||||
return Array.from(value.matchAll(pattern), matchToValue)
|
||||
}
|
||||
case "search": {
|
||||
result = value.search(toHostRegex(args[0], name, node))
|
||||
break
|
||||
}
|
||||
case "repeat": {
|
||||
const count = num(0)
|
||||
if (!Number.isFinite(count) || count < 0)
|
||||
throw new InterpreterRuntimeError("String.repeat expects a finite non-negative count.", node)
|
||||
result = value.repeat(count)
|
||||
break
|
||||
}
|
||||
case "padStart":
|
||||
result = value.padStart(num(0), optStr(1))
|
||||
break
|
||||
case "padEnd":
|
||||
result = value.padEnd(num(0), optStr(1))
|
||||
break
|
||||
case "charAt":
|
||||
result = value.charAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "at":
|
||||
result = value.at(optNum(0) ?? 0)
|
||||
break
|
||||
case "substring":
|
||||
result = value.substring(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
case "substr":
|
||||
result = value.substr(optNum(0) ?? 0, optNum(1))
|
||||
break
|
||||
// JS charCodeAt returns NaN out of range; NaN flows as an ordinary in-sandbox value
|
||||
// (normalized to null only at the data boundary - see copyOut), so return it as-is.
|
||||
case "charCodeAt":
|
||||
result = value.charCodeAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "codePointAt":
|
||||
result = value.codePointAt(optNum(0) ?? 0)
|
||||
break
|
||||
case "toString":
|
||||
result = value
|
||||
break
|
||||
case "concat": {
|
||||
result = value.concat(...args.map((_, index) => str(index)))
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `String.${name} result`)
|
||||
}
|
||||
|
||||
const invokeArrayStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
switch (name) {
|
||||
case "isArray":
|
||||
return Array.isArray(args[0])
|
||||
case "of":
|
||||
return [...args]
|
||||
case "from": {
|
||||
if (args.length > 1) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array.from(...) does not support a map function in CodeMode; call .map() on the result instead.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
// Map/Set materialize directly (the data checkpoint would serialize them to {}).
|
||||
if (args[0] instanceof SandboxMap)
|
||||
return Array.from((args[0] as SandboxMap).map.entries(), ([key, item]) => [key, item])
|
||||
if (args[0] instanceof SandboxSet) return Array.from((args[0] as SandboxSet).set.values())
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
return Array.from(args[0].params.entries(), ([key, value]) => [key, value])
|
||||
}
|
||||
const source = boundedData(args[0], "Array.from input")
|
||||
if (typeof source === "string") return Array.from(source)
|
||||
if (Array.isArray(source)) return [...source]
|
||||
if (
|
||||
source !== null &&
|
||||
typeof source === "object" &&
|
||||
typeof (source as { length?: unknown }).length === "number"
|
||||
) {
|
||||
return Array.from(source as ArrayLike<unknown>)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Array.from expects an array, string, Map, Set, or array-like value.", node)
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Array.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
const invokeGlobalMethod = (ref: GlobalMethodReference, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (ref.namespace === "console")
|
||||
throw new InterpreterRuntimeError(`console.${ref.name} is not available in CodeMode.`, node)
|
||||
if (ref.namespace === "Object") return invokeObjectMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Math") return invokeMathMethod(ref.name, args, node)
|
||||
if (ref.namespace === "Array") return invokeArrayStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Number") return invokeNumberStatic(ref.name, args, node)
|
||||
if (ref.namespace === "String") return invokeStringStatic(ref.name, args, node)
|
||||
if (ref.namespace === "URL") return invokeURLStatic(ref.name, args, node)
|
||||
if (ref.namespace === "Date") {
|
||||
if (!dateStatics.has(ref.name))
|
||||
throw new InterpreterRuntimeError(`Date.${ref.name} is not available in CodeMode.`, node)
|
||||
return invokeDateStatic(ref.name, args, node)
|
||||
}
|
||||
if (
|
||||
ref.namespace === "RegExp" ||
|
||||
ref.namespace === "Map" ||
|
||||
ref.namespace === "Set" ||
|
||||
ref.namespace === "URLSearchParams"
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${ref.namespace}.${ref.name} is not available in CodeMode.`, node)
|
||||
}
|
||||
return invokeJsonMethod(ref.name, args, node)
|
||||
}
|
||||
|
||||
// Every identifier a parameter pattern binds, used to seed TDZ slots before defaults run.
|
||||
const collectPatternNames = (pattern: AstNode, out: Array<string> = []): Array<string> => {
|
||||
switch (pattern.type) {
|
||||
case "Identifier":
|
||||
out.push(getString(pattern, "name"))
|
||||
break
|
||||
case "AssignmentPattern":
|
||||
collectPatternNames(getNode(pattern, "left"), out)
|
||||
break
|
||||
case "RestElement":
|
||||
collectPatternNames(getNode(pattern, "argument"), out)
|
||||
break
|
||||
case "ArrayPattern":
|
||||
for (const element of getArray(pattern, "elements")) {
|
||||
if (element !== null) collectPatternNames(asNode(element, "elements"), out)
|
||||
}
|
||||
break
|
||||
case "ObjectPattern":
|
||||
for (const property of getArray(pattern, "properties")) {
|
||||
const prop = asNode(property, "properties")
|
||||
collectPatternNames(prop.type === "RestElement" ? getNode(prop, "argument") : getNode(prop, "value"), out)
|
||||
}
|
||||
break
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
class Interpreter<R> {
|
||||
private scopes: Array<Map<string, Binding>>
|
||||
private readonly invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
|
||||
// Enumerable namespace/tool names at a node of the host tool tree, threaded from
|
||||
// ToolRuntime.make like invokeTool: the interpreter never holds the tree itself.
|
||||
private readonly toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
|
||||
private readonly logs: Array<string>
|
||||
private lastValue: unknown
|
||||
// Caps how many eagerly forked tool calls run at once (the parallel-call concurrency cap).
|
||||
private readonly callPermits: Semaphore.Semaphore
|
||||
// Fiber-backed promises whose settlement no program construct has observed yet. Successful
|
||||
// program completion drains these (like a runtime waiting on in-flight work at exit) and
|
||||
// surfaces a never-awaited failure as an unhandled-rejection diagnostic.
|
||||
private readonly pendingSettlements = new Set<SandboxPromise>()
|
||||
|
||||
constructor(
|
||||
invokeTool: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
|
||||
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
|
||||
logs: Array<string> = [],
|
||||
) {
|
||||
const globalScope = new Map<string, Binding>()
|
||||
this.scopes = [globalScope]
|
||||
this.invokeTool = invokeTool
|
||||
this.toolKeys = toolKeys
|
||||
this.logs = logs
|
||||
this.lastValue = undefined
|
||||
this.callPermits = Semaphore.makeUnsafe(TOOL_CALL_CONCURRENCY)
|
||||
globalScope.set("tools", { mutable: false, value: new ToolReference([]) })
|
||||
globalScope.set("Promise", { mutable: false, value: new PromiseNamespace() })
|
||||
globalScope.set("undefined", { mutable: false, value: undefined })
|
||||
globalScope.set("Object", { mutable: false, value: new GlobalNamespace("Object") })
|
||||
globalScope.set("Math", { mutable: false, value: new GlobalNamespace("Math") })
|
||||
globalScope.set("JSON", { mutable: false, value: new GlobalNamespace("JSON") })
|
||||
globalScope.set("Number", { mutable: false, value: new CoercionFunction("Number") })
|
||||
globalScope.set("String", { mutable: false, value: new CoercionFunction("String") })
|
||||
globalScope.set("Boolean", { mutable: false, value: new CoercionFunction("Boolean") })
|
||||
globalScope.set("Array", { mutable: false, value: new GlobalNamespace("Array") })
|
||||
globalScope.set("console", { mutable: false, value: new GlobalNamespace("console") })
|
||||
globalScope.set("parseInt", { mutable: false, value: new CoercionFunction("parseInt") })
|
||||
globalScope.set("parseFloat", { mutable: false, value: new CoercionFunction("parseFloat") })
|
||||
globalScope.set("Date", { mutable: false, value: new GlobalNamespace("Date") })
|
||||
globalScope.set("RegExp", { mutable: false, value: new GlobalNamespace("RegExp") })
|
||||
globalScope.set("Map", { mutable: false, value: new GlobalNamespace("Map") })
|
||||
globalScope.set("Set", { mutable: false, value: new GlobalNamespace("Set") })
|
||||
globalScope.set("URL", { mutable: false, value: new GlobalNamespace("URL") })
|
||||
globalScope.set("URLSearchParams", { mutable: false, value: new GlobalNamespace("URLSearchParams") })
|
||||
globalScope.set("encodeURI", { mutable: false, value: new UriFunction("encodeURI") })
|
||||
globalScope.set("encodeURIComponent", { mutable: false, value: new UriFunction("encodeURIComponent") })
|
||||
globalScope.set("decodeURI", { mutable: false, value: new UriFunction("decodeURI") })
|
||||
globalScope.set("decodeURIComponent", { mutable: false, value: new UriFunction("decodeURIComponent") })
|
||||
// Error constructors are real values, so `x instanceof Error` works and `Error("msg")`
|
||||
// (with or without `new`) constructs a branded { name, message } error object.
|
||||
for (const name of errorConstructors) {
|
||||
globalScope.set(name, { mutable: false, value: new ErrorConstructorReference(name) })
|
||||
}
|
||||
// NaN/Infinity flow as ordinary in-sandbox values (normalized to null only at the data
|
||||
// boundary - see copyOut), so their global bindings must exist too, e.g. `reduce(max, -Infinity)`.
|
||||
globalScope.set("NaN", { mutable: false, value: NaN })
|
||||
globalScope.set("Infinity", { mutable: false, value: Infinity })
|
||||
}
|
||||
|
||||
run(program: ProgramNode): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
// Run the program body in its own module scope on top of the builtin global scope, so
|
||||
// top-level declarations (`let undefined = 5`, `const Object = ...`) shadow builtins like
|
||||
// JS module scope, instead of colliding with the seeded globals.
|
||||
this.pushScope()
|
||||
return Effect.gen(function* () {
|
||||
self.hoistFunctions(program.body)
|
||||
let value: unknown = undefined
|
||||
let returned = false
|
||||
for (const statement of program.body) {
|
||||
const result = yield* self.evaluateStatement(statement)
|
||||
|
||||
if (result.kind === "return") {
|
||||
value = result.value
|
||||
returned = true
|
||||
break
|
||||
}
|
||||
|
||||
if (result.kind === "break" || result.kind === "continue") {
|
||||
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
if (!returned) value = self.lastValue
|
||||
|
||||
// The program body runs inside an implicit async function, so a returned promise
|
||||
// resolves before crossing the data boundary - `return tools.ns.tool(...)` works
|
||||
// without an explicit await, exactly as in JS.
|
||||
if (value instanceof SandboxPromise) value = yield* self.settlePromise(value)
|
||||
yield* self.drainPendingSettlements()
|
||||
return value
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
// Awaits every fiber-backed promise the program abandoned (fire-and-forget tool calls), so
|
||||
// their work completes before the execution ends - mirroring a JS runtime waiting on
|
||||
// in-flight I/O at exit. A failure nobody could have handled becomes an unhandled-rejection
|
||||
// diagnostic (interrupted calls, e.g. Promise.race losers, are ignored).
|
||||
private drainPendingSettlements(): Effect.Effect<void, unknown, never> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const promise of [...self.pendingSettlements]) {
|
||||
const exit = yield* self.observePromise(promise)
|
||||
if (Exit.isSuccess(exit) || Cause.hasInterruptsOnly(exit.cause)) continue
|
||||
const failure = normalizeError(Cause.squash(exit.cause))
|
||||
throw new InterpreterRuntimeError(
|
||||
`Unhandled rejection from an un-awaited tool call: ${failure.message}`,
|
||||
undefined,
|
||||
failure.kind,
|
||||
["Await tool calls - `const result = await tools.ns.tool(...)` - so failures can be caught and handled."],
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Eagerly starts a tool call on a supervised child fiber (so the execution timeout and
|
||||
// scope teardown interrupt it) gated by the concurrency semaphore, and wraps the fiber in a
|
||||
// first-class promise value. `startImmediately` makes the runtime admit the call - charging
|
||||
// the tool-call budget and firing onToolCallStart - at the call site, before any await.
|
||||
private createToolCallPromise(
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<unknown>,
|
||||
): Effect.Effect<SandboxPromise, never, R> {
|
||||
const self = this
|
||||
return Effect.map(
|
||||
Effect.forkChild(this.callPermits.withPermit(Effect.suspend(() => self.invokeTool(path, args))), {
|
||||
startImmediately: true,
|
||||
}),
|
||||
(fiber) => {
|
||||
const promise = new SandboxPromise(fiber)
|
||||
self.pendingSettlements.add(promise)
|
||||
return promise
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// The promise's settlement as an Exit, marking it observed for unhandled-rejection tracking.
|
||||
// Fiber settlement is idempotent, so observing the same promise repeatedly (await twice,
|
||||
// Promise.all([p, p])) never re-runs the underlying call.
|
||||
private observePromise(promise: SandboxPromise): Effect.Effect<Exit.Exit<unknown, unknown>> {
|
||||
this.pendingSettlements.delete(promise)
|
||||
return promise.fiber !== undefined ? Fiber.await(promise.fiber) : Effect.exit(promise.immediate ?? Effect.void)
|
||||
}
|
||||
|
||||
// `await promise`: succeed with the fulfilled value or re-raise the failure so try/catch
|
||||
// observes it exactly like a synchronous throw at the await site.
|
||||
private settlePromise(promise: SandboxPromise, node?: AstNode): Effect.Effect<unknown, unknown, never> {
|
||||
const self = this
|
||||
return Effect.flatMap(this.observePromise(promise), (exit) => self.unwrapPromiseExit(promise, exit, node))
|
||||
}
|
||||
|
||||
private unwrapPromiseExit(
|
||||
promise: SandboxPromise | undefined,
|
||||
exit: Exit.Exit<unknown, unknown>,
|
||||
node?: AstNode,
|
||||
): Effect.Effect<unknown, unknown> {
|
||||
if (Exit.isSuccess(exit)) return Effect.succeed(exit.value)
|
||||
// A call Promise.race interrupted after losing settles as a catchable program failure;
|
||||
// any other interruption is execution teardown (timeout/host) and must keep propagating
|
||||
// as interruption rather than becoming program-visible data.
|
||||
if (promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)) {
|
||||
return Effect.fail(
|
||||
new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Effect.failCause(exit.cause)
|
||||
}
|
||||
|
||||
private evaluateStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
switch (node.type) {
|
||||
case "ExpressionStatement":
|
||||
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) => ({ kind: "value", value }))
|
||||
case "VariableDeclaration":
|
||||
return Effect.map(this.evaluateVariableDeclaration(node), () => ({ kind: "none" }))
|
||||
case "ReturnStatement": {
|
||||
const argumentNode = getOptionalNode(node, "argument")
|
||||
return argumentNode
|
||||
? Effect.map(this.evaluateExpression(argumentNode), (value) => ({ kind: "return", value }))
|
||||
: Effect.succeed({ kind: "return", value: undefined })
|
||||
}
|
||||
case "BlockStatement":
|
||||
return this.evaluateBlock(node)
|
||||
case "IfStatement":
|
||||
return this.evaluateIfStatement(node)
|
||||
case "SwitchStatement":
|
||||
return this.evaluateSwitchStatement(node)
|
||||
case "WhileStatement":
|
||||
return this.evaluateWhileStatement(node)
|
||||
case "DoWhileStatement":
|
||||
return this.evaluateDoWhileStatement(node)
|
||||
case "ForStatement":
|
||||
return this.evaluateForStatement(node)
|
||||
case "ForOfStatement":
|
||||
return this.evaluateForOfStatement(node)
|
||||
case "ForInStatement":
|
||||
return this.evaluateForInStatement(node)
|
||||
case "BreakStatement":
|
||||
return Effect.succeed(this.evaluateBreakStatement(node))
|
||||
case "ContinueStatement":
|
||||
return Effect.succeed(this.evaluateContinueStatement(node))
|
||||
case "ThrowStatement":
|
||||
return this.evaluateThrowStatement(node)
|
||||
case "TryStatement":
|
||||
return this.evaluateTryStatement(node)
|
||||
case "EmptyStatement":
|
||||
return Effect.succeed({ kind: "none" })
|
||||
case "FunctionDeclaration":
|
||||
return Effect.succeed({ kind: "none" }) // bound ahead of time by hoistFunctions
|
||||
default:
|
||||
throw unsupportedSyntax(node.type, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateBlock(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const body = getArray(node, "body")
|
||||
self.hoistFunctions(body)
|
||||
|
||||
for (const statementValue of body) {
|
||||
const statement = asNode(statementValue, "body")
|
||||
const result = yield* self.evaluateStatement(statement)
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind !== "none") {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private createFunction(node: AstNode): CodeModeFunction {
|
||||
if (node.generator === true) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Generator functions are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
return new CodeModeFunction(
|
||||
getArray(node, "params").map((parameter, index) => asNode(parameter, `params[${index}]`)),
|
||||
getNode(node, "body"),
|
||||
this.scopes.slice(),
|
||||
)
|
||||
}
|
||||
|
||||
// Function declarations are hoisted: bound in their scope before the body runs, so a
|
||||
// program can call a helper defined further down (matching JavaScript).
|
||||
private hoistFunctions(statements: Array<unknown>): void {
|
||||
for (const statementValue of statements) {
|
||||
if (!isRecord(statementValue) || statementValue.type !== "FunctionDeclaration") continue
|
||||
const node = statementValue as AstNode
|
||||
this.declare(getString(getNode(node, "id"), "name"), this.createFunction(node), true, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateIfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const testNode = getNode(node, "test")
|
||||
const consequentNode = getNode(node, "consequent")
|
||||
const alternateNode = getOptionalNode(node, "alternate")
|
||||
|
||||
return Effect.flatMap(this.evaluateExpression(testNode), (test) =>
|
||||
test
|
||||
? this.evaluateStatement(consequentNode)
|
||||
: alternateNode
|
||||
? this.evaluateStatement(alternateNode)
|
||||
: Effect.succeed({ kind: "none" }),
|
||||
)
|
||||
}
|
||||
|
||||
private evaluateSwitchStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const self = this
|
||||
this.pushScope()
|
||||
return Effect.gen(function* () {
|
||||
const discriminant = yield* self.evaluateExpression(getNode(node, "discriminant"))
|
||||
if (containsOpaqueReference(discriminant)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Switch discriminants must be data values in CodeMode.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
const cases = getArray(node, "cases").map((value, index) => asNode(value, `cases[${index}]`))
|
||||
let defaultIndex: number | undefined
|
||||
let selected: number | undefined
|
||||
for (const [index, branch] of cases.entries()) {
|
||||
const test = getOptionalNode(branch, "test")
|
||||
if (!test) {
|
||||
defaultIndex = index
|
||||
continue
|
||||
}
|
||||
const candidate = yield* self.evaluateExpression(test)
|
||||
if (containsOpaqueReference(candidate)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Switch case values must be data values in CodeMode.",
|
||||
test,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
if (candidate === discriminant) {
|
||||
selected = index
|
||||
break
|
||||
}
|
||||
}
|
||||
const start = selected ?? defaultIndex
|
||||
if (start === undefined) return { kind: "none" } satisfies StatementResult
|
||||
for (let index = start; index < cases.length; index += 1) {
|
||||
for (const statementValue of getArray(cases[index]!, "consequent")) {
|
||||
const result = yield* self.evaluateStatement(asNode(statementValue, "consequent"))
|
||||
if (result.kind === "break") return { kind: "none" } satisfies StatementResult
|
||||
if (result.kind === "return" || result.kind === "continue") return result
|
||||
if (result.kind === "value") self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private evaluateWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const testNode = getNode(node, "test")
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
while (yield* self.evaluateExpression(testNode)) {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateDoWhileStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const bodyNode = getNode(node, "body")
|
||||
const testNode = getNode(node, "test")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
do {
|
||||
const result = yield* self.evaluateStatement(bodyNode)
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
} while (yield* self.evaluateExpression(testNode))
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateForStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
this.pushScope()
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const initNode = getOptionalNode(node, "init")
|
||||
const testNode = getOptionalNode(node, "test")
|
||||
const updateNode = getOptionalNode(node, "update")
|
||||
const bodyNode = getNode(node, "body")
|
||||
|
||||
if (initNode) {
|
||||
if (initNode.type === "VariableDeclaration") {
|
||||
yield* self.evaluateVariableDeclaration(initNode)
|
||||
} else {
|
||||
yield* self.evaluateExpression(initNode)
|
||||
}
|
||||
}
|
||||
|
||||
const perIterationBindings =
|
||||
initNode?.type === "VariableDeclaration" && getString(initNode, "kind") !== "var"
|
||||
? Array.from(self.currentScope().keys())
|
||||
: []
|
||||
|
||||
while (testNode ? yield* self.evaluateExpression(testNode) : true) {
|
||||
let iterationScope: Map<string, Binding> | undefined
|
||||
if (perIterationBindings.length > 0) {
|
||||
iterationScope = new Map(
|
||||
perIterationBindings.map((name) => {
|
||||
const binding = self.currentScope().get(name)!
|
||||
return [name, { ...binding }]
|
||||
}),
|
||||
)
|
||||
self.scopes.push(iterationScope)
|
||||
}
|
||||
const result = yield* self.evaluateStatement(bodyNode).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (iterationScope) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (iterationScope) {
|
||||
const loopScope = self.currentScope()
|
||||
for (const name of perIterationBindings) {
|
||||
loopScope.set(name, { ...iterationScope.get(name)! })
|
||||
}
|
||||
}
|
||||
|
||||
if (updateNode) {
|
||||
yield* self.evaluateExpression(updateNode)
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" } satisfies StatementResult
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
}
|
||||
|
||||
private evaluateForOfStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
if (getBoolean(node, "await")) {
|
||||
throw new InterpreterRuntimeError("for await...of is not supported.", node)
|
||||
}
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const left = getNode(node, "left")
|
||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const body = getNode(node, "body")
|
||||
|
||||
// Arrays iterate in place; strings iterate code points; Maps iterate [key, value]
|
||||
// pairs and Sets iterate values over a snapshot (mutation during iteration is safe).
|
||||
const iterable = Array.isArray(right) ? right : spreadItems(right)
|
||||
if (iterable === undefined) {
|
||||
throw new InterpreterRuntimeError("for...of requires an array, string, Map, or Set value in CodeMode.", node)
|
||||
}
|
||||
|
||||
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||
let assignmentName: string | undefined
|
||||
|
||||
if (left.type === "VariableDeclaration") {
|
||||
const declarations = getArray(left, "declarations")
|
||||
if (declarations.length !== 1) {
|
||||
throw new InterpreterRuntimeError("for...of supports one declared binding.", left)
|
||||
}
|
||||
|
||||
const declarator = asNode(declarations[0], "declarations[0]")
|
||||
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||
} else if (left.type === "Identifier") {
|
||||
assignmentName = getString(left, "name")
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
|
||||
}
|
||||
|
||||
for (const value of iterable) {
|
||||
if (declaration) {
|
||||
self.pushScope()
|
||||
yield* self.declarePattern(declaration.pattern, value, declaration.mutable, left)
|
||||
} else if (assignmentName) {
|
||||
self.setIdentifierValue(assignmentName, value, left)
|
||||
}
|
||||
|
||||
const result = yield* self.evaluateStatement(body).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" }
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" }
|
||||
})
|
||||
}
|
||||
|
||||
// Own enumerable string keys of a value, shared by `for...in` and `Object.keys` over tool
|
||||
// references: plain data objects enumerate their own keys, arrays their index strings (plus
|
||||
// any own non-index properties, e.g. match results' index/groups - exactly Object.keys in
|
||||
// JS), and a tool reference the namespace/tool names at its path in the host tool tree.
|
||||
// Returns undefined for everything else so callers can raise a contextual error.
|
||||
private enumerableKeys(value: unknown): Array<string> | undefined {
|
||||
if (value instanceof ToolReference) {
|
||||
return [...this.toolKeys(value.path)]
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return Object.keys(value)
|
||||
}
|
||||
if (value !== null && typeof value === "object" && !isRuntimeReference(value)) {
|
||||
return Object.keys(value)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
private evaluateForInStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const left = getNode(node, "left")
|
||||
const right = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const body = getNode(node, "body")
|
||||
|
||||
// Keys are snapshotted up front (mutation during iteration is safe): plain objects
|
||||
// enumerate their own keys, arrays their index strings, and tool references the
|
||||
// namespace/tool names at that node - the same enumeration Object.keys performs.
|
||||
// Anything else (strings, Maps, Sets, numbers, null, ...) is a deliberate error rather
|
||||
// than real JS's surprising behavior (indices for strings, zero iterations for
|
||||
// Maps/Sets/null): the hint points at the constructs that do what the program means.
|
||||
const keys = self.enumerableKeys(right)
|
||||
if (keys === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"for...in requires a plain object, array, or tools reference in CodeMode. Use for...of for arrays/strings/Maps/Sets, or Object.keys(value) for a key list.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
let declaration: { readonly pattern: AstNode; readonly mutable: boolean } | undefined
|
||||
let assignmentName: string | undefined
|
||||
|
||||
if (left.type === "VariableDeclaration") {
|
||||
const declarations = getArray(left, "declarations")
|
||||
if (declarations.length !== 1) {
|
||||
throw new InterpreterRuntimeError("for...in supports one declared binding.", left)
|
||||
}
|
||||
|
||||
const declarator = asNode(declarations[0], "declarations[0]")
|
||||
declaration = { pattern: getNode(declarator, "id"), mutable: getString(left, "kind") !== "const" }
|
||||
} else if (left.type === "Identifier") {
|
||||
assignmentName = getString(left, "name")
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
|
||||
}
|
||||
|
||||
for (const key of keys) {
|
||||
if (declaration) {
|
||||
self.pushScope()
|
||||
yield* self.declarePattern(declaration.pattern, key, declaration.mutable, left)
|
||||
} else if (assignmentName) {
|
||||
self.setIdentifierValue(assignmentName, key, left)
|
||||
}
|
||||
|
||||
const result = yield* self.evaluateStatement(body).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
if (declaration) self.popScope()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
if (result.kind === "return") {
|
||||
return result
|
||||
}
|
||||
|
||||
if (result.kind === "break") {
|
||||
return { kind: "none" }
|
||||
}
|
||||
|
||||
if (result.kind === "value") {
|
||||
self.lastValue = result.value
|
||||
}
|
||||
|
||||
if (result.kind === "continue") {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: "none" }
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateBreakStatement(node: AstNode): StatementResult {
|
||||
const labelNode = getOptionalNode(node, "label")
|
||||
|
||||
if (labelNode) {
|
||||
throw new InterpreterRuntimeError("Labeled break is not supported in v1.", node)
|
||||
}
|
||||
|
||||
return { kind: "break" }
|
||||
}
|
||||
|
||||
private evaluateContinueStatement(node: AstNode): StatementResult {
|
||||
const labelNode = getOptionalNode(node, "label")
|
||||
|
||||
if (labelNode) {
|
||||
throw new InterpreterRuntimeError("Labeled continue is not supported in v1.", node)
|
||||
}
|
||||
|
||||
return { kind: "continue" }
|
||||
}
|
||||
|
||||
private evaluateThrowStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const argument = getNode(node, "argument")
|
||||
return Effect.flatMap(this.evaluateExpression(argument), (value) => Effect.fail(new ProgramThrow(value)))
|
||||
}
|
||||
|
||||
private evaluateTryStatement(node: AstNode): Effect.Effect<StatementResult, unknown, R> {
|
||||
const body = getNode(node, "block")
|
||||
const handler = getOptionalNode(node, "handler")
|
||||
const finalizer = getOptionalNode(node, "finalizer")
|
||||
const self = this
|
||||
|
||||
const attempted = Effect.matchCauseEffect(this.evaluateStatement(body), {
|
||||
onFailure: (cause) => {
|
||||
if (cause.reasons.some(Cause.isInterruptReason) || !handler) {
|
||||
return Effect.failCause(cause)
|
||||
}
|
||||
|
||||
// The program sees a plain { message } error (or the thrown value itself) - see
|
||||
// caughtErrorValue, shared with Promise.allSettled rejection reasons.
|
||||
const caught = caughtErrorValue(Cause.squash(cause))
|
||||
const parameter = getOptionalNode(handler, "param")
|
||||
self.pushScope()
|
||||
return Effect.gen(function* () {
|
||||
if (parameter) yield* self.declarePattern(parameter, caught, true, handler)
|
||||
return yield* self.evaluateStatement(getNode(handler, "body"))
|
||||
}).pipe(Effect.ensuring(Effect.sync(() => self.popScope())))
|
||||
},
|
||||
onSuccess: Effect.succeed,
|
||||
})
|
||||
|
||||
if (!finalizer) return attempted
|
||||
|
||||
const isAbrupt = (result: StatementResult): boolean =>
|
||||
result.kind === "return" || result.kind === "break" || result.kind === "continue"
|
||||
|
||||
return Effect.matchCauseEffect(attempted, {
|
||||
onFailure: (cause) =>
|
||||
cause.reasons.some(Cause.isInterruptReason)
|
||||
? Effect.failCause(cause)
|
||||
: Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.failCause(cause),
|
||||
),
|
||||
onSuccess: (result) =>
|
||||
Effect.flatMap(this.evaluateStatement(finalizer), (final) =>
|
||||
isAbrupt(final) ? Effect.succeed(final) : Effect.succeed(result),
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateVariableDeclaration(node: AstNode): Effect.Effect<void, unknown, R> {
|
||||
const kind = getString(node, "kind")
|
||||
const declarations = getArray(node, "declarations")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const declarationValue of declarations) {
|
||||
const declaration = asNode(declarationValue, "declarations")
|
||||
|
||||
if (declaration.type !== "VariableDeclarator") {
|
||||
throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
|
||||
}
|
||||
|
||||
const init = getOptionalNode(declaration, "init")
|
||||
const value = init ? yield* self.evaluateExpression(init) : undefined
|
||||
yield* self.declarePattern(getNode(declaration, "id"), value, kind !== "const", declaration)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private declarePattern(
|
||||
pattern: AstNode,
|
||||
value: unknown,
|
||||
mutable: boolean,
|
||||
node: AstNode,
|
||||
): Effect.Effect<void, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (pattern.type === "Identifier") {
|
||||
self.declare(getString(pattern, "name"), value, mutable, node)
|
||||
return
|
||||
}
|
||||
|
||||
// Default values: `x = expr` / `{ a = 1 }` - the default is evaluated only when the value is undefined.
|
||||
if (pattern.type === "AssignmentPattern") {
|
||||
const resolved = value === undefined ? yield* self.evaluateExpression(getNode(pattern, "right")) : value
|
||||
yield* self.declarePattern(getNode(pattern, "left"), resolved, mutable, node)
|
||||
return
|
||||
}
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value) || isRuntimeReference(value)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object destructuring requires a data object value.",
|
||||
pattern,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
const consumed = new Set<string>()
|
||||
for (const propertyValue of getArray(pattern, "properties")) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
// Object rest: `{ a, ...others }` - gather the not-yet-consumed own keys.
|
||||
if (property.type === "RestElement") {
|
||||
const rest: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, item] of Object.entries(value as SafeObject)) {
|
||||
if (!consumed.has(key) && !isBlockedMember(key)) rest[key] = item
|
||||
}
|
||||
yield* self.declarePattern(getNode(property, "argument"), rest, mutable, property)
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
property.type !== "Property" ||
|
||||
getBoolean(property, "computed") ||
|
||||
getString(property, "kind") !== "init"
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only named object destructuring properties are supported.", property)
|
||||
}
|
||||
|
||||
const keyNode = getNode(property, "key")
|
||||
const key = keyNode.type === "Identifier" ? getString(keyNode, "name") : String(keyNode.value)
|
||||
if (isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, keyNode)
|
||||
}
|
||||
consumed.add(key)
|
||||
yield* self.declarePattern(getNode(property, "value"), (value as SafeObject)[key], mutable, property)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (pattern.type === "ArrayPattern") {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError("Array destructuring requires an array value.", pattern)
|
||||
}
|
||||
|
||||
for (const [index, item] of getArray(pattern, "elements").entries()) {
|
||||
if (item === null) continue
|
||||
const element = asNode(item, `elements[${index}]`)
|
||||
// Array rest: `[head, ...tail]` - binds the remaining elements (must be last).
|
||||
if (element.type === "RestElement") {
|
||||
yield* self.declarePattern(getNode(element, "argument"), value.slice(index), mutable, element)
|
||||
break
|
||||
}
|
||||
yield* self.declarePattern(element, value[index], mutable, pattern)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
switch (node.type) {
|
||||
case "Literal": {
|
||||
// A regex literal parses as a Literal node carrying { pattern, flags }; construct the
|
||||
// sandbox regex from those (the host `value` instance is never exposed).
|
||||
const regex = node.regex
|
||||
if (isRecord(regex) && typeof regex.pattern === "string") {
|
||||
return Effect.sync(() =>
|
||||
this.constructRegExp([regex.pattern, typeof regex.flags === "string" ? regex.flags : ""], node),
|
||||
)
|
||||
}
|
||||
return Effect.sync(() => boundedData(node.value, "Literal"))
|
||||
}
|
||||
case "Identifier":
|
||||
return Effect.sync(() => this.getIdentifierValue(getString(node, "name"), node))
|
||||
case "BinaryExpression":
|
||||
return this.evaluateBinaryExpression(node)
|
||||
case "LogicalExpression":
|
||||
return this.evaluateLogicalExpression(node)
|
||||
case "UnaryExpression":
|
||||
return this.evaluateUnaryExpression(node)
|
||||
case "AssignmentExpression":
|
||||
return this.evaluateAssignmentExpression(node)
|
||||
case "CallExpression":
|
||||
return this.evaluateCallExpression(node)
|
||||
case "ArrowFunctionExpression":
|
||||
case "FunctionExpression":
|
||||
return Effect.sync(() => this.createFunction(node))
|
||||
case "MemberExpression":
|
||||
return this.readMember(node)
|
||||
case "ChainExpression":
|
||||
return Effect.map(this.evaluateExpression(getNode(node, "expression")), (value) =>
|
||||
value === OptionalShortCircuit ? undefined : value,
|
||||
)
|
||||
case "ObjectExpression":
|
||||
return this.evaluateObjectExpression(node)
|
||||
case "ArrayExpression":
|
||||
return this.evaluateArrayExpression(node)
|
||||
case "TemplateLiteral":
|
||||
return this.evaluateTemplateLiteral(node)
|
||||
case "ConditionalExpression":
|
||||
return this.evaluateConditionalExpression(node)
|
||||
case "UpdateExpression":
|
||||
return this.evaluateUpdateExpression(node)
|
||||
case "AwaitExpression": {
|
||||
// `await` resolves a promise value; awaiting anything else is a passthrough no-op,
|
||||
// matching real JS semantics for non-thenables.
|
||||
const self = this
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "argument")), (value) =>
|
||||
value instanceof SandboxPromise ? self.settlePromise(value, node) : Effect.succeed(value),
|
||||
)
|
||||
}
|
||||
case "NewExpression":
|
||||
return this.evaluateNewExpression(node)
|
||||
default:
|
||||
throw unsupportedSyntax(node.type, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateNewExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callee = getNode(node, "callee")
|
||||
if (callee.type !== "Identifier") {
|
||||
throw unsupportedSyntax("NewExpression", node)
|
||||
}
|
||||
const name = getString(callee, "name")
|
||||
const argNodes = getArray(node, "arguments")
|
||||
const self = this
|
||||
if (name === "Promise") {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Promise(...) is not supported in CodeMode; tool calls already return promises - call the tool and await the result.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
if (errorConstructors.has(name)) {
|
||||
return Effect.gen(function* () {
|
||||
const arg =
|
||||
argNodes.length > 0 ? yield* self.evaluateExpression(asNode(argNodes[0], "arguments[0]")) : undefined
|
||||
return createErrorValue(name, arg === undefined ? "" : coerceToString(arg))
|
||||
})
|
||||
}
|
||||
if (valueConstructors.has(name)) {
|
||||
return Effect.gen(function* () {
|
||||
const args = yield* self.evaluateCallArguments(argNodes)
|
||||
switch (name) {
|
||||
case "Date":
|
||||
return self.constructDate(args)
|
||||
case "RegExp":
|
||||
return self.constructRegExp(args, node)
|
||||
case "Map":
|
||||
return self.constructMap(args[0], node)
|
||||
case "Set":
|
||||
return self.constructSet(args[0], node)
|
||||
case "URL":
|
||||
return self.constructURL(args, node)
|
||||
default:
|
||||
return self.constructURLSearchParams(args[0], node)
|
||||
}
|
||||
})
|
||||
}
|
||||
throw unsupportedSyntax("NewExpression", node)
|
||||
}
|
||||
|
||||
private constructDate(args: Array<unknown>): SandboxDate {
|
||||
if (args.length === 0) return new SandboxDate(Date.now())
|
||||
if (args.length === 1) {
|
||||
const arg = args[0]
|
||||
if (arg instanceof SandboxDate) return new SandboxDate(arg.time)
|
||||
if (typeof arg === "number") return new SandboxDate(new Date(arg).getTime())
|
||||
if (typeof arg === "string") return new SandboxDate(Date.parse(arg))
|
||||
return new SandboxDate(Number.NaN)
|
||||
}
|
||||
// new Date(year, month, day?, hours?, ...) - local-time component form.
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return new SandboxDate(new Date(...(parts as [number, number])).getTime())
|
||||
}
|
||||
|
||||
private constructRegExp(args: Array<unknown>, node: AstNode): SandboxRegExp {
|
||||
const first = args[0]
|
||||
const pattern =
|
||||
first instanceof SandboxRegExp ? first.regex.source : first === undefined ? "" : coerceToString(first)
|
||||
const flagsArg = args[1]
|
||||
if (flagsArg !== undefined && typeof flagsArg !== "string") {
|
||||
throw new InterpreterRuntimeError(
|
||||
`RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
const flags = flagsArg ?? (first instanceof SandboxRegExp ? first.regex.flags : "")
|
||||
try {
|
||||
return new SandboxRegExp(pattern, flags)
|
||||
} catch (error) {
|
||||
// Say which part was rejected and how to fix it, instead of passing the engine
|
||||
// message through bare. A flags failure names the flags; a pattern failure gets the
|
||||
// escaping hint (the usual cause is an unescaped metacharacter in a built-up string).
|
||||
const reason = regexFailureReason(error)
|
||||
throw new InterpreterRuntimeError(
|
||||
/flag/i.test(reason)
|
||||
? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.`
|
||||
: `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`,
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
}
|
||||
|
||||
private constructMap(init: unknown, node: AstNode): SandboxMap {
|
||||
const target = new SandboxMap()
|
||||
if (init === undefined || init === null) return target
|
||||
const entries = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxMap
|
||||
? Array.from(init.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
: undefined
|
||||
if (entries === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new Map(...) expects an array of [key, value] pairs, a Map, or no argument.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
for (const pair of entries) {
|
||||
if (!Array.isArray(pair)) {
|
||||
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs.", node)
|
||||
}
|
||||
target.map.set(pair[0], pair[1])
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private constructSet(init: unknown, node: AstNode): SandboxSet {
|
||||
const target = new SandboxSet()
|
||||
if (init === undefined || init === null) return target
|
||||
const items = Array.isArray(init)
|
||||
? init
|
||||
: init instanceof SandboxSet
|
||||
? Array.from(init.set.values())
|
||||
: typeof init === "string"
|
||||
? Array.from(init)
|
||||
: undefined
|
||||
if (items === undefined) {
|
||||
throw new InterpreterRuntimeError("new Set(...) expects an array, Set, string, or no argument.", node)
|
||||
}
|
||||
for (const item of items) target.set.add(item)
|
||||
return target
|
||||
}
|
||||
|
||||
private constructURL(args: Array<unknown>, node: AstNode): SandboxURL {
|
||||
if (args.length === 0) {
|
||||
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node).as(
|
||||
"TypeError",
|
||||
)
|
||||
}
|
||||
const input = urlArgument(args[0], "new URL input")
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], "new URL base")
|
||||
try {
|
||||
return new SandboxURL(new URL(input, base))
|
||||
} catch {
|
||||
throw new InterpreterRuntimeError(
|
||||
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
}
|
||||
|
||||
private constructURLSearchParams(init: unknown, node: AstNode): SandboxURLSearchParams {
|
||||
if (init === undefined) return new SandboxURLSearchParams(new URLSearchParams())
|
||||
if (init instanceof SandboxURLSearchParams) {
|
||||
return new SandboxURLSearchParams(new URLSearchParams(init.params))
|
||||
}
|
||||
if (typeof init === "string") return new SandboxURLSearchParams(new URLSearchParams(init))
|
||||
if (init === null || typeof init === "number" || typeof init === "boolean") {
|
||||
return new SandboxURLSearchParams(new URLSearchParams(coerceToString(init)))
|
||||
}
|
||||
if (init instanceof SandboxMap) {
|
||||
return this.constructURLSearchParams(
|
||||
Array.from(init.map.entries(), ([key, value]) => [key, value]),
|
||||
node,
|
||||
)
|
||||
}
|
||||
if (Array.isArray(init)) {
|
||||
const entries = init.map((pair) => {
|
||||
if (!Array.isArray(pair) || pair.length !== 2) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new URLSearchParams(...) expects an array of [name, value] pairs.",
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return [uriArgument(pair[0], "URLSearchParams name"), uriArgument(pair[1], "URLSearchParams value")] as [
|
||||
string,
|
||||
string,
|
||||
]
|
||||
})
|
||||
return new SandboxURLSearchParams(new URLSearchParams(entries))
|
||||
}
|
||||
if (isSandboxValue(init)) return new SandboxURLSearchParams(new URLSearchParams())
|
||||
const data = boundedData(init, "new URLSearchParams input")
|
||||
if (data === null || typeof data !== "object") {
|
||||
throw new InterpreterRuntimeError(
|
||||
"new URLSearchParams(...) expects a query string, data object, array of pairs, or URLSearchParams.",
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
return new SandboxURLSearchParams(
|
||||
new URLSearchParams(Object.fromEntries(Object.entries(data).map(([key, value]) => [key, coerceToString(value)]))),
|
||||
)
|
||||
}
|
||||
|
||||
private evaluateBinaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const lhs = yield* self.evaluateExpression(getNode(node, "left"))
|
||||
const rhs = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
// Like `typeof`, `instanceof` observes any value without coercing it (a promise or
|
||||
// function operand is a legitimate question, not an error), so it is handled before
|
||||
// the data-only operand check.
|
||||
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
|
||||
return boundedData(self.applyBinaryOperator(operator, lhs, rhs, node), "Binary expression result")
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies a binary operator to two already-evaluated operands with CodeMode's coercion
|
||||
* semantics. Shared by binary expressions and compound assignment (`x op= y` must behave
|
||||
* exactly like `x = x op y`, coercion included).
|
||||
*/
|
||||
private applyBinaryOperator(operator: string, lhs: unknown, rhs: unknown, node: AstNode): unknown {
|
||||
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
|
||||
throw new InterpreterRuntimeError("Binary operators require data values in CodeMode.", node, "InvalidDataValue")
|
||||
}
|
||||
// Data objects/arrays are null-prototype, so JS's ToPrimitive throws an opaque host
|
||||
// "No default value" TypeError when an operator coerces them. Coerce to their JS string
|
||||
// form first (as String(x) / template literals do) so operators behave like JavaScript.
|
||||
// A Date follows its ToPrimitive hints: string for `+` (concatenation), its time value
|
||||
// for arithmetic and ordering - so `end - start` and `a < b` work as in JS.
|
||||
// Identity (=== / !==) and the right operand of `in` keep their raw object value.
|
||||
const coerceOperand = (operand: unknown): unknown => {
|
||||
if (operand instanceof SandboxDate) return operator === "+" ? coerceToString(operand) : operand.time
|
||||
return operand !== null && typeof operand === "object" ? coerceToString(operand) : operand
|
||||
}
|
||||
const bothObjects = lhs !== null && typeof lhs === "object" && rhs !== null && typeof rhs === "object"
|
||||
const l = coerceOperand(lhs)
|
||||
const r = coerceOperand(rhs)
|
||||
switch (operator) {
|
||||
case "+":
|
||||
return (l as string) + (r as string)
|
||||
case "-":
|
||||
return (l as number) - (r as number)
|
||||
case "*":
|
||||
return (l as number) * (r as number)
|
||||
case "/":
|
||||
return (l as number) / (r as number)
|
||||
case "%":
|
||||
return (l as number) % (r as number)
|
||||
case "**":
|
||||
return (l as number) ** (r as number)
|
||||
// Two objects compare by identity in JS (no ToPrimitive); only object-vs-primitive coerces.
|
||||
case "==":
|
||||
return bothObjects ? lhs === rhs : l == r
|
||||
case "===":
|
||||
return lhs === rhs
|
||||
case "!=":
|
||||
return bothObjects ? lhs !== rhs : l != r
|
||||
case "!==":
|
||||
return lhs !== rhs
|
||||
case "<":
|
||||
return (l as string) < (r as string)
|
||||
case "<=":
|
||||
return (l as string) <= (r as string)
|
||||
case ">":
|
||||
return (l as string) > (r as string)
|
||||
case ">=":
|
||||
return (l as string) >= (r as string)
|
||||
case "&":
|
||||
return (l as number) & (r as number)
|
||||
case "|":
|
||||
return (l as number) | (r as number)
|
||||
case "^":
|
||||
return (l as number) ^ (r as number)
|
||||
case "<<":
|
||||
return (l as number) << (r as number)
|
||||
case ">>":
|
||||
return (l as number) >> (r as number)
|
||||
case ">>>":
|
||||
return (l as number) >>> (r as number)
|
||||
case "in":
|
||||
if (rhs === null || typeof rhs !== "object") {
|
||||
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
// Own properties only, so arrays don't leak the host Array.prototype (map/constructor/...).
|
||||
return Object.hasOwn(rhs as object, coerceOperand(lhs) as PropertyKey)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private evaluateLogicalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "left")), (left) => {
|
||||
if (operator === "&&") return left ? this.evaluateExpression(getNode(node, "right")) : Effect.succeed(left)
|
||||
if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(getNode(node, "right"))
|
||||
if (operator === "??")
|
||||
return left !== null && left !== undefined
|
||||
? Effect.succeed(left)
|
||||
: this.evaluateExpression(getNode(node, "right"))
|
||||
throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateUnaryExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const argument = getNode(node, "argument")
|
||||
// `typeof undeclaredIdentifier` is `"undefined"` in JS (never a ReferenceError), so
|
||||
// feature-detection guards like `typeof x !== "undefined"` don't crash. Short-circuit before
|
||||
// evaluating the argument; a declared-but-TDZ binding still falls through to the normal throw.
|
||||
if (operator === "typeof" && argument.type === "Identifier" && !this.resolveBinding(getString(argument, "name"))) {
|
||||
return Effect.succeed("undefined")
|
||||
}
|
||||
return Effect.map(this.evaluateExpression(argument), (value) => {
|
||||
// `typeof` and `!` never throw in JS - they observe any value (functions and runtime
|
||||
// references included) without coercing it, so feature detection and negation work.
|
||||
if (operator === "typeof") return typeofValue(value)
|
||||
if (operator === "!") return !value
|
||||
if (containsOpaqueReference(value)) {
|
||||
throw new InterpreterRuntimeError("Unary operators require data values in CodeMode.", node, "InvalidDataValue")
|
||||
}
|
||||
// Numeric/bitwise unary operators ToPrimitive their operand; a Date yields its time value
|
||||
// (`+date` is the epoch-ms idiom), other null-prototype data objects/arrays coerce to
|
||||
// their JS string form first (see evaluateBinaryExpression).
|
||||
const operand =
|
||||
value instanceof SandboxDate
|
||||
? value.time
|
||||
: value !== null && typeof value === "object"
|
||||
? coerceToString(value)
|
||||
: value
|
||||
let result: unknown
|
||||
switch (operator) {
|
||||
case "+":
|
||||
result = +(operand as number)
|
||||
break
|
||||
case "-":
|
||||
result = -(operand as number)
|
||||
break
|
||||
case "~":
|
||||
result = ~(operand as number)
|
||||
break
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
|
||||
}
|
||||
return boundedData(result, "Unary expression result")
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateAssignmentExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const left = getNode(node, "left")
|
||||
const operator = getString(node, "operator")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (operator === "??=" || operator === "||=" || operator === "&&=") {
|
||||
return yield* self.evaluateLogicalAssignment(node, left, operator)
|
||||
}
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
if (operator !== "=") {
|
||||
const current = self.getIdentifierValue(name, left)
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
const next = boundedData(
|
||||
self.applyCompoundAssignment(operator, current, rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
return self.setIdentifierValue(name, next, left)
|
||||
}
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
if (operator === "=") return self.setIdentifierValue(name, rightValue, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
return yield* self.modifyMember(left, (current) =>
|
||||
Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => {
|
||||
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
|
||||
const next = boundedData(
|
||||
self.applyCompoundAssignment(operator, current, rightValue, node),
|
||||
"Assignment result",
|
||||
)
|
||||
return { write: true, next, result: next }
|
||||
}),
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateLogicalAssignment(
|
||||
node: AstNode,
|
||||
left: AstNode,
|
||||
operator: string,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
const shouldAssign = (current: unknown): boolean =>
|
||||
operator === "??=" ? current === null || current === undefined : operator === "||=" ? !current : Boolean(current)
|
||||
if (left.type === "Identifier") {
|
||||
const name = getString(left, "name")
|
||||
return Effect.gen(function* () {
|
||||
const current = self.getIdentifierValue(name, left)
|
||||
if (!shouldAssign(current)) return current
|
||||
const rightValue = yield* self.evaluateExpression(getNode(node, "right"))
|
||||
return self.setIdentifierValue(name, rightValue, left)
|
||||
})
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
// Resolve the member exactly once; evaluate the RHS only if we actually assign.
|
||||
return self.modifyMember(left, (current) =>
|
||||
shouldAssign(current)
|
||||
? Effect.map(self.evaluateExpression(getNode(node, "right")), (rightValue) => ({
|
||||
write: true,
|
||||
next: rightValue,
|
||||
result: rightValue,
|
||||
}))
|
||||
: Effect.succeed({ write: false, next: current, result: current }),
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
|
||||
}
|
||||
|
||||
private evaluateUpdateExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const operator = getString(node, "operator")
|
||||
const argument = getNode(node, "argument")
|
||||
const prefix = getBoolean(node, "prefix")
|
||||
|
||||
const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
|
||||
|
||||
if (increment === undefined) {
|
||||
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = getString(argument, "name")
|
||||
const current = Number(this.getIdentifierValue(name, argument))
|
||||
const next = current + increment
|
||||
this.setIdentifierValue(name, next, argument)
|
||||
return prefix ? next : current
|
||||
})
|
||||
}
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = Number(current)
|
||||
const next = value + increment
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
}
|
||||
|
||||
private evaluateCallExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
const callee = getNode(node, "callee")
|
||||
const argNodes = getArray(node, "arguments")
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const callable = yield* self.evaluateExpression(callee)
|
||||
if (callable === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((callable === null || callable === undefined) && node.optional === true) return OptionalShortCircuit
|
||||
|
||||
const args = yield* self.evaluateCallArguments(argNodes)
|
||||
|
||||
if (callable instanceof ToolReference) {
|
||||
if (callable.path.length === 0) throw new InterpreterRuntimeError("The tools root is not callable.", callee)
|
||||
// An un-awaited tool call is a first-class promise value; the call itself starts now.
|
||||
return yield* self.createToolCallPromise(callable.path, args)
|
||||
}
|
||||
if (callable instanceof PromiseMethodReference) {
|
||||
return yield* self.invokePromiseMethod(callable, args, node)
|
||||
}
|
||||
if (callable instanceof CodeModeFunction) {
|
||||
return yield* self.invokeFunction(callable, args)
|
||||
}
|
||||
if (callable instanceof IntrinsicReference) {
|
||||
return yield* self.invokeIntrinsic(callable, args, node)
|
||||
}
|
||||
if (callable instanceof GlobalMethodReference) {
|
||||
if (callable.namespace === "console") return self.invokeConsole(callable.name, args, node)
|
||||
if (callable.namespace === "Object" && args[0] instanceof ToolReference) {
|
||||
return self.invokeObjectMethodOnTools(callable.name, args[0] as ToolReference, node)
|
||||
}
|
||||
if (callable.namespace === "Object" && callable.name === "assign") {
|
||||
return invokeGlobalMethod(callable, args, node)
|
||||
}
|
||||
return boundedData(invokeGlobalMethod(callable, args, node), `${callable.namespace}.${callable.name} result`)
|
||||
}
|
||||
if (callable instanceof CoercionFunction) {
|
||||
return boundedData(invokeCoercion(callable, args, node), `${callable.name} result`)
|
||||
}
|
||||
if (callable instanceof UriFunction) {
|
||||
return invokeUriFunction(callable, args, node)
|
||||
}
|
||||
// `Error("msg")` without `new` constructs an error exactly like `new Error("msg")`, as in JS.
|
||||
if (callable instanceof ErrorConstructorReference) {
|
||||
return createErrorValue(callable.name, args[0] === undefined ? "" : coerceToString(args[0]))
|
||||
}
|
||||
throw new InterpreterRuntimeError("Only tools are callable in CodeMode.", callee)
|
||||
})
|
||||
}
|
||||
|
||||
// Object.* over a tool reference: `Object.keys(tools)` / `Object.keys(tools.ns)` enumerate
|
||||
// namespace/tool names from the host tool tree - the discovery idiom a model reaches for
|
||||
// first. Every other Object helper cannot produce data from a tool reference, so it fails
|
||||
// with a pointer at the working idioms instead of the generic plain-objects-only message.
|
||||
private invokeObjectMethodOnTools(name: string, ref: ToolReference, node: AstNode): unknown {
|
||||
if (name === "keys") {
|
||||
return boundedData(this.enumerableKeys(ref)!, "Object.keys result")
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Object.${name}(...) cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or tools.$codemode.search({ query }) for signatures.`,
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
private invokeConsole(name: string, args: Array<unknown>, node: AstNode): undefined {
|
||||
if (!consoleMethods.has(name))
|
||||
throw new InterpreterRuntimeError(`console.${name} is not available in CodeMode.`, node)
|
||||
this.logs.push(publicErrorMessage(this.formatConsoleMessage(name, args, node)))
|
||||
return undefined
|
||||
}
|
||||
|
||||
private formatConsoleMessage(name: string, args: Array<unknown>, node: AstNode): string {
|
||||
if (name === "dir") return args.length === 0 ? "undefined" : this.formatConsoleArgument(args[0])
|
||||
if (name === "table") return this.formatConsoleTable(args[0], args[1], node)
|
||||
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
|
||||
return `${prefix}${args.map((arg) => this.formatConsoleArgument(arg)).join(" ")}`
|
||||
}
|
||||
|
||||
// Console arguments format deeply and totally: values render as a debugger would show them
|
||||
// rather than as boundary JSON - numbers keep NaN/Infinity (JSON would say null), sandbox
|
||||
// values keep their friendly forms at ANY depth (ISO date, /regex/flags, Map(n) [...],
|
||||
// Set(n) [...]), opaque runtime references become "[CodeMode reference]" markers in place,
|
||||
// and plain objects/arrays render JSON-style. Formatting never fails the program: cycles
|
||||
// render "[Circular]" and extreme depth degrades to "...".
|
||||
private formatConsoleArgument(value: unknown): string {
|
||||
if (value === undefined) return "undefined"
|
||||
// A top-level string prints bare; nested strings are JSON-quoted (see formatConsoleValue).
|
||||
if (typeof value === "string") return value
|
||||
return this.formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
private formatConsoleValue(value: unknown, seen: Set<object>, depth: number): string {
|
||||
// Nested undefined renders as null, matching what JSON boundary output would show.
|
||||
if (value === null || value === undefined) return "null"
|
||||
if (typeof value === "string") return JSON.stringify(value)
|
||||
// String(value) keeps NaN/Infinity/-Infinity readable; finite numbers match their JSON form.
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value)
|
||||
if (typeof value !== "object") return String(value)
|
||||
if (value instanceof SandboxPromise) return "[Promise (await it to get its value)]"
|
||||
if (value instanceof SandboxDate) return coerceToString(value)
|
||||
if (value instanceof SandboxRegExp) return coerceToString(value)
|
||||
if (value instanceof SandboxURL) return coerceToString(value)
|
||||
if (value instanceof SandboxURLSearchParams) return coerceToString(value)
|
||||
if (depth > MAX_CONSOLE_DEPTH) return "..."
|
||||
if (seen.has(value)) return "[Circular]"
|
||||
if (value instanceof SandboxMap) {
|
||||
seen.add(value)
|
||||
try {
|
||||
const entries = Array.from(value.map.entries(), ([key, item]): Array<unknown> => [key, item])
|
||||
return `Map(${value.map.size}) ${this.formatConsoleValue(entries, seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (value instanceof SandboxSet) {
|
||||
seen.add(value)
|
||||
try {
|
||||
return `Set(${value.set.size}) ${this.formatConsoleValue(Array.from(value.set.values()), seen, depth + 1)}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
if (isRuntimeReference(value)) return "[CodeMode reference]"
|
||||
seen.add(value)
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => this.formatConsoleValue(item, seen, depth + 1)).join(",")}]`
|
||||
}
|
||||
return `{${Object.entries(value)
|
||||
.map(([key, item]) => `${JSON.stringify(key)}:${this.formatConsoleValue(item, seen, depth + 1)}`)
|
||||
.join(",")}}`
|
||||
} finally {
|
||||
seen.delete(value)
|
||||
}
|
||||
}
|
||||
|
||||
private formatConsoleTable(value: unknown, columnsArgument: unknown, node: AstNode): string {
|
||||
if (value === undefined) return "undefined"
|
||||
// Sandbox values are legitimate table data (cells render their friendly forms); only
|
||||
// truly opaque references (functions, tools, promises) collapse to the marker.
|
||||
if (containsOpaqueReference(value)) return "[CodeMode reference]"
|
||||
const data = boundedData(value, "console.table argument")
|
||||
const columns = this.consoleTableColumns(columnsArgument, node)
|
||||
const rows = this.consoleTableRows(data, columns)
|
||||
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
|
||||
const header = ["(index)", ...keys].join("\t")
|
||||
return [
|
||||
header,
|
||||
...rows.map((row) => [row.index, ...keys.map((key) => this.formatConsoleTableCell(row.values[key]))].join("\t")),
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
private consoleTableColumns(value: unknown, node: AstNode): ReadonlyArray<string> | undefined {
|
||||
if (value === undefined) return undefined
|
||||
if (containsRuntimeReference(value)) return undefined
|
||||
const columns = copyOut(copyIn(value, "console.table columns"), true)
|
||||
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
|
||||
}
|
||||
|
||||
private consoleTableRows(
|
||||
data: unknown,
|
||||
columns: ReadonlyArray<string> | undefined,
|
||||
): Array<{ readonly index: string; readonly values: Record<string, unknown> }> {
|
||||
if (Array.isArray(data)) {
|
||||
return data.map((item, index) => ({ index: String(index), values: this.consoleTableValues(item, columns) }))
|
||||
}
|
||||
if (data !== null && typeof data === "object" && !isSandboxValue(data)) {
|
||||
return Object.entries(data).map(([index, item]) => ({ index, values: this.consoleTableValues(item, columns) }))
|
||||
}
|
||||
return [{ index: "0", values: { Value: data } }]
|
||||
}
|
||||
|
||||
private consoleTableValues(value: unknown, columns: ReadonlyArray<string> | undefined): Record<string, unknown> {
|
||||
if (value !== null && typeof value === "object" && !Array.isArray(value) && !isSandboxValue(value)) {
|
||||
const source = value as Record<string, unknown>
|
||||
if (columns !== undefined) return Object.fromEntries(columns.map((column) => [column, source[column]]))
|
||||
return Object.fromEntries(Object.entries(source))
|
||||
}
|
||||
return { Value: value }
|
||||
}
|
||||
|
||||
private formatConsoleTableCell(value: unknown): string {
|
||||
if (value === undefined) return ""
|
||||
if (typeof value === "string") return value
|
||||
return this.formatConsoleValue(value, new Set(), 0)
|
||||
}
|
||||
|
||||
private evaluateCallArguments(argNodes: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const args: Array<unknown> = []
|
||||
for (const [index, arg] of argNodes.entries()) {
|
||||
const argNode = asNode(arg, `arguments[${index}]`)
|
||||
if (argNode.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(argNode, "argument"))
|
||||
const items = spreadItems(spread)
|
||||
if (items === undefined)
|
||||
throw new InterpreterRuntimeError(
|
||||
"Spread arguments require an array, string, Map, or Set in CodeMode.",
|
||||
argNode,
|
||||
)
|
||||
args.push(...items)
|
||||
} else {
|
||||
args.push(yield* self.evaluateExpression(argNode))
|
||||
}
|
||||
}
|
||||
return args
|
||||
})
|
||||
}
|
||||
|
||||
// Promise.* over ordinary runtime values. Combinators accept ANY array (or spreadable
|
||||
// collection) mixing promise values and plain data - built inline, beforehand, via spread,
|
||||
// whatever - because tool calls already run eagerly on their own fibers; the combinators
|
||||
// only observe settlements. Joining is therefore sequential (no extra fibers) without
|
||||
// costing parallelism, and the concurrency cap stays where the work is: the fork semaphore.
|
||||
private invokePromiseMethod(
|
||||
ref: PromiseMethodReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
if (ref.name === "resolve") {
|
||||
// Promise.resolve of a promise is that promise (JS flattens); anything else is a
|
||||
// promise already fulfilled with the value.
|
||||
const value = args[0]
|
||||
return Effect.succeed(
|
||||
value instanceof SandboxPromise ? value : new SandboxPromise(undefined, Effect.succeed(value)),
|
||||
)
|
||||
}
|
||||
if (ref.name === "reject") {
|
||||
return Effect.sync(() => new SandboxPromise(undefined, Effect.fail(new ProgramThrow(args[0]))))
|
||||
}
|
||||
|
||||
const items = Array.isArray(args[0]) ? args[0] : spreadItems(args[0])
|
||||
if (items === undefined) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.${ref.name} expects an array of promises or plain values (e.g. Promise.${ref.name}(items.map((item) => tools.ns.tool(item)))).`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
switch (ref.name) {
|
||||
case "all": {
|
||||
// Mark every promise element observed up-front (Promise.all handles all of its
|
||||
// members' failures, as in JS), then join in index order; the first failure rejects
|
||||
// the whole call while unrelated in-flight members keep running.
|
||||
const settles = items.map((item) =>
|
||||
item instanceof SandboxPromise ? this.settlePromise(item, node) : Effect.succeed(item),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const values: Array<unknown> = []
|
||||
for (const settle of settles) values.push(yield* settle)
|
||||
return values
|
||||
})
|
||||
}
|
||||
case "allSettled": {
|
||||
const observations = items.map((item) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ promise: item as SandboxPromise | undefined, exit }))
|
||||
: Effect.succeed({ promise: undefined as SandboxPromise | undefined, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const outcomes: Array<unknown> = []
|
||||
for (const observation of observations) {
|
||||
const { exit, promise } = yield* observation
|
||||
if (Exit.isSuccess(exit)) {
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, { status: "fulfilled", value: exit.value }),
|
||||
)
|
||||
continue
|
||||
}
|
||||
const raceInterrupted = promise?.interrupted === true && Cause.hasInterruptsOnly(exit.cause)
|
||||
if (Cause.hasInterruptsOnly(exit.cause) && !raceInterrupted) {
|
||||
// Execution teardown (timeout/host interruption), not a program-level rejection.
|
||||
return yield* Effect.failCause(exit.cause)
|
||||
}
|
||||
const thrown = raceInterrupted
|
||||
? new InterpreterRuntimeError(
|
||||
"This tool call was interrupted because another value settled a Promise.race first.",
|
||||
node,
|
||||
)
|
||||
: Cause.squash(exit.cause)
|
||||
outcomes.push(
|
||||
Object.assign(Object.create(null) as SafeObject, {
|
||||
status: "rejected",
|
||||
reason: caughtErrorValue(thrown),
|
||||
}),
|
||||
)
|
||||
}
|
||||
return outcomes
|
||||
})
|
||||
}
|
||||
case "race": {
|
||||
if (items.length === 0) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Promise.race([]) would never settle; provide at least one promise or value.",
|
||||
node,
|
||||
)
|
||||
}
|
||||
const observations = items.map((item, index) =>
|
||||
item instanceof SandboxPromise
|
||||
? Effect.map(this.observePromise(item), (exit) => ({ index, exit }))
|
||||
: Effect.succeed({ index, exit: Exit.succeed(item as unknown) }),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
// First settlement (fulfilled OR rejected) wins; the observations never fail, so
|
||||
// racing them yields exactly that. Losing in-flight calls are then interrupted.
|
||||
const winner = yield* Effect.raceAll(observations)
|
||||
for (const [index, item] of items.entries()) {
|
||||
if (index === winner.index || !(item instanceof SandboxPromise) || item.fiber === undefined) continue
|
||||
item.interrupted = true
|
||||
yield* Fiber.interrupt(item.fiber)
|
||||
}
|
||||
const winningItem = items[winner.index]
|
||||
return yield* self.unwrapPromiseExit(
|
||||
winningItem instanceof SandboxPromise ? winningItem : undefined,
|
||||
winner.exit,
|
||||
node,
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private invokeFunction(fn: CodeModeFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.suspend(() => {
|
||||
const savedScopes = self.scopes
|
||||
self.scopes = [...fn.capturedScopes, new Map<string, Binding>()]
|
||||
const run = Effect.gen(function* () {
|
||||
// Seed every parameter name into the scope as a TDZ slot first, so a default that
|
||||
// references another parameter resolves to that (uninitialized) param rather than
|
||||
// silently falling through to an outer binding of the same name - matching JS.
|
||||
const paramScope = self.currentScope()
|
||||
for (const parameter of fn.parameters) {
|
||||
for (const name of collectPatternNames(parameter)) {
|
||||
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
|
||||
}
|
||||
}
|
||||
for (const [index, parameter] of fn.parameters.entries()) {
|
||||
if (parameter.type === "RestElement") {
|
||||
yield* self.declarePattern(getNode(parameter, "argument"), args.slice(index), true, parameter)
|
||||
break
|
||||
}
|
||||
yield* self.declarePattern(parameter, args[index], true, parameter)
|
||||
}
|
||||
|
||||
if (fn.body.type === "BlockStatement") {
|
||||
const result = yield* self.evaluateStatement(fn.body)
|
||||
return result.kind === "return" || result.kind === "value" ? result.value : undefined
|
||||
}
|
||||
|
||||
return yield* self.evaluateExpression(fn.body)
|
||||
})
|
||||
return run.pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
self.scopes = savedScopes
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private invokeIntrinsic(
|
||||
ref: IntrinsicReference,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
if (typeof ref.receiver === "string") {
|
||||
if (
|
||||
(ref.name === "replace" || ref.name === "replaceAll") &&
|
||||
(args[1] instanceof CodeModeFunction || args[1] instanceof CoercionFunction || args[1] instanceof UriFunction)
|
||||
) {
|
||||
return this.invokeStringReplacer(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
return Effect.succeed(invokeStringMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (typeof ref.receiver === "number") {
|
||||
return Effect.succeed(invokeNumberMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (Array.isArray(ref.receiver)) {
|
||||
return this.invokeArrayMethod(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxDate) {
|
||||
return Effect.succeed(invokeDateMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxRegExp) {
|
||||
return Effect.succeed(invokeRegExpMethod(ref.receiver, ref.name, args, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxMap) {
|
||||
return this.invokeMapMethod(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxSet) {
|
||||
return this.invokeSetMethod(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURL) {
|
||||
return Effect.succeed(invokeURLMethod(ref.receiver, ref.name, node))
|
||||
}
|
||||
if (ref.receiver instanceof SandboxURLSearchParams) {
|
||||
return this.invokeURLSearchParamsMethod(ref.receiver, ref.name, args, node)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Method '${ref.name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
|
||||
private invokeStringReplacer(
|
||||
value: string,
|
||||
name: "replace" | "replaceAll",
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const apply = this.applyCollectionCallback(args[1], `String.${name}`, node)
|
||||
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
|
||||
const collect = (...callbackArgs: Array<unknown>): string => {
|
||||
const match = callbackArgs[0]
|
||||
const groups = callbackArgs[callbackArgs.length - 1]
|
||||
const hasGroups = groups !== null && typeof groups === "object"
|
||||
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
|
||||
if (typeof match !== "string" || typeof offset !== "number") {
|
||||
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
|
||||
}
|
||||
if (hasGroups) {
|
||||
const safeGroups: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, group] of Object.entries(groups)) {
|
||||
if (!isBlockedMember(key)) safeGroups[key] = group
|
||||
}
|
||||
callbackArgs[callbackArgs.length - 1] = safeGroups
|
||||
}
|
||||
matches.push({ match, offset, args: callbackArgs })
|
||||
return match
|
||||
}
|
||||
|
||||
const pattern = args[0]
|
||||
if (pattern instanceof SandboxRegExp) {
|
||||
if (name === "replaceAll" && !pattern.regex.global) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.regex.source}/${pattern.regex.flags}g, or use String.replace to replace only the first match.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern.regex, collect)
|
||||
else value.replaceAll(pattern.regex, collect)
|
||||
} else {
|
||||
if (typeof pattern !== "string") {
|
||||
throw new InterpreterRuntimeError(`String.${name} expects argument 1 to be a string.`, node)
|
||||
}
|
||||
if (name === "replace") value.replace(pattern, collect)
|
||||
else value.replaceAll(pattern, collect)
|
||||
}
|
||||
|
||||
return Effect.gen(function* () {
|
||||
const output: Array<string> = []
|
||||
let end = 0
|
||||
for (const match of matches) {
|
||||
output.push(
|
||||
value.slice(end, match.offset),
|
||||
coerceToString(boundedData(yield* apply(match.args), `String.${name} replacer result`)),
|
||||
)
|
||||
end = match.offset + match.match.length
|
||||
}
|
||||
output.push(value.slice(end))
|
||||
return boundedData(output.join(""), `String.${name} result`)
|
||||
})
|
||||
}
|
||||
|
||||
// Runs a collection callback accepting a user function or supported builtin callable,
|
||||
// mirroring the array-method callback contract.
|
||||
private applyCollectionCallback(
|
||||
callback: unknown,
|
||||
name: string,
|
||||
node: AstNode,
|
||||
): (args: Array<unknown>) => Effect.Effect<unknown, unknown, R> {
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof UriFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
|
||||
}
|
||||
return (callbackArgs) =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: this.invokeFunction(callback, callbackArgs)
|
||||
}
|
||||
|
||||
private invokeMapMethod(
|
||||
target: SandboxMap,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
switch (name) {
|
||||
case "get":
|
||||
return Effect.succeed(target.map.get(args[0]))
|
||||
case "has":
|
||||
return Effect.succeed(target.map.has(args[0]))
|
||||
case "set":
|
||||
return Effect.sync(() => {
|
||||
target.map.set(args[0], args[1])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.map.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.map.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.map.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.map.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.map.entries(), ([key, item]): Array<unknown> => [key, item]))
|
||||
case "forEach": {
|
||||
const apply = this.applyCollectionCallback(args[0], "Map.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
// Snapshot iteration, matching the array-method callback contract.
|
||||
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Map method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private invokeSetMethod(
|
||||
target: SandboxSet,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
switch (name) {
|
||||
case "has":
|
||||
return Effect.succeed(target.set.has(args[0]))
|
||||
case "add":
|
||||
return Effect.sync(() => {
|
||||
target.set.add(args[0])
|
||||
return target
|
||||
})
|
||||
case "delete":
|
||||
return Effect.sync(() => target.set.delete(args[0]))
|
||||
case "clear":
|
||||
return Effect.sync(() => {
|
||||
target.set.clear()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.set.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.set.values(), (item): Array<unknown> => [item, item]))
|
||||
case "forEach": {
|
||||
const apply = this.applyCollectionCallback(args[0], "Set.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Set method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private invokeURLSearchParamsMethod(
|
||||
target: SandboxURLSearchParams,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const arg = (index: number): string => uriArgument(args[index], `URLSearchParams.${name} argument ${index + 1}`)
|
||||
const requireArgs = (count: number): void => {
|
||||
if (args.length < count) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
|
||||
node,
|
||||
).as("TypeError")
|
||||
}
|
||||
}
|
||||
switch (name) {
|
||||
case "append": {
|
||||
requireArgs(2)
|
||||
return Effect.sync(() => {
|
||||
target.params.append(arg(0), arg(1))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "delete": {
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => {
|
||||
if (args[1] !== undefined) target.params.delete(arg(0), arg(1))
|
||||
else target.params.delete(arg(0))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "get":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => target.params.get(arg(0)))
|
||||
case "getAll":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() => target.params.getAll(arg(0)))
|
||||
case "has":
|
||||
requireArgs(1)
|
||||
return Effect.sync(() =>
|
||||
args[1] !== undefined ? target.params.has(arg(0), arg(1)) : target.params.has(arg(0)),
|
||||
)
|
||||
case "set": {
|
||||
requireArgs(2)
|
||||
return Effect.sync(() => {
|
||||
target.params.set(arg(0), arg(1))
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
case "sort":
|
||||
return Effect.sync(() => {
|
||||
target.params.sort()
|
||||
return undefined
|
||||
})
|
||||
case "keys":
|
||||
return Effect.sync(() => Array.from(target.params.keys()))
|
||||
case "values":
|
||||
return Effect.sync(() => Array.from(target.params.values()))
|
||||
case "entries":
|
||||
return Effect.sync(() => Array.from(target.params.entries(), ([key, value]): Array<unknown> => [key, value]))
|
||||
case "toString":
|
||||
return Effect.sync(() => target.params.toString())
|
||||
case "forEach": {
|
||||
requireArgs(1)
|
||||
const apply = this.applyCollectionCallback(args[0], "URLSearchParams.forEach", node)
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`URLSearchParams method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
private invokeArrayMethod(
|
||||
target: Array<unknown>,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const optNumber = (value: unknown, label: string): number | undefined => {
|
||||
if (value === undefined) return undefined
|
||||
if (typeof value !== "number")
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
|
||||
return value
|
||||
}
|
||||
switch (name) {
|
||||
case "join": {
|
||||
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
|
||||
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
|
||||
}
|
||||
const input = boundedData(target, "Array.join input") as Array<unknown>
|
||||
return Effect.succeed(
|
||||
input.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string)),
|
||||
)
|
||||
}
|
||||
case "includes":
|
||||
if (args.length === 0 || args.length > 2)
|
||||
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
|
||||
return Effect.succeed(target.includes(args[0], optNumber(args[1], "start index")))
|
||||
case "indexOf":
|
||||
return Effect.succeed(target.indexOf(args[0], optNumber(args[1], "start index")))
|
||||
case "lastIndexOf":
|
||||
return Effect.succeed(
|
||||
args[1] === undefined
|
||||
? target.lastIndexOf(args[0])
|
||||
: target.lastIndexOf(args[0], optNumber(args[1], "start index")),
|
||||
)
|
||||
case "at":
|
||||
return Effect.succeed(target.at(optNumber(args[0], "index") ?? 0))
|
||||
case "slice":
|
||||
return Effect.succeed(target.slice(optNumber(args[0], "start"), optNumber(args[1], "end")))
|
||||
case "concat":
|
||||
return Effect.succeed(target.concat(...args))
|
||||
case "flat":
|
||||
return Effect.succeed(target.flat(optNumber(args[0], "depth") ?? 1))
|
||||
case "reverse":
|
||||
return Effect.succeed(target.reverse())
|
||||
case "sort":
|
||||
return Effect.map(this.sortArray(target, args[0], node), (sorted) => {
|
||||
target.splice(0, target.length, ...sorted)
|
||||
return target
|
||||
})
|
||||
case "toSorted":
|
||||
return this.sortArray(target, args[0], node)
|
||||
case "toReversed":
|
||||
return Effect.succeed([...target].reverse())
|
||||
case "with": {
|
||||
const index = optNumber(args[0], "index") ?? 0
|
||||
const resolved = index < 0 ? target.length + index : index
|
||||
if (resolved < 0 || resolved >= target.length) {
|
||||
throw new InterpreterRuntimeError("Array.with index is out of range.", node)
|
||||
}
|
||||
const copied = [...target]
|
||||
copied[resolved] = args[1]
|
||||
return Effect.succeed(copied)
|
||||
}
|
||||
case "push": {
|
||||
// Validate before mutating (so no rollback is needed): inserting a container into
|
||||
// itself would create a cycle no later walk could survive.
|
||||
for (const item of args) this.rejectCircularInsertion(target, item, "Array.push result", node)
|
||||
target.push(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "unshift": {
|
||||
for (const item of args) this.rejectCircularInsertion(target, item, "Array.unshift result", node)
|
||||
target.unshift(...args)
|
||||
return Effect.succeed(target.length)
|
||||
}
|
||||
case "pop":
|
||||
return Effect.succeed(target.pop())
|
||||
case "shift":
|
||||
return Effect.succeed(target.shift())
|
||||
case "splice": {
|
||||
// Mutates in place and returns the removed elements, exactly like JS: one argument
|
||||
// removes to the end, an undefined delete count removes nothing.
|
||||
if (args.length === 0) return Effect.succeed(target.splice(0, 0))
|
||||
const start = optNumber(args[0], "start") ?? 0
|
||||
if (args.length === 1) return Effect.succeed(target.splice(start))
|
||||
const deleteCount = optNumber(args[1], "delete count") ?? 0
|
||||
const inserted = args.slice(2)
|
||||
for (const item of inserted) this.rejectCircularInsertion(target, item, "Array.splice result", node)
|
||||
return Effect.succeed(target.splice(start, deleteCount, ...inserted))
|
||||
}
|
||||
case "fill": {
|
||||
this.rejectCircularInsertion(target, args[0], "Array.fill result", node)
|
||||
return Effect.succeed(target.fill(args[0], optNumber(args[1], "start"), optNumber(args[2], "end")))
|
||||
}
|
||||
case "copyWithin":
|
||||
return Effect.succeed(
|
||||
target.copyWithin(
|
||||
optNumber(args[0], "target index") ?? 0,
|
||||
optNumber(args[1], "start") ?? 0,
|
||||
optNumber(args[2], "end"),
|
||||
),
|
||||
)
|
||||
// keys/values/entries return arrays (not iterators), matching the Map/Set convention;
|
||||
// they work with for...of and spread either way.
|
||||
case "keys":
|
||||
return Effect.succeed(Array.from(target.keys()))
|
||||
case "values":
|
||||
return Effect.succeed([...target])
|
||||
case "entries":
|
||||
return Effect.succeed(Array.from(target.entries(), ([index, item]): Array<unknown> => [index, item]))
|
||||
}
|
||||
|
||||
const callback = args[0]
|
||||
if (
|
||||
!(callback instanceof CodeModeFunction) &&
|
||||
!(callback instanceof CoercionFunction) &&
|
||||
!(callback instanceof UriFunction)
|
||||
) {
|
||||
throw new InterpreterRuntimeError(`Array.${name} expects a function callback.`, node)
|
||||
}
|
||||
const self = this
|
||||
// Accept a user function or supported builtin callable, so idioms such as
|
||||
// `filter(Boolean)`, `map(String)`, and `map(encodeURIComponent)` work as in JS. Builtins
|
||||
// are synchronous; only CodeModeFunctions can await tool calls.
|
||||
const apply = (callbackArgs: Array<unknown>): Effect.Effect<unknown, unknown, R> =>
|
||||
callback instanceof CoercionFunction
|
||||
? Effect.succeed(invokeCoercion(callback, callbackArgs, node))
|
||||
: callback instanceof UriFunction
|
||||
? Effect.succeed(invokeUriFunction(callback, callbackArgs, node))
|
||||
: self.invokeFunction(callback, callbackArgs)
|
||||
return Effect.gen(function* () {
|
||||
// Capture the initial length, but read the receiver live so callbacks observe mutations
|
||||
// without visiting elements appended after iteration begins.
|
||||
const length = target.length
|
||||
switch (name) {
|
||||
case "map": {
|
||||
const values: Array<unknown> = []
|
||||
values.length = length
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
values[index] = yield* apply([target[index], index, target])
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "flatMap": {
|
||||
const values: Array<unknown> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const mapped = yield* apply([target[index], index, target])
|
||||
if (Array.isArray(mapped)) values.push(...mapped)
|
||||
else values.push(mapped)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "filter": {
|
||||
const values: Array<unknown> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) values.push(item)
|
||||
}
|
||||
return values
|
||||
}
|
||||
case "find":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const item = target[index]
|
||||
if (yield* apply([item, index, target])) return item
|
||||
}
|
||||
return undefined
|
||||
case "findIndex":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (yield* apply([target[index], index, target])) return index
|
||||
}
|
||||
return -1
|
||||
case "some":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
if (yield* apply([target[index], index, target])) return true
|
||||
}
|
||||
return false
|
||||
case "every":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
if (!(yield* apply([target[index], index, target]))) return false
|
||||
}
|
||||
return true
|
||||
case "forEach":
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (index in target) yield* apply([target[index], index, target])
|
||||
}
|
||||
return undefined
|
||||
case "reduce": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = 0
|
||||
} else {
|
||||
if (length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
|
||||
accumulator = target[0]
|
||||
start = 1
|
||||
}
|
||||
for (let index = start; index < length; index += 1) {
|
||||
if (!(index in target)) continue
|
||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "reduceRight": {
|
||||
let accumulator: unknown
|
||||
let start: number
|
||||
if (args.length >= 2) {
|
||||
accumulator = args[1]
|
||||
start = length - 1
|
||||
} else {
|
||||
if (length === 0)
|
||||
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
|
||||
accumulator = target[length - 1]
|
||||
start = length - 2
|
||||
}
|
||||
for (let index = start; index >= 0; index -= 1) {
|
||||
if (!(index in target)) continue
|
||||
accumulator = yield* apply([accumulator, target[index], index, target])
|
||||
}
|
||||
return accumulator
|
||||
}
|
||||
case "findLast":
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([target[index], index, target])) return target[index]
|
||||
}
|
||||
return undefined
|
||||
case "findLastIndex":
|
||||
for (let index = length - 1; index >= 0; index -= 1) {
|
||||
if (yield* apply([target[index], index, target])) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Array method '${name}' is not available in CodeMode.`, node)
|
||||
})
|
||||
}
|
||||
|
||||
private sortArray(
|
||||
target: Array<unknown>,
|
||||
comparator: unknown,
|
||||
node: AstNode,
|
||||
): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
if (comparator !== undefined && !(comparator instanceof CodeModeFunction)) {
|
||||
throw new InterpreterRuntimeError("Array.sort expects an arrow function comparator.", node)
|
||||
}
|
||||
if (!(comparator instanceof CodeModeFunction)) {
|
||||
return Effect.sync(() =>
|
||||
[...target].sort((a, b) => {
|
||||
const left = coerceToString(a)
|
||||
const right = coerceToString(b)
|
||||
return left < right ? -1 : left > right ? 1 : 0
|
||||
}),
|
||||
)
|
||||
}
|
||||
const self = this
|
||||
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, 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> = []
|
||||
let leftIndex = 0
|
||||
let rightIndex = 0
|
||||
while (leftIndex < left.length && rightIndex < right.length) {
|
||||
// Coerce the comparator's result like JS ToNumber (data objects -> NaN, never a host
|
||||
// crash) and treat NaN as 0 - the spec's "no consistent order" -> keep the left element.
|
||||
const order = coerceToNumber(yield* self.invokeFunction(comparator, [left[leftIndex], right[rightIndex]]))
|
||||
if (Number.isNaN(order) || order <= 0) merged.push(left[leftIndex++])
|
||||
else merged.push(right[rightIndex++])
|
||||
}
|
||||
return [...merged, ...left.slice(leftIndex), ...right.slice(rightIndex)]
|
||||
})
|
||||
}
|
||||
// Per spec, undefined elements sort to the end and the comparator is never called on them.
|
||||
const defined = target.filter((item) => item !== undefined)
|
||||
const undefinedCount = target.length - defined.length
|
||||
return Effect.map(mergeSort(defined), (items) => [...items, ...Array(undefinedCount).fill(undefined)])
|
||||
}
|
||||
|
||||
private evaluateObjectExpression(node: AstNode): Effect.Effect<Record<string, unknown>, unknown, R> {
|
||||
const objectValue: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
||||
const properties = getArray(node, "properties")
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const propertyValue of properties) {
|
||||
const property = asNode(propertyValue, "properties")
|
||||
|
||||
if (property.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(property, "argument"))
|
||||
// JS treats `{ ...null }` / `{ ...undefined }` as a no-op, so the common
|
||||
// `{ ...maybeOpts, override }` merge works when the operand is absent. Sandbox values
|
||||
// have no own enumerable properties in JS, so they are no-ops too.
|
||||
if (spread === null || spread === undefined || isSandboxValue(spread)) continue
|
||||
if (typeof spread !== "object" || Array.isArray(spread) || isRuntimeReference(spread)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Object spread requires a data object in CodeMode.",
|
||||
property,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
for (const [key, value] of Object.entries(spread)) {
|
||||
if (isBlockedMember(key))
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, property)
|
||||
objectValue[key] = value
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (property.type !== "Property") {
|
||||
throw new InterpreterRuntimeError("Only standard object properties are supported.", property)
|
||||
}
|
||||
|
||||
if (getString(property, "kind") !== "init") {
|
||||
throw new InterpreterRuntimeError("Only init object properties are supported.", property)
|
||||
}
|
||||
|
||||
const keyNode = getNode(property, "key")
|
||||
const valueNode = getNode(property, "value")
|
||||
const computed = getBoolean(property, "computed")
|
||||
|
||||
let key: PropertyKey
|
||||
|
||||
if (computed) {
|
||||
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
|
||||
} else if (keyNode.type === "Identifier") {
|
||||
key = getString(keyNode, "name")
|
||||
} else if (keyNode.type === "Literal") {
|
||||
key = self.toPropertyKey(keyNode.value, keyNode)
|
||||
} else {
|
||||
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
|
||||
}
|
||||
|
||||
if (isBlockedMember(String(key))) {
|
||||
throw new InterpreterRuntimeError(`Property '${String(key)}' is not available in CodeMode.`, keyNode)
|
||||
}
|
||||
objectValue[String(key)] = yield* self.evaluateExpression(valueNode)
|
||||
}
|
||||
|
||||
return objectValue
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateArrayExpression(node: AstNode): Effect.Effect<Array<unknown>, unknown, R> {
|
||||
const elements = getArray(node, "elements")
|
||||
const values: Array<unknown> = []
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (const elementValue of elements) {
|
||||
if (elementValue === null) {
|
||||
values.push(undefined)
|
||||
continue
|
||||
}
|
||||
const element = asNode(elementValue, "elements")
|
||||
if (element.type === "SpreadElement") {
|
||||
const spread = yield* self.evaluateExpression(getNode(element, "argument"))
|
||||
const items = spreadItems(spread)
|
||||
if (items === undefined)
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array spread requires an array, string, Map, or Set in CodeMode.",
|
||||
element,
|
||||
)
|
||||
values.push(...items)
|
||||
} else {
|
||||
values.push(yield* self.evaluateExpression(element))
|
||||
}
|
||||
}
|
||||
return values
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateTemplateLiteral(node: AstNode): Effect.Effect<string, unknown, R> {
|
||||
const quasis = getArray(node, "quasis")
|
||||
const expressions = getArray(node, "expressions")
|
||||
|
||||
let output = ""
|
||||
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
for (let index = 0; index < quasis.length; index += 1) {
|
||||
const quasi = asNode(quasis[index], "quasis")
|
||||
const rawValue = quasi.value
|
||||
|
||||
if (!isRecord(rawValue) || typeof rawValue.cooked !== "string") {
|
||||
throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
|
||||
}
|
||||
|
||||
output += rawValue.cooked
|
||||
|
||||
if (index < expressions.length) {
|
||||
const raw = yield* self.evaluateExpression(asNode(expressions[index], "expressions"))
|
||||
// The preserving checkpoint keeps sandbox values intact, so coerceToString renders
|
||||
// them directly (ISO date, /regex/ literal form) instead of a JSON-serialized husk.
|
||||
output += coerceToString(boundedData(raw, "Template interpolation"))
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
})
|
||||
}
|
||||
|
||||
private evaluateConditionalExpression(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.flatMap(this.evaluateExpression(getNode(node, "test")), (test) =>
|
||||
this.evaluateExpression(getNode(node, test ? "consequent" : "alternate")),
|
||||
)
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
|
||||
// `x op= y` is `x = x op y`: dispatch through the shared binary operator implementation
|
||||
// so compound assignment inherits the same coercion semantics (Dates, data objects, ...).
|
||||
// Only the arithmetic/bitwise operators are compoundable; logical assignments (&&=/||=/??=)
|
||||
// short-circuit and are handled by evaluateLogicalAssignment before reaching here.
|
||||
if (!compoundOperators.has(operator)) {
|
||||
throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
|
||||
}
|
||||
|
||||
private getMemberReference(
|
||||
node: AstNode,
|
||||
): Effect.Effect<
|
||||
| MemberReference
|
||||
| ToolReference
|
||||
| PromiseMethodReference
|
||||
| IntrinsicReference
|
||||
| GlobalMethodReference
|
||||
| ComputedValue
|
||||
| typeof OptionalShortCircuit
|
||||
| undefined,
|
||||
unknown,
|
||||
R
|
||||
> {
|
||||
const objectNode = getNode(node, "object")
|
||||
const propertyNode = getNode(node, "property")
|
||||
const computed = getBoolean(node, "computed")
|
||||
const optional = node.optional === true
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const objectValue = yield* self.evaluateExpression(objectNode)
|
||||
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((objectValue === null || objectValue === undefined) && optional) return OptionalShortCircuit
|
||||
|
||||
const key = computed
|
||||
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
: propertyNode.type === "Identifier"
|
||||
? getString(propertyNode, "name")
|
||||
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode), propertyNode)
|
||||
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError("Tool paths must use safe string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
|
||||
if (objectValue instanceof PromiseNamespace) {
|
||||
if (typeof key === "string" && promiseStatics.has(key as PromiseMethodName)) {
|
||||
return new PromiseMethodReference(key as PromiseMethodName)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.${String(key)} is not available in CodeMode. Available: Promise.all, Promise.allSettled, Promise.race, Promise.resolve, and Promise.reject; consume promises with await.`,
|
||||
propertyNode,
|
||||
)
|
||||
}
|
||||
|
||||
if (objectValue instanceof GlobalNamespace) {
|
||||
if (typeof key !== "string" || isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${objectValue.name}.${String(key)} is not available in CodeMode.`,
|
||||
propertyNode,
|
||||
)
|
||||
}
|
||||
if (objectValue.name === "Math" && mathConstants.has(key)) {
|
||||
return new ComputedValue((Math as unknown as Record<string, number>)[key])
|
||||
}
|
||||
return new GlobalMethodReference(objectValue.name, key)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return new ComputedValue(objectValue.length)
|
||||
if (typeof key === "number") return new ComputedValue(objectValue[key])
|
||||
if (typeof key === "string" && /^\d+$/.test(key)) return new ComputedValue(objectValue[Number(key)])
|
||||
if (typeof key === "string" && stringMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
// Unknown property on a string reads as `undefined`, matching JS (`"x".foo === undefined`),
|
||||
// instead of throwing - so defensive access like `result?.login ?? result` on a JSON-string
|
||||
// tool result doesn't crash. (Optional chaining only guards null/undefined receivers, so a
|
||||
// real string still reaches here.) Only the method allowlist above yields callables.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
if (typeof objectValue === "number") {
|
||||
if (typeof key === "string" && numberMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
// Unknown property on a number reads as `undefined`, matching JS, rather than throwing.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
// Number / String expose a small allowlist of statics; everything else stays opaque.
|
||||
if (objectValue instanceof CoercionFunction && typeof key === "string" && !isBlockedMember(key)) {
|
||||
if (objectValue.name === "Number" && numberConstants.has(key)) {
|
||||
return new ComputedValue((Number as unknown as Record<string, number>)[key])
|
||||
}
|
||||
if (objectValue.name === "Number" && numberStatics.has(key)) return new GlobalMethodReference("Number", key)
|
||||
if (objectValue.name === "String" && stringStatics.has(key)) return new GlobalMethodReference("String", key)
|
||||
}
|
||||
|
||||
// Sandbox value types expose their method/property allowlists; any other key reads as
|
||||
// `undefined`, consistent with unknown-property reads on strings/numbers/arrays.
|
||||
if (objectValue instanceof SandboxDate) {
|
||||
if (typeof key === "string" && dateMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxRegExp) {
|
||||
if (typeof key === "string" && regexpProperties.has(key)) {
|
||||
return new ComputedValue((objectValue.regex as unknown as Record<string, unknown>)[key])
|
||||
}
|
||||
if (typeof key === "string" && regexpMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxMap) {
|
||||
if (key === "size") return new ComputedValue(objectValue.map.size)
|
||||
if (typeof key === "string" && mapMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxSet) {
|
||||
if (key === "size") return new ComputedValue(objectValue.set.size)
|
||||
if (typeof key === "string" && setMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxURL) {
|
||||
if (key === "searchParams") {
|
||||
return new ComputedValue(objectValue.searchParams)
|
||||
}
|
||||
if (typeof key === "string" && urlMethods.has(key)) return new IntrinsicReference(objectValue, key)
|
||||
if (typeof key === "string" && urlProperties.has(key)) return { target: objectValue, key }
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
if (objectValue instanceof SandboxURLSearchParams) {
|
||||
if (key === "size") return new ComputedValue(objectValue.params.size)
|
||||
if (typeof key === "string" && urlSearchParamsMethods.has(key)) {
|
||||
return new IntrinsicReference(objectValue, key)
|
||||
}
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
|
||||
// Any property access on a promise is a confused program (`p.then(...)`, `p.value`);
|
||||
// reading `undefined` here would hide the missing await, so both paths get an explicit,
|
||||
// await-hinting error instead of the forgiving unknown-property fallthrough.
|
||||
if (objectValue instanceof SandboxPromise) {
|
||||
if (key === "then" || key === "catch" || key === "finally") {
|
||||
throw new InterpreterRuntimeError(
|
||||
`Promise.prototype.${String(key)} is not supported in CodeMode; use await instead (with try/catch to handle failures) - e.g. \`const result = await tools.ns.tool(...)\`.`,
|
||||
propertyNode,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
"This value is an un-awaited Promise and has no readable properties; await it first - e.g. `const result = await tools.ns.tool(...)`.",
|
||||
objectNode,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
if (isRuntimeReference(objectValue)) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"CodeMode runtime references are opaque and do not expose properties.",
|
||||
objectNode,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
|
||||
if (typeof objectValue !== "object" || objectValue === null) {
|
||||
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
|
||||
}
|
||||
|
||||
if (typeof key === "string" && isBlockedMember(key)) {
|
||||
throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, propertyNode)
|
||||
}
|
||||
|
||||
if (Array.isArray(objectValue)) {
|
||||
if (
|
||||
key !== "length" &&
|
||||
!(typeof key === "string" && arrayMethods.has(key)) &&
|
||||
typeof key !== "number" &&
|
||||
!/^\d+$/.test(key)
|
||||
) {
|
||||
// Own non-index properties read through (match results carry index/groups); like JS,
|
||||
// they are readable in place and dropped by JSON at data boundaries.
|
||||
if (typeof key === "string" && Object.hasOwn(objectValue, key)) {
|
||||
return new ComputedValue((objectValue as Record<string, unknown> & Array<unknown>)[key])
|
||||
}
|
||||
// Unknown property on an array reads as `undefined`, matching JS (`[1,2].foo === undefined`),
|
||||
// instead of throwing - so defensive access under optional chaining behaves as expected.
|
||||
return new ComputedValue(undefined)
|
||||
}
|
||||
return { target: objectValue, key }
|
||||
}
|
||||
|
||||
return { target: objectValue as SafeObject, key }
|
||||
})
|
||||
}
|
||||
|
||||
private readMember(node: AstNode): Effect.Effect<unknown, unknown, R> {
|
||||
return Effect.map(this.getMemberReference(node), (reference) => {
|
||||
if (reference === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if (reference instanceof ComputedValue) return reference.value
|
||||
if (
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseMethodReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference
|
||||
)
|
||||
return reference
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
return new IntrinsicReference(reference.target, reference.key)
|
||||
}
|
||||
return reference.key === "length" ? reference.target.length : reference.target[Number(reference.key)]
|
||||
}
|
||||
if (reference.target instanceof SandboxURL) {
|
||||
return (reference.target.url as unknown as Record<string, unknown>)[String(reference.key)]
|
||||
}
|
||||
return reference.target[String(reference.key)]
|
||||
})
|
||||
}
|
||||
|
||||
private writeMember(node: AstNode, value: unknown): Effect.Effect<unknown, unknown, R> {
|
||||
return this.modifyMember(node, () => Effect.succeed({ write: true, next: value, result: value }))
|
||||
}
|
||||
|
||||
// Resolves the member reference EXACTLY ONCE (so a side-effecting object/key expression
|
||||
// runs once), then lets `compute` decide whether to write - enabling compound assignment,
|
||||
// updates, plain writes, and short-circuiting logical assignment to share one safe path.
|
||||
private modifyMember(
|
||||
node: AstNode,
|
||||
compute: (current: unknown) => Effect.Effect<{ write: boolean; next: unknown; result: unknown }, unknown, R>,
|
||||
): Effect.Effect<unknown, unknown, R> {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const reference = yield* self.getMemberReference(node)
|
||||
if (
|
||||
reference === OptionalShortCircuit ||
|
||||
reference instanceof ComputedValue ||
|
||||
reference === undefined ||
|
||||
reference instanceof ToolReference ||
|
||||
reference instanceof PromiseMethodReference ||
|
||||
reference instanceof IntrinsicReference ||
|
||||
reference instanceof GlobalMethodReference
|
||||
) {
|
||||
throw new InterpreterRuntimeError("Only data fields may be assigned in CodeMode.", node)
|
||||
}
|
||||
if (Array.isArray(reference.target)) {
|
||||
if (reference.key === "length")
|
||||
throw new InterpreterRuntimeError("Array length cannot be assigned in CodeMode.", node)
|
||||
if (typeof reference.key === "string" && arrayMethods.has(reference.key)) {
|
||||
throw new InterpreterRuntimeError("Array methods cannot be assigned in CodeMode.", node)
|
||||
}
|
||||
}
|
||||
const key = Array.isArray(reference.target) ? Number(reference.key) : String(reference.key)
|
||||
const current =
|
||||
reference.target instanceof SandboxURL
|
||||
? (reference.target.url as unknown as Record<string, unknown>)[key]
|
||||
: (reference.target as Record<PropertyKey, unknown>)[key]
|
||||
const { write, next, result } = yield* compute(current)
|
||||
if (write) self.assignToReference(reference, key, next, node)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// Rejects inserting a value that (transitively) contains the container it is being inserted
|
||||
// into - the mutation that would create a circular structure no later walk could survive.
|
||||
private rejectCircularInsertion(
|
||||
container: object,
|
||||
value: unknown,
|
||||
label: string,
|
||||
node: AstNode,
|
||||
seen = new Set<object>(),
|
||||
): void {
|
||||
if (value === container)
|
||||
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
|
||||
if (value === null || typeof value !== "object" || isRuntimeReference(value) || seen.has(value)) return
|
||||
seen.add(value)
|
||||
const items = Array.isArray(value) ? value : Object.values(value)
|
||||
for (const item of items) this.rejectCircularInsertion(container, item, label, node, seen)
|
||||
seen.delete(value)
|
||||
}
|
||||
|
||||
private assignToReference(reference: MemberReference, key: number | string, next: unknown, node: AstNode): void {
|
||||
if (Array.isArray(reference.target)) {
|
||||
const target = reference.target
|
||||
const index = key as number
|
||||
if (!Number.isInteger(index) || index < 0) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"Array assignment index must be a non-negative integer.",
|
||||
node,
|
||||
"InvalidDataValue",
|
||||
)
|
||||
}
|
||||
this.rejectCircularInsertion(target, next, "Array assignment result", node)
|
||||
target[index] = next
|
||||
return
|
||||
}
|
||||
if (reference.target instanceof SandboxURL) {
|
||||
const property = key as string
|
||||
if (!urlWritableProperties.has(property)) {
|
||||
throw new InterpreterRuntimeError(`URL.${property} is read-only.`, node).as("TypeError")
|
||||
}
|
||||
try {
|
||||
const url = reference.target.url as unknown as Record<string, string>
|
||||
url[property] = uriArgument(next, `URL.${property} value`)
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
|
||||
throw new InterpreterRuntimeError(`URL.${property} received an invalid value.`, node).as("TypeError")
|
||||
}
|
||||
}
|
||||
const target = reference.target as SafeObject
|
||||
const objectKey = key as string
|
||||
this.rejectCircularInsertion(target, next, "Object assignment result", node)
|
||||
target[objectKey] = next
|
||||
}
|
||||
|
||||
private toPropertyKey(value: unknown, node: AstNode): string | number {
|
||||
if (typeof value === "string" || typeof value === "number") {
|
||||
return value
|
||||
}
|
||||
|
||||
throw new InterpreterRuntimeError("Property key must be a string or number.", node)
|
||||
}
|
||||
|
||||
private declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
|
||||
const scope = this.currentScope()
|
||||
|
||||
// A pre-seeded parameter slot (initialized === false) is being bound for the first time;
|
||||
// anything else already present is a genuine duplicate declaration.
|
||||
const existing = scope.get(name)
|
||||
if (existing && existing.initialized !== false) {
|
||||
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
|
||||
}
|
||||
|
||||
scope.set(name, { mutable, value, initialized: true })
|
||||
}
|
||||
|
||||
private getIdentifierValue(name: string, node: AstNode): unknown {
|
||||
const binding = this.resolveBinding(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
// A parameter default that forward-references a later (not-yet-bound) parameter - JS TDZ.
|
||||
if (binding.initialized === false) {
|
||||
throw new InterpreterRuntimeError(`Cannot access '${name}' before initialization.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
return binding.value
|
||||
}
|
||||
|
||||
private setIdentifierValue(name: string, value: unknown, node: AstNode): unknown {
|
||||
const binding = this.resolveBinding(name)
|
||||
|
||||
if (!binding) {
|
||||
throw new InterpreterRuntimeError(`Unknown identifier '${name}'.`, node).as("ReferenceError")
|
||||
}
|
||||
|
||||
if (!binding.mutable) {
|
||||
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node).as("TypeError")
|
||||
}
|
||||
|
||||
binding.value = value
|
||||
return value
|
||||
}
|
||||
|
||||
private resolveBinding(name: string): Binding | undefined {
|
||||
for (let index = this.scopes.length - 1; index >= 0; index -= 1) {
|
||||
const scope = this.scopes[index]
|
||||
const binding = scope?.get(name)
|
||||
|
||||
if (binding) {
|
||||
return binding
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
private currentScope(): Map<string, Binding> {
|
||||
const scope = this.scopes[this.scopes.length - 1]
|
||||
|
||||
if (!scope) {
|
||||
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
private pushScope(): void {
|
||||
this.scopes.push(new Map())
|
||||
}
|
||||
|
||||
private popScope(): void {
|
||||
this.scopes.pop()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes one Effect-native CodeMode program without constructing a reusable runtime.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const result = yield* CodeMode.execute({
|
||||
* tools: { lookup },
|
||||
* code: `return await tools.lookup({ id: "order_42" })`,
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const executeWithLimits = <const Tools extends Record<string, unknown>>(
|
||||
options: ExecuteOptions<Tools>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
searchIndex: ToolRuntime.DiscoveryPlan["searchIndex"],
|
||||
): Effect.Effect<Result, never, Services<Tools>> => {
|
||||
const hooks = {
|
||||
...(options.onToolCallStart === undefined ? {} : { onToolCallStart: options.onToolCallStart }),
|
||||
...(options.onToolCallEnd === undefined ? {} : { onToolCallEnd: options.onToolCallEnd }),
|
||||
}
|
||||
const tools = ToolRuntime.make(
|
||||
(options.tools ?? {}) as HostTools<Services<Tools>>,
|
||||
limits.maxToolCalls,
|
||||
searchIndex,
|
||||
hooks,
|
||||
)
|
||||
const logs: Array<string> = []
|
||||
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
|
||||
|
||||
if (options.code.trim().length === 0) {
|
||||
return Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "ParseError", message: "Code cannot be empty." },
|
||||
toolCalls: tools.calls,
|
||||
})
|
||||
}
|
||||
|
||||
const operation = Effect.gen(function* () {
|
||||
const program = parseProgram(options.code)
|
||||
const interpreter = new Interpreter<Services<Tools>>(tools.invoke, tools.keys, logs)
|
||||
const value = yield* interpreter.run(program)
|
||||
const result = copyOut(copyIn(value, "Execution result"), true) as DataValue
|
||||
return {
|
||||
ok: true,
|
||||
value: result,
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result
|
||||
}).pipe((program) => {
|
||||
const timeoutMs = limits.timeoutMs
|
||||
if (timeoutMs === undefined) return program
|
||||
return program.pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: timeoutMs,
|
||||
orElse: () =>
|
||||
Effect.succeed({
|
||||
ok: false,
|
||||
error: { kind: "TimeoutExceeded", message: `Execution timed out after ${timeoutMs}ms.` },
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
return operation.pipe(
|
||||
Effect.catchCause((cause) =>
|
||||
Cause.hasInterruptsOnly(cause)
|
||||
? Effect.interrupt
|
||||
: Effect.succeed({
|
||||
ok: false,
|
||||
error: normalizeError(Cause.squash(cause)),
|
||||
...logged(),
|
||||
toolCalls: tools.calls,
|
||||
} satisfies Result),
|
||||
),
|
||||
Effect.map((result) => (limits.maxOutputBytes === undefined ? result : boundOutput(result, limits.maxOutputBytes))),
|
||||
)
|
||||
}
|
||||
|
||||
const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).byteLength
|
||||
|
||||
// Truncates to a UTF-8 byte budget without splitting a code point (a split multi-byte
|
||||
// sequence decodes to a replacement character, which is dropped).
|
||||
const utf8Truncate = (value: string, maxBytes: number): string => {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
if (bytes.byteLength <= maxBytes) return value
|
||||
const text = new TextDecoder("utf-8").decode(bytes.slice(0, Math.max(0, maxBytes)))
|
||||
return text.endsWith("\uFFFD") ? text.slice(0, -1) : text
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounds the model-facing output (serialized result value plus logs) to `maxOutputBytes`.
|
||||
* Oversized values are replaced by their truncated serialized text with an explanatory marker,
|
||||
* and logs are kept from the start until the remaining budget is exhausted. Truncation never
|
||||
* fails the execution; `truncated: true` marks affected results. Only runs when the host set
|
||||
* `maxOutputBytes` - with the limit absent, output passes through unbounded.
|
||||
*/
|
||||
const boundOutput = (result: Result, maxOutputBytes: number): Result => {
|
||||
let truncated = false
|
||||
|
||||
let value: DataValue = null
|
||||
let valueBytes = 0
|
||||
if (result.ok) {
|
||||
const serialized = JSON.stringify(result.value) ?? "null"
|
||||
const bytes = utf8ByteLength(serialized)
|
||||
if (bytes > maxOutputBytes) {
|
||||
truncated = true
|
||||
value = `${utf8Truncate(serialized, maxOutputBytes)} [result truncated: ${bytes} bytes exceeds the ${maxOutputBytes}-byte output limit; return a smaller value]`
|
||||
valueBytes = maxOutputBytes
|
||||
} else {
|
||||
value = result.value
|
||||
valueBytes = bytes
|
||||
}
|
||||
}
|
||||
|
||||
const logs = result.logs ?? []
|
||||
const kept: Array<string> = []
|
||||
const logBudget = Math.max(0, maxOutputBytes - valueBytes)
|
||||
let logBytes = 0
|
||||
for (const line of logs) {
|
||||
const lineBytes = utf8ByteLength(line) + 1
|
||||
if (logBytes + lineBytes > logBudget) break
|
||||
logBytes += lineBytes
|
||||
kept.push(line)
|
||||
}
|
||||
if (kept.length < logs.length) {
|
||||
truncated = true
|
||||
kept.push(`[logs truncated: showing ${kept.length} of ${logs.length} lines]`)
|
||||
}
|
||||
|
||||
if (!truncated) return result
|
||||
const logsPart = kept.length > 0 ? { logs: kept } : {}
|
||||
return result.ok
|
||||
? { ok: true, value, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
: { ok: false, error: result.error, ...logsPart, truncated: true, toolCalls: result.toolCalls }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
# OpenAPI Follow-ups
|
||||
|
||||
The initial adapter intentionally skips operations it cannot execute correctly. Future work may add:
|
||||
|
||||
- Cookie parameters, authentication, and cookie-header merging.
|
||||
- Matrix, label, space-delimited, pipe-delimited, `allowReserved`, and parameter `content` serialization.
|
||||
- External references and complete nested `$defs` support.
|
||||
- Relative or templated server URLs and server variables.
|
||||
- Base URLs containing query strings or fragments.
|
||||
- Runtime response-schema validation and full content negotiation.
|
||||
- Binary response values and explicit byte-oriented return types.
|
||||
- Request/response projection for `readOnly` and `writeOnly` properties.
|
||||
- SSE, WebSocket, and other streaming transports.
|
||||
- Recovery of responses rejected by a status-filtering `HttpClient`.
|
||||
- Configurable request and response size limits.
|
||||
- Adapter-enforced redirect policy independent of the supplied `HttpClient`.
|
||||
- Strict UTF-8 and empty-body validation for JSON responses.
|
||||
- Compile-time rejection of parameter schemas with nested values unsupported by their serialization style; runtime rejects them before auth resolution.
|
||||
- Complete malformed-security-scheme validation and broader auth-combination coverage.
|
||||
@@ -0,0 +1,130 @@
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import { make, type Definition } from "../tool.js"
|
||||
import { invoke } from "./runtime.js"
|
||||
import {
|
||||
componentDefinitions,
|
||||
inputSchema,
|
||||
isRecord,
|
||||
methods,
|
||||
nonEmptyString,
|
||||
operationInput,
|
||||
operationOutput,
|
||||
operationPath,
|
||||
operationSecurityRequirements,
|
||||
securityRequirements,
|
||||
securitySchemes,
|
||||
specServerUrl,
|
||||
validateBaseUrl,
|
||||
} from "./spec.js"
|
||||
import type { Operation, Options, Result, Skipped, Tools } from "./types.js"
|
||||
|
||||
export type {
|
||||
AuthResolver,
|
||||
Credential,
|
||||
Document,
|
||||
Operation,
|
||||
Options,
|
||||
Result,
|
||||
SecurityScheme,
|
||||
Skipped,
|
||||
Tools,
|
||||
} from "./types.js"
|
||||
|
||||
/**
|
||||
* Builds a CodeMode tool subtree from an OpenAPI 3.x document, one tool per
|
||||
* operation. Auth is resolved host-side via `auth.resolve` and never
|
||||
* model-visible. Tools require `HttpClient.HttpClient`; unrepresentable
|
||||
* operations land in `skipped`.
|
||||
*/
|
||||
export const fromSpec = (options: Options): Result => {
|
||||
const document = options.spec
|
||||
const schemes = securitySchemes(document)
|
||||
const defaultSecurity = securityRequirements(document.security)
|
||||
const definitions = componentDefinitions(document)
|
||||
const paths = isRecord(document.paths) ? document.paths : {}
|
||||
const used = new Set<string>()
|
||||
const namespaces = new Set<string>()
|
||||
const skipped: Array<Skipped> = []
|
||||
const tools = Object.create(null) as Tools
|
||||
|
||||
for (const [path, pathValue] of Object.entries(paths)) {
|
||||
if (!isRecord(pathValue)) continue
|
||||
for (const [method, operationValue] of Object.entries(pathValue)) {
|
||||
if (!methods.has(method) || !isRecord(operationValue)) continue
|
||||
const segments = operationPath(method, path, operationValue, used, namespaces)
|
||||
const operation: Operation = {
|
||||
operationId: nonEmptyString(operationValue.operationId),
|
||||
method: method.toUpperCase(),
|
||||
path,
|
||||
summary: nonEmptyString(operationValue.summary),
|
||||
description: nonEmptyString(operationValue.description),
|
||||
}
|
||||
const output = operationOutput(document, operationValue, definitions)
|
||||
if (!output.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: output.reason })
|
||||
continue
|
||||
}
|
||||
|
||||
const resolvedBaseUrl = (() => {
|
||||
if (options.baseUrl !== undefined) return validateBaseUrl(options.baseUrl)
|
||||
if (operationValue.servers !== undefined) return specServerUrl(operationValue)
|
||||
if (pathValue.servers !== undefined) return specServerUrl(pathValue)
|
||||
return specServerUrl(document)
|
||||
})()
|
||||
if (!resolvedBaseUrl.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: resolvedBaseUrl.reason })
|
||||
continue
|
||||
}
|
||||
const parsedInput = operationInput(document, pathValue, operationValue)
|
||||
if (!parsedInput.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: parsedInput.reason })
|
||||
continue
|
||||
}
|
||||
const input = parsedInput.value
|
||||
|
||||
const security = operationSecurityRequirements(operationValue.security, defaultSecurity, schemes)
|
||||
if (!security.ok) {
|
||||
skipped.push({ method: operation.method, path, reason: security.reason })
|
||||
continue
|
||||
}
|
||||
const plan = {
|
||||
operation,
|
||||
url: `${resolvedBaseUrl.value.replace(/\/+$/, "")}${path}`,
|
||||
fields: input.fields,
|
||||
body: input.body,
|
||||
security: security.value,
|
||||
schemes,
|
||||
auth: options.auth,
|
||||
headers: options.headers ?? {},
|
||||
}
|
||||
used.add(segments.join("."))
|
||||
for (const index of segments.slice(0, -1).keys()) namespaces.add(segments.slice(0, index + 1).join("."))
|
||||
setTool(
|
||||
tools,
|
||||
segments,
|
||||
make({
|
||||
description: operation.description ?? operation.summary ?? `${operation.method} ${path}`,
|
||||
input: inputSchema(input.fields, definitions),
|
||||
output: output.value,
|
||||
run: (input) => invoke(plan, input),
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return { tools, skipped }
|
||||
}
|
||||
|
||||
const setTool = (tools: Tools, path: ReadonlyArray<string>, definition: Definition<HttpClient.HttpClient>): void => {
|
||||
const [head, ...rest] = path
|
||||
if (head === undefined) return
|
||||
if (rest.length === 0) {
|
||||
tools[head] = definition
|
||||
return
|
||||
}
|
||||
const child = tools[head]
|
||||
if (child === undefined || !isRecord(child) || child._tag === "CodeModeTool") {
|
||||
tools[head] = Object.create(null) as Tools
|
||||
}
|
||||
setTool(tools[head] as Tools, rest, definition)
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
import { Effect, Option, Schema, Stream } from "effect"
|
||||
import { HttpClient, HttpClientRequest, HttpClientResponse, type HttpMethod } from "effect/unstable/http"
|
||||
import { ToolError, toolError } from "../tool-error.js"
|
||||
import { isRecord, own } from "./spec.js"
|
||||
import type { AppliedAuth, Credential, Plan, SecurityScheme } from "./types.js"
|
||||
|
||||
const decodeJson = Schema.decodeUnknownOption(Schema.UnknownFromJsonString)
|
||||
const maxErrorBodyChars = 1_024
|
||||
const maxResponseBodyBytes = 50 * 1024 * 1024
|
||||
|
||||
export const invoke = (plan: Plan, input: unknown): Effect.Effect<unknown, unknown, HttpClient.HttpClient> =>
|
||||
Effect.gen(function* () {
|
||||
const value = isRecord(input) ? input : {}
|
||||
|
||||
let request = yield* buildRequest(plan, value)
|
||||
|
||||
const auth = yield* resolveAuth(plan)
|
||||
for (const [name, item] of Object.entries(auth.query)) {
|
||||
request = HttpClientRequest.setUrlParam(request, name, item)
|
||||
}
|
||||
request = HttpClientRequest.setHeaders(request, auth.headers)
|
||||
|
||||
const client = yield* HttpClient.HttpClient
|
||||
const response = yield* client
|
||||
.execute(request)
|
||||
.pipe(
|
||||
Effect.catch((cause) =>
|
||||
Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} failed: transport error`, cause)),
|
||||
),
|
||||
)
|
||||
const text = yield* readResponseBody(response, plan)
|
||||
const mediaType = response.headers["content-type"]?.split(";")[0]?.trim().toLowerCase()
|
||||
const json = mediaType === "application/json" || mediaType?.endsWith("+json") === true
|
||||
const decoded = text === "" ? Option.some(null) : json ? decodeJson(text) : Option.none()
|
||||
const parsed = json ? Option.getOrElse(decoded, () => text) : text === "" ? null : text
|
||||
if (response.status < 200 || response.status >= 300) {
|
||||
const rendered = typeof parsed === "string" ? parsed : (JSON.stringify(parsed) ?? "")
|
||||
const summary =
|
||||
rendered === "" || rendered === "null"
|
||||
? "no response body"
|
||||
: rendered.length > maxErrorBodyChars
|
||||
? `${rendered.slice(0, maxErrorBodyChars)}...`
|
||||
: rendered
|
||||
return yield* Effect.fail(
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} failed with HTTP ${response.status}: ${summary}`),
|
||||
)
|
||||
}
|
||||
if (json && Option.isNone(decoded)) {
|
||||
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} returned malformed JSON.`))
|
||||
}
|
||||
return parsed
|
||||
})
|
||||
|
||||
const buildRequest = (
|
||||
plan: Plan,
|
||||
input: Readonly<Record<string, unknown>>,
|
||||
): Effect.Effect<HttpClientRequest.HttpClientRequest, ToolError> =>
|
||||
Effect.gen(function* () {
|
||||
// Validate every model-controlled value before auth resolution, which may refresh tokens.
|
||||
const url = buildUrl(plan, input)
|
||||
if (url instanceof ToolError) return yield* Effect.fail(url)
|
||||
const missing = plan.fields.find(
|
||||
(field) => field.required && field.location !== "path" && own(input, field.inputName) === undefined,
|
||||
)
|
||||
if (missing !== undefined) {
|
||||
const label = missing.location === "body" ? "body field" : `${missing.location} parameter`
|
||||
return yield* Effect.fail(toolError(`Missing required ${label} '${missing.inputName}'.`))
|
||||
}
|
||||
|
||||
let request = HttpClientRequest.make(plan.operation.method as HttpMethod.HttpMethod)(url)
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "query") continue
|
||||
const item = own(input, field.inputName)
|
||||
if (item === undefined) continue
|
||||
const serialized = serializeQuery(request, field, item)
|
||||
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
|
||||
request = serialized
|
||||
}
|
||||
|
||||
// Host headers first, then declared header parameters.
|
||||
request = HttpClientRequest.setHeaders(request, plan.headers)
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "header") continue
|
||||
const item = own(input, field.inputName)
|
||||
if (item === undefined) continue
|
||||
const serialized = serializeSimple(field, item, String)
|
||||
if (serialized instanceof ToolError) return yield* Effect.fail(serialized)
|
||||
request = HttpClientRequest.setHeader(request, field.name, serialized)
|
||||
}
|
||||
|
||||
const setBody = (value: unknown, mediaType: string) =>
|
||||
HttpClientRequest.bodyJson(request, value).pipe(
|
||||
Effect.map((next) => HttpClientRequest.setHeader(next, "content-type", mediaType)),
|
||||
Effect.mapError((cause) =>
|
||||
toolError(`Invalid JSON body for ${plan.operation.method} ${plan.operation.path}.`, cause),
|
||||
),
|
||||
)
|
||||
if (plan.body?.mode === "value") {
|
||||
const field = plan.fields.find((field) => field.location === "body")
|
||||
const body = field === undefined ? undefined : own(input, field.inputName)
|
||||
if (body !== undefined) request = yield* setBody(body, plan.body.mediaType)
|
||||
}
|
||||
if (plan.body?.mode === "object") {
|
||||
const entries = plan.fields.flatMap((field) => {
|
||||
if (field.location !== "body") return []
|
||||
const item = own(input, field.inputName)
|
||||
return item === undefined ? [] : [[field.name, item] as const]
|
||||
})
|
||||
if (plan.body.required || entries.length > 0) {
|
||||
request = yield* setBody(Object.fromEntries(entries), plan.body.mediaType)
|
||||
}
|
||||
}
|
||||
return request
|
||||
})
|
||||
|
||||
const resolveAuth = (plan: Plan): Effect.Effect<AppliedAuth, unknown> =>
|
||||
Effect.gen(function* () {
|
||||
const none: AppliedAuth = { headers: {}, query: {} }
|
||||
if (plan.security.length === 0) return none
|
||||
|
||||
const unavailable: Array<string> = []
|
||||
alternatives: for (const requirement of plan.security) {
|
||||
const names = Object.keys(requirement)
|
||||
if (names.length === 0) return none
|
||||
const credentials: Array<readonly [string, SecurityScheme, Credential]> = []
|
||||
for (const name of names) {
|
||||
const scheme = own(plan.schemes, name)
|
||||
if (scheme === undefined || plan.auth === undefined) {
|
||||
unavailable.push(name)
|
||||
continue alternatives
|
||||
}
|
||||
const credential = yield* plan.auth.resolve({
|
||||
name,
|
||||
definition: scheme,
|
||||
scopes: requirement[name] ?? [],
|
||||
operation: plan.operation,
|
||||
})
|
||||
if (credential === undefined) {
|
||||
unavailable.push(name)
|
||||
continue alternatives
|
||||
}
|
||||
credentials.push([name, scheme, credential])
|
||||
}
|
||||
const applied = applyCredentials(credentials)
|
||||
return applied instanceof ToolError ? yield* Effect.fail(applied) : applied
|
||||
}
|
||||
|
||||
return yield* Effect.fail(
|
||||
toolError(
|
||||
`${plan.operation.method} ${plan.operation.path} requires authentication; no credential available for: ${[...new Set(unavailable)].join(", ")}.`,
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const applyCredentials = (
|
||||
credentials: ReadonlyArray<readonly [string, SecurityScheme, Credential]>,
|
||||
): AppliedAuth | ToolError => {
|
||||
const headers = new Map<string, string>()
|
||||
const query = new Map<string, string>()
|
||||
const add = (carrier: "header" | "query", name: string, value: string): ToolError | undefined => {
|
||||
const target = carrier === "header" ? headers : query
|
||||
if (target.has(name)) return toolError(`Authentication resolves multiple credentials for ${carrier} '${name}'.`)
|
||||
target.set(name, value)
|
||||
}
|
||||
for (const [name, definition, credential] of credentials) {
|
||||
if (credential.type === "bearer") {
|
||||
const duplicate = add("header", "authorization", `Bearer ${credential.token}`)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
if (credential.type === "basic") {
|
||||
// Buffer instead of btoa: btoa throws on non-Latin-1 credentials.
|
||||
const duplicate = add(
|
||||
"header",
|
||||
"authorization",
|
||||
`Basic ${Buffer.from(`${credential.username}:${credential.password}`, "utf8").toString("base64")}`,
|
||||
)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
if (credential.type === "header") {
|
||||
const duplicate = add("header", credential.name.toLowerCase(), credential.value)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
continue
|
||||
}
|
||||
// apiKey: the carrier comes from the scheme declaration.
|
||||
if (definition.type !== "apiKey") {
|
||||
return toolError(
|
||||
`Security scheme '${name}' is not an apiKey scheme; resolve a bearer, basic, or header credential for it.`,
|
||||
)
|
||||
}
|
||||
if (definition.in === "cookie") return toolError(`Cookie authentication '${name}' is not supported.`)
|
||||
const parameter = definition.in === "header" ? definition.name.toLowerCase() : definition.name
|
||||
const duplicate = add(definition.in, parameter, credential.value)
|
||||
if (duplicate !== undefined) return duplicate
|
||||
}
|
||||
return { headers: Object.fromEntries(headers), query: Object.fromEntries(query) }
|
||||
}
|
||||
|
||||
const buildUrl = (plan: Plan, input: Readonly<Record<string, unknown>>): string | ToolError => {
|
||||
let url = plan.url
|
||||
for (const field of plan.fields) {
|
||||
if (field.location !== "path") continue
|
||||
const item = own(input, field.inputName)
|
||||
if (item === undefined) {
|
||||
return toolError(`Missing required path parameter '${field.inputName}'.`)
|
||||
}
|
||||
const fieldValue = serializeSimple(field, item, (value) =>
|
||||
encodeURIComponent(value).replace(
|
||||
/[!'()*]/g,
|
||||
(character) => `%${character.charCodeAt(0).toString(16).toUpperCase()}`,
|
||||
),
|
||||
)
|
||||
if (fieldValue instanceof ToolError) return fieldValue
|
||||
// '.'/'..' survive encoding and URL normalization collapses them, letting a
|
||||
// model-supplied value retarget the request to a different endpoint.
|
||||
if (fieldValue === "" || fieldValue === "." || fieldValue === "..") {
|
||||
return toolError(`Invalid path parameter '${field.inputName}'.`)
|
||||
}
|
||||
url = url.replaceAll(`{${field.name}}`, fieldValue)
|
||||
}
|
||||
const unresolved = url.match(/\{[^{}]+\}/)
|
||||
if (unresolved !== null) return toolError(`Unresolved path parameter ${unresolved[0]}.`)
|
||||
return url
|
||||
}
|
||||
|
||||
const serializeSimple = (
|
||||
field: Plan["fields"][number],
|
||||
value: unknown,
|
||||
encode: (value: string) => string,
|
||||
): string | ToolError => {
|
||||
const scalar = (item: unknown): string | ToolError =>
|
||||
item !== null && typeof item !== "string" && typeof item !== "number" && typeof item !== "boolean"
|
||||
? toolError(`Parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
: encode(String(item))
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map(scalar)
|
||||
const invalid = items.find((item): item is ToolError => item instanceof ToolError)
|
||||
return invalid ?? items.join(",")
|
||||
}
|
||||
if (!isRecord(value)) return scalar(value)
|
||||
const entries = Object.entries(value).flatMap<string | ToolError>(([name, item]) => {
|
||||
const rendered = scalar(item)
|
||||
if (rendered instanceof ToolError) return [rendered]
|
||||
return field.explode ? [`${encode(name)}=${rendered}`] : [encode(name), rendered]
|
||||
})
|
||||
const invalid = entries.find((item): item is ToolError => item instanceof ToolError)
|
||||
return invalid ?? entries.join(",")
|
||||
}
|
||||
|
||||
const serializeQuery = (
|
||||
request: HttpClientRequest.HttpClientRequest,
|
||||
field: Plan["fields"][number],
|
||||
value: unknown,
|
||||
): HttpClientRequest.HttpClientRequest | ToolError => {
|
||||
if (field.style === "deepObject") {
|
||||
if (!isRecord(value)) return toolError(`Deep-object parameter '${field.inputName}' must be an object.`)
|
||||
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
|
||||
if (current instanceof ToolError) return current
|
||||
if (item === undefined || (item !== null && typeof item === "object")) {
|
||||
return toolError(`Deep-object parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
}
|
||||
return HttpClientRequest.appendUrlParam(current, `${field.name}[${name}]`, String(item))
|
||||
}, request)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const rendered = serializeSimple(field, value, String)
|
||||
if (rendered instanceof ToolError) return rendered
|
||||
if (!field.explode) return HttpClientRequest.appendUrlParam(request, field.name, rendered)
|
||||
if (value.some((item) => item === undefined || (item !== null && typeof item === "object"))) {
|
||||
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
}
|
||||
return value.reduce((current, item) => HttpClientRequest.appendUrlParam(current, field.name, String(item)), request)
|
||||
}
|
||||
if (isRecord(value) && field.explode) {
|
||||
return Object.entries(value).reduce<HttpClientRequest.HttpClientRequest | ToolError>((current, [name, item]) => {
|
||||
if (current instanceof ToolError) return current
|
||||
if (item === undefined || (item !== null && typeof item === "object")) {
|
||||
return toolError(`Query parameter '${field.inputName}' contains an unsupported nested value.`)
|
||||
}
|
||||
return HttpClientRequest.appendUrlParam(current, name, String(item))
|
||||
}, request)
|
||||
}
|
||||
const rendered = serializeSimple(field, value, String)
|
||||
return rendered instanceof ToolError ? rendered : HttpClientRequest.appendUrlParam(request, field.name, rendered)
|
||||
}
|
||||
|
||||
const readResponseBody = (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
plan: Plan,
|
||||
): Effect.Effect<string, ToolError> =>
|
||||
Effect.gen(function* () {
|
||||
const contentLength = response.headers["content-length"]
|
||||
const parsedSize = contentLength === undefined ? undefined : Number.parseInt(contentLength, 10)
|
||||
const declaredSize =
|
||||
parsedSize !== undefined && Number.isSafeInteger(parsedSize) && parsedSize >= 0 ? parsedSize : undefined
|
||||
if (declaredSize !== undefined && declaredSize > maxResponseBodyBytes) {
|
||||
return yield* Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
||||
}
|
||||
let body = Buffer.allocUnsafe(Math.min(maxResponseBodyBytes, declaredSize ?? 64 * 1024))
|
||||
let size = 0
|
||||
yield* Stream.runForEach(response.stream, (chunk) => {
|
||||
if (size + chunk.byteLength > maxResponseBodyBytes) {
|
||||
return Effect.fail(toolError(`${plan.operation.method} ${plan.operation.path} response exceeds 50 MiB.`))
|
||||
}
|
||||
if (size + chunk.byteLength > body.byteLength) {
|
||||
const grown = Buffer.allocUnsafe(
|
||||
Math.min(maxResponseBodyBytes, Math.max(size + chunk.byteLength, body.byteLength * 2)),
|
||||
)
|
||||
body.copy(grown, 0, 0, size)
|
||||
body = grown
|
||||
}
|
||||
body.set(chunk, size)
|
||||
size += chunk.byteLength
|
||||
return Effect.void
|
||||
}).pipe(
|
||||
Effect.catch((cause) => {
|
||||
if (cause instanceof ToolError) return Effect.fail(cause)
|
||||
if (cause.reason._tag === "EmptyBodyError") return Effect.void
|
||||
return Effect.fail(
|
||||
toolError(`${plan.operation.method} ${plan.operation.path} failed while reading the response body.`, cause),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return new TextDecoder().decode(body.subarray(0, size))
|
||||
})
|
||||
@@ -0,0 +1,511 @@
|
||||
import { fromSchemaOpenApi3_0, fromSchemaOpenApi3_1 } from "effect/JsonSchema"
|
||||
import type { JsonSchema } from "../tool.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import type {
|
||||
Body,
|
||||
Document,
|
||||
InputField,
|
||||
OperationInput,
|
||||
Parsed,
|
||||
SecurityRequirement,
|
||||
SecurityScheme,
|
||||
} from "./types.js"
|
||||
|
||||
export const methods = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"])
|
||||
const parameterLocations = ["path", "query", "header"] as const
|
||||
const ignoredHeaderParameters = new Set(["accept", "content-type", "authorization"])
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
|
||||
const asArray = (value: unknown): ReadonlyArray<unknown> => (Array.isArray(value) ? value : [])
|
||||
|
||||
export const nonEmptyString = (value: unknown): string | undefined =>
|
||||
typeof value === "string" && value !== "" ? value : undefined
|
||||
|
||||
// Guards record lookups keyed by spec- or model-controlled names against
|
||||
// prototype-inherited values (e.g. a parameter named `toString`).
|
||||
export const own = <T>(record: Readonly<Record<string, T>>, key: string): T | undefined =>
|
||||
Object.hasOwn(record, key) ? record[key] : undefined
|
||||
|
||||
export const resolve = (document: Document, value: unknown): unknown => {
|
||||
const next = (current: unknown, seen: ReadonlySet<string>): unknown => {
|
||||
if (!isRecord(current)) return current
|
||||
const ref = nonEmptyString(current.$ref)
|
||||
if (ref === undefined || !ref.startsWith("#/") || seen.has(ref)) return current
|
||||
const target = ref
|
||||
.slice(2)
|
||||
.split("/")
|
||||
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"))
|
||||
.reduce<unknown>((item, segment) => (isRecord(item) ? own(item, segment) : undefined), document)
|
||||
return target === undefined ? current : next(target, new Set([...seen, ref]))
|
||||
}
|
||||
return next(value, new Set())
|
||||
}
|
||||
|
||||
const projectSchema = (document: Document, value: unknown): JsonSchema => {
|
||||
if (!isRecord(value)) return {}
|
||||
const normalized = nonEmptyString(document.openapi)?.startsWith("3.0")
|
||||
? fromSchemaOpenApi3_0(value)
|
||||
: fromSchemaOpenApi3_1(value)
|
||||
return Object.keys(normalized.definitions).length === 0
|
||||
? normalized.schema
|
||||
: { ...normalized.schema, $defs: normalized.definitions }
|
||||
}
|
||||
|
||||
export const componentDefinitions = (document: Document): Readonly<Record<string, JsonSchema>> => {
|
||||
const components = isRecord(document.components) ? document.components : {}
|
||||
const schemas = isRecord(components.schemas) ? components.schemas : {}
|
||||
return Object.fromEntries(Object.entries(schemas).map(([name, value]) => [name, projectSchema(document, value)]))
|
||||
}
|
||||
|
||||
const withDefinitions = (schema: JsonSchema, definitions: Readonly<Record<string, JsonSchema>>): JsonSchema => {
|
||||
if (Object.keys(definitions).length === 0) return schema
|
||||
const local = isRecord(schema.$defs) ? schema.$defs : {}
|
||||
return { ...schema, $defs: { ...definitions, ...local } }
|
||||
}
|
||||
|
||||
const isJsonMediaType = (mediaType: string): boolean => {
|
||||
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
|
||||
return normalized === "application/json" || normalized.endsWith("+json")
|
||||
}
|
||||
|
||||
const isBinaryMediaType = (document: Document, mediaType: string, value: unknown): boolean => {
|
||||
const normalized = mediaType.split(";")[0]?.trim().toLowerCase() ?? ""
|
||||
if (!isJsonMediaType(normalized) && !normalized.startsWith("text/")) return true
|
||||
if (!isRecord(value)) return false
|
||||
const schema = resolve(document, value.schema)
|
||||
return isRecord(schema) && schema.format === "binary"
|
||||
}
|
||||
|
||||
const jsonContent = (
|
||||
content: Record<string, unknown>,
|
||||
): { readonly mediaType: string; readonly schema: unknown } | undefined => {
|
||||
const entry = Object.entries(content).find(([mediaType]) => isJsonMediaType(mediaType))
|
||||
return entry !== undefined && isRecord(entry[1]) ? { mediaType: entry[0], schema: entry[1].schema } : undefined
|
||||
}
|
||||
|
||||
const isFlattenableObjectBody = (
|
||||
schema: unknown,
|
||||
requestRequired: boolean,
|
||||
): schema is Record<string, unknown> & { readonly properties: Record<string, unknown> } =>
|
||||
isRecord(schema) &&
|
||||
requestRequired &&
|
||||
schema.type === "object" &&
|
||||
isRecord(schema.properties) &&
|
||||
schema.additionalProperties === false &&
|
||||
schema.nullable !== true &&
|
||||
schema.allOf === undefined &&
|
||||
schema.anyOf === undefined &&
|
||||
schema.oneOf === undefined
|
||||
|
||||
type PlannedField = Omit<InputField, "inputName">
|
||||
|
||||
const operationParameters = (
|
||||
document: Document,
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<ReadonlyArray<PlannedField>> => {
|
||||
// Operation-level parameters override path-level ones sharing (location, name).
|
||||
const declared = new Map<
|
||||
string,
|
||||
{ readonly name: string; readonly location: string; readonly parameter: Record<string, unknown> }
|
||||
>()
|
||||
for (const raw of [...asArray(pathItem.parameters), ...asArray(operation.parameters)]) {
|
||||
const resolved = resolve(document, raw)
|
||||
if (!isRecord(resolved)) return { ok: false, reason: "parameter declaration is invalid or unresolved" }
|
||||
const name = nonEmptyString(resolved.name)
|
||||
const location = nonEmptyString(resolved.in)
|
||||
if (name === undefined || location === undefined)
|
||||
return { ok: false, reason: "parameter declaration is missing name or location" }
|
||||
declared.set(`${location}:${name}`, { name, location, parameter: resolved })
|
||||
}
|
||||
const unordered: Array<PlannedField> = []
|
||||
for (const item of declared.values()) {
|
||||
const name = item.name
|
||||
const location = item.location
|
||||
const resolved = item.parameter
|
||||
if (location === "cookie") return { ok: false, reason: `cookie parameter '${name}' is not supported` }
|
||||
if (location !== "path" && location !== "query" && location !== "header") {
|
||||
return { ok: false, reason: `parameter '${name}' uses unsupported location '${location}'` }
|
||||
}
|
||||
if (location === "header" && ignoredHeaderParameters.has(name.toLowerCase())) continue
|
||||
if (resolved.schema === undefined && resolved.content === undefined) {
|
||||
return { ok: false, reason: `parameter '${name}' declares neither schema nor content` }
|
||||
}
|
||||
if (resolved.content !== undefined)
|
||||
return { ok: false, reason: `parameter '${name}' uses unsupported content encoding` }
|
||||
if (resolved.style !== undefined && nonEmptyString(resolved.style) === undefined) {
|
||||
return { ok: false, reason: `parameter '${name}' has an invalid style` }
|
||||
}
|
||||
if (resolved.explode !== undefined && typeof resolved.explode !== "boolean") {
|
||||
return { ok: false, reason: `parameter '${name}' has an invalid explode value` }
|
||||
}
|
||||
if (resolved.allowReserved !== undefined && typeof resolved.allowReserved !== "boolean") {
|
||||
return { ok: false, reason: `parameter '${name}' has an invalid allowReserved value` }
|
||||
}
|
||||
if (resolved.allowReserved === true)
|
||||
return { ok: false, reason: `parameter '${name}' uses unsupported allowReserved encoding` }
|
||||
const declaredStyle = nonEmptyString(resolved.style) ?? (location === "query" ? "form" : "simple")
|
||||
if (location === "query" && declaredStyle !== "form" && declaredStyle !== "deepObject") {
|
||||
return { ok: false, reason: `query parameter '${name}' uses unsupported style '${declaredStyle}'` }
|
||||
}
|
||||
if (location !== "query" && declaredStyle !== "simple") {
|
||||
return { ok: false, reason: `${location} parameter '${name}' uses unsupported style '${declaredStyle}'` }
|
||||
}
|
||||
const style = declaredStyle === "deepObject" ? "deepObject" : declaredStyle === "form" ? "form" : "simple"
|
||||
const explode = typeof resolved.explode === "boolean" ? resolved.explode : style === "form"
|
||||
if (style === "deepObject" && !explode) {
|
||||
return { ok: false, reason: `query parameter '${name}' uses deepObject with explode=false` }
|
||||
}
|
||||
const base = projectSchema(document, resolved.schema)
|
||||
const description = nonEmptyString(resolved.description)
|
||||
unordered.push({
|
||||
name,
|
||||
location,
|
||||
required: resolved.required === true || location === "path",
|
||||
style,
|
||||
explode,
|
||||
schema: {
|
||||
...base,
|
||||
...(base.description === undefined && description !== undefined ? { description } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
value: parameterLocations.flatMap((location) => unordered.filter((field) => field.location === location)),
|
||||
}
|
||||
}
|
||||
|
||||
const operationBody = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<{ readonly fields: ReadonlyArray<PlannedField>; readonly body: Body | undefined }> => {
|
||||
const resolved = resolve(document, operation.requestBody)
|
||||
if (!isRecord(resolved)) return { ok: true, value: { fields: [], body: undefined } }
|
||||
const content = isRecord(resolved.content) ? resolved.content : {}
|
||||
const selected = jsonContent(content)
|
||||
if (selected === undefined) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: `request body has no JSON content (declared: ${Object.keys(content).join(", ") || "none"})`,
|
||||
}
|
||||
}
|
||||
const schema = resolve(document, selected.schema)
|
||||
const required = resolved.required === true
|
||||
if (!isFlattenableObjectBody(schema, required)) {
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fields: [
|
||||
{
|
||||
name: "body",
|
||||
location: "body",
|
||||
required,
|
||||
schema: projectSchema(document, selected.schema),
|
||||
style: undefined,
|
||||
explode: undefined,
|
||||
},
|
||||
],
|
||||
body: { required, mode: "value", mediaType: selected.mediaType },
|
||||
},
|
||||
}
|
||||
}
|
||||
const requiredProperties = new Set(
|
||||
Array.isArray(schema.required) ? schema.required.filter((item): item is string => typeof item === "string") : [],
|
||||
)
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fields: Object.entries(schema.properties).map(([name, value]) => ({
|
||||
name,
|
||||
location: "body" as const,
|
||||
required: required && requiredProperties.has(name),
|
||||
schema: projectSchema(document, value),
|
||||
style: undefined,
|
||||
explode: undefined,
|
||||
})),
|
||||
body: { required, mode: "object", mediaType: selected.mediaType },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const operationInput = (
|
||||
document: Document,
|
||||
pathItem: Record<string, unknown>,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<OperationInput> => {
|
||||
const parameters = operationParameters(document, pathItem, operation)
|
||||
if (!parameters.ok) return parameters
|
||||
const requestBody = operationBody(document, operation)
|
||||
if (!requestBody.ok) return requestBody
|
||||
const fields = [...parameters.value, ...requestBody.value.fields]
|
||||
|
||||
const conflicts = new Set(
|
||||
[...Map.groupBy(fields, (field) => field.name)]
|
||||
.filter(([, matches]) => new Set(matches.map((field) => field.location)).size > 1)
|
||||
.map(([name]) => name),
|
||||
)
|
||||
const used = new Set<string>()
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
fields: fields.map((field) => {
|
||||
const visibleName = isBlockedMember(field.name) ? `${field.name}_2` : field.name
|
||||
const base = conflicts.has(field.name) ? `${field.location}_${visibleName}` : visibleName
|
||||
const next = (index: number): string => {
|
||||
const candidate = index === 1 ? base : `${base}_${index}`
|
||||
return used.has(candidate) ? next(index + 1) : candidate
|
||||
}
|
||||
const inputName = next(1)
|
||||
used.add(inputName)
|
||||
return { ...field, inputName }
|
||||
}),
|
||||
body: requestBody.value.body,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const inputSchema = (
|
||||
fields: ReadonlyArray<InputField>,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): JsonSchema => {
|
||||
const required = fields.filter((field) => field.required).map((field) => field.inputName)
|
||||
return withDefinitions(
|
||||
{
|
||||
type: "object",
|
||||
properties: Object.fromEntries(fields.map((field) => [field.inputName, field.schema])),
|
||||
...(required.length === 0 ? {} : { required }),
|
||||
},
|
||||
definitions,
|
||||
)
|
||||
}
|
||||
|
||||
const successfulResponses = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
): Parsed<ReadonlyArray<Record<string, unknown>>> => {
|
||||
if (!isRecord(operation.responses)) return { ok: true, value: [] }
|
||||
const entries = Object.entries(operation.responses)
|
||||
const selected = [
|
||||
...entries.filter(([status]) => /^2\d\d$/.test(status)).sort(([a], [b]) => a.localeCompare(b)),
|
||||
...entries.filter(([status]) => status.toUpperCase() === "2XX"),
|
||||
]
|
||||
const responses: Array<Record<string, unknown>> = []
|
||||
for (const [, value] of selected) {
|
||||
const resolved = resolve(document, value)
|
||||
if (!isRecord(resolved) || nonEmptyString(resolved.$ref) !== undefined) {
|
||||
return { ok: false, reason: "successful response declaration is invalid or unresolved" }
|
||||
}
|
||||
responses.push(resolved)
|
||||
}
|
||||
return { ok: true, value: responses }
|
||||
}
|
||||
|
||||
export const operationOutput = (
|
||||
document: Document,
|
||||
operation: Record<string, unknown>,
|
||||
definitions: Readonly<Record<string, JsonSchema>>,
|
||||
): Parsed<JsonSchema | undefined> => {
|
||||
if (operation["x-websocket"] === true) return { ok: false, reason: "WebSocket operations are not supported" }
|
||||
const responses = successfulResponses(document, operation)
|
||||
if (!responses.ok) return responses
|
||||
const streams = responses.value.some(
|
||||
(response) =>
|
||||
isRecord(response.content) &&
|
||||
Object.keys(response.content).some(
|
||||
(mediaType) => mediaType.split(";")[0]?.trim().toLowerCase() === "text/event-stream",
|
||||
),
|
||||
)
|
||||
if (streams) return { ok: false, reason: "SSE operations are not supported" }
|
||||
const binary = responses.value.some(
|
||||
(response) =>
|
||||
isRecord(response.content) &&
|
||||
Object.entries(response.content).some(([mediaType, value]) => isBinaryMediaType(document, mediaType, value)),
|
||||
)
|
||||
if (binary) return { ok: false, reason: "binary responses are not supported" }
|
||||
|
||||
const outcomes: Array<JsonSchema> = []
|
||||
for (const response of responses.value) {
|
||||
if (response.content !== undefined && !isRecord(response.content)) return { ok: true, value: undefined }
|
||||
const content = isRecord(response.content) ? response.content : {}
|
||||
if (Object.keys(content).length === 0) {
|
||||
outcomes.push({ type: "null" })
|
||||
continue
|
||||
}
|
||||
for (const [mediaType, value] of Object.entries(content)) {
|
||||
if (!isJsonMediaType(mediaType)) {
|
||||
outcomes.push({ type: "string" })
|
||||
continue
|
||||
}
|
||||
if (!isRecord(value) || value.schema === undefined) return { ok: true, value: undefined }
|
||||
outcomes.push(projectSchema(document, value.schema))
|
||||
}
|
||||
}
|
||||
if (outcomes.length === 0) return { ok: true, value: undefined }
|
||||
return {
|
||||
ok: true,
|
||||
value: withDefinitions(outcomes.length === 1 ? (outcomes[0] ?? {}) : { anyOf: outcomes }, definitions),
|
||||
}
|
||||
}
|
||||
|
||||
const sanitizeOperationSegment = (raw: string): string => {
|
||||
const base =
|
||||
raw
|
||||
.replaceAll(/[^A-Za-z0-9_$]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.replace(/^([0-9])/, "_$1") || "operation"
|
||||
return isBlockedMember(base) ? `${base}_2` : base
|
||||
}
|
||||
|
||||
const fallbackOperationId = (method: string, path: string): string =>
|
||||
[
|
||||
method,
|
||||
...path
|
||||
.split("/")
|
||||
.filter((part) => part !== "")
|
||||
.flatMap((part) => (part.startsWith("{") && part.endsWith("}") ? ["by", part.slice(1, -1)] : [part]))
|
||||
.flatMap((part) => part.split(/[^A-Za-z0-9]+/).filter((word) => word !== "")),
|
||||
]
|
||||
.map((word, index) => {
|
||||
const lower = word.toLowerCase()
|
||||
return index === 0 ? lower : `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`
|
||||
})
|
||||
.join("")
|
||||
|
||||
export const operationPath = (
|
||||
method: string,
|
||||
path: string,
|
||||
operation: Record<string, unknown>,
|
||||
used: ReadonlySet<string>,
|
||||
namespaces: ReadonlySet<string>,
|
||||
): ReadonlyArray<string> => {
|
||||
const raw = nonEmptyString(operation.operationId)
|
||||
const segments = (raw === undefined ? [fallbackOperationId(method, path)] : raw.split(".")).map(
|
||||
sanitizeOperationSegment,
|
||||
)
|
||||
if (isOperationPathAvailable(segments, used, namespaces)) return segments
|
||||
const conflict = segments.slice(0, -1).findIndex((_, index) => used.has(segments.slice(0, index + 1).join(".")))
|
||||
if (conflict >= 0 && conflict + 1 < segments.length) {
|
||||
const collapsed = segments.flatMap((segment, index) => {
|
||||
if (index === conflict) {
|
||||
const next = segments[index + 1] ?? ""
|
||||
return [`${segment}${next.charAt(0).toUpperCase()}${next.slice(1)}`]
|
||||
}
|
||||
return index === conflict + 1 ? [] : [segment]
|
||||
})
|
||||
if (isOperationPathAvailable(collapsed, used, namespaces)) return collapsed
|
||||
}
|
||||
const fallback = segments.join("_")
|
||||
const next = (index: number): string => {
|
||||
const candidate = `${fallback}_${index}`
|
||||
return isOperationPathAvailable([candidate], used, namespaces) ? candidate : next(index + 1)
|
||||
}
|
||||
return [next(2)]
|
||||
}
|
||||
|
||||
const isOperationPathAvailable = (
|
||||
segments: ReadonlyArray<string>,
|
||||
used: ReadonlySet<string>,
|
||||
namespaces: ReadonlySet<string>,
|
||||
): boolean => {
|
||||
const key = segments.join(".")
|
||||
if (used.has(key) || namespaces.has(key)) return false
|
||||
return segments.slice(0, -1).every((_, index) => !used.has(segments.slice(0, index + 1).join(".")))
|
||||
}
|
||||
|
||||
export const specServerUrl = (source: Record<string, unknown>): Parsed<string> => {
|
||||
const server = asArray(source.servers).find(isRecord)
|
||||
const url = server === undefined ? undefined : nonEmptyString(server.url)
|
||||
if (url === undefined) return { ok: false, reason: "spec declares no servers; pass baseUrl" }
|
||||
if (/\{[^{}]+\}/.test(url)) {
|
||||
return { ok: false, reason: `server URL '${url}' is not an absolute URL; pass baseUrl` }
|
||||
}
|
||||
return validateBaseUrl(url)
|
||||
}
|
||||
|
||||
export const validateBaseUrl = (value: string): Parsed<string> => {
|
||||
if (!/^https?:\/\//i.test(value)) return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
|
||||
const url = URL.parse(value)
|
||||
if (url === null || (url.protocol !== "http:" && url.protocol !== "https:")) {
|
||||
return { ok: false, reason: `server URL '${value}' is not an absolute HTTP(S) URL` }
|
||||
}
|
||||
if (url.search !== "" || url.hash !== "") {
|
||||
return { ok: false, reason: `server URL '${value}' contains an unsupported query string or fragment` }
|
||||
}
|
||||
return { ok: true, value }
|
||||
}
|
||||
|
||||
export const securityRequirements = (value: unknown): Parsed<ReadonlyArray<SecurityRequirement>> => {
|
||||
if (value === undefined) return { ok: true, value: [] }
|
||||
if (!Array.isArray(value)) return { ok: false, reason: "security declaration is not an array" }
|
||||
const requirements: Array<SecurityRequirement> = []
|
||||
for (const item of value) {
|
||||
if (!isRecord(item)) return { ok: false, reason: "security requirement is not an object" }
|
||||
const requirement = Object.create(null) as Record<string, ReadonlyArray<string>>
|
||||
for (const [name, scopes] of Object.entries(item)) {
|
||||
if (!Array.isArray(scopes)) return { ok: false, reason: "security requirement scopes are not string arrays" }
|
||||
const parsed = scopes.filter((scope): scope is string => typeof scope === "string")
|
||||
if (parsed.length !== scopes.length) {
|
||||
return { ok: false, reason: "security requirement scopes are not string arrays" }
|
||||
}
|
||||
requirement[name] = parsed
|
||||
}
|
||||
requirements.push(requirement)
|
||||
}
|
||||
return { ok: true, value: requirements }
|
||||
}
|
||||
|
||||
export const operationSecurityRequirements = (
|
||||
value: unknown,
|
||||
defaults: Parsed<ReadonlyArray<SecurityRequirement>>,
|
||||
schemes: Readonly<Record<string, SecurityScheme>>,
|
||||
): Parsed<ReadonlyArray<SecurityRequirement>> => {
|
||||
const parsed = value === undefined ? defaults : securityRequirements(value)
|
||||
if (!parsed.ok) return parsed
|
||||
const supported = parsed.value.filter((requirement) =>
|
||||
Object.keys(requirement).every((name) => {
|
||||
const scheme = own(schemes, name)
|
||||
return scheme !== undefined && !(scheme.type === "apiKey" && scheme.in === "cookie")
|
||||
}),
|
||||
)
|
||||
if (parsed.value.length === 0 || supported.length > 0) return { ok: true, value: supported }
|
||||
|
||||
const names = [...new Set(parsed.value.flatMap((requirement) => Object.keys(requirement)))]
|
||||
const cookieScheme = names.find((name) => {
|
||||
const definition = own(schemes, name)
|
||||
return definition?.type === "apiKey" && definition.in === "cookie"
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
reason:
|
||||
cookieScheme === undefined
|
||||
? `security requirement references missing or malformed scheme: ${names.join(", ")}`
|
||||
: `cookie authentication '${cookieScheme}' is not supported`,
|
||||
}
|
||||
}
|
||||
|
||||
export const securitySchemes = (document: Document): Readonly<Record<string, SecurityScheme>> => {
|
||||
const components = isRecord(document.components) ? document.components : {}
|
||||
const declared = isRecord(components.securitySchemes) ? components.securitySchemes : {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(declared).flatMap<readonly [string, SecurityScheme]>(([name, value]) => {
|
||||
const resolved = resolve(document, value)
|
||||
if (!isRecord(resolved)) return []
|
||||
const type = nonEmptyString(resolved.type)
|
||||
if (type === "apiKey") {
|
||||
const carrier = nonEmptyString(resolved.in)
|
||||
const parameter = nonEmptyString(resolved.name)
|
||||
if (parameter === undefined || (carrier !== "header" && carrier !== "query" && carrier !== "cookie")) return []
|
||||
return [[name, { type, name: parameter, in: carrier }] as const]
|
||||
}
|
||||
if (type === "http") {
|
||||
const scheme = nonEmptyString(resolved.scheme)?.toLowerCase()
|
||||
return scheme === undefined ? [] : [[name, { type, scheme }] as const]
|
||||
}
|
||||
if (type === "oauth2" || type === "openIdConnect") return [[name, { type }] as const]
|
||||
return []
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Effect } from "effect"
|
||||
import { HttpClient } from "effect/unstable/http"
|
||||
import type { Definition, JsonSchema } from "../tool.js"
|
||||
|
||||
/** A parsed OpenAPI 3.x document. YAML must be parsed by the host. */
|
||||
export type Document = Record<string, unknown>
|
||||
|
||||
/** The operation identity handed to auth resolution and errors. */
|
||||
export type Operation = {
|
||||
readonly operationId: string | undefined
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly summary: string | undefined
|
||||
readonly description: string | undefined
|
||||
}
|
||||
|
||||
/** A resolved OpenAPI security scheme from `components.securitySchemes`. */
|
||||
export type SecurityScheme =
|
||||
| { readonly type: "apiKey"; readonly name: string; readonly in: "header" | "query" | "cookie" }
|
||||
| { readonly type: "http"; readonly scheme: string }
|
||||
| { readonly type: "oauth2" }
|
||||
| { readonly type: "openIdConnect" }
|
||||
|
||||
/**
|
||||
* Credential material returned by a host auth resolver. The carrier for `apiKey`
|
||||
* comes from the scheme definition, not the credential. `header` is the escape
|
||||
* hatch for nonstandard schemes.
|
||||
*/
|
||||
export type Credential =
|
||||
| { readonly type: "bearer"; readonly token: string }
|
||||
| { readonly type: "basic"; readonly username: string; readonly password: string }
|
||||
| { readonly type: "apiKey"; readonly value: string }
|
||||
| { readonly type: "header"; readonly name: string; readonly value: string }
|
||||
|
||||
/**
|
||||
* Resolves credential material for one named security scheme at call time.
|
||||
* `undefined` means unavailable, try the next OR alternative; a failure aborts
|
||||
* the call rather than falling through.
|
||||
*/
|
||||
export type AuthResolver = (context: {
|
||||
readonly name: string
|
||||
readonly definition: SecurityScheme
|
||||
readonly scopes: ReadonlyArray<string>
|
||||
readonly operation: Operation
|
||||
}) => Effect.Effect<Credential | undefined, unknown>
|
||||
|
||||
export type Options = {
|
||||
readonly spec: Document
|
||||
/** Overrides all document, path, and operation `servers`. Required when no applicable absolute server URL exists. */
|
||||
readonly baseUrl?: string | undefined
|
||||
/** Host credential resolution, keyed by security scheme name. */
|
||||
readonly auth?: { readonly resolve: AuthResolver } | undefined
|
||||
/** Static headers on every request. Not model-visible; declared header params may override them, auth always wins. */
|
||||
readonly headers?: Readonly<Record<string, string>> | undefined
|
||||
}
|
||||
|
||||
/** An operation that could not be represented as a tool, and why. */
|
||||
export type Skipped = {
|
||||
readonly method: string
|
||||
readonly path: string
|
||||
readonly reason: string
|
||||
}
|
||||
|
||||
export type Tools = { [name: string]: Definition<HttpClient.HttpClient> | Tools }
|
||||
|
||||
export type Result = {
|
||||
/** Tool subtree; the host places it under a key in its `tools` tree. */
|
||||
readonly tools: Tools
|
||||
readonly skipped: ReadonlyArray<Skipped>
|
||||
}
|
||||
|
||||
export type Parsed<T> = { readonly ok: true; readonly value: T } | { readonly ok: false; readonly reason: string }
|
||||
|
||||
export type InputLocation = "path" | "query" | "header" | "body"
|
||||
|
||||
export type InputField = {
|
||||
/** Model-visible field name after cross-location collision handling. */
|
||||
readonly inputName: string
|
||||
/** Original parameter or body-property name used on the wire. */
|
||||
readonly name: string
|
||||
readonly location: InputLocation
|
||||
readonly required: boolean
|
||||
readonly schema: JsonSchema
|
||||
readonly style: "simple" | "form" | "deepObject" | undefined
|
||||
readonly explode: boolean | undefined
|
||||
}
|
||||
|
||||
export type Body = { readonly required: boolean; readonly mode: "object" | "value"; readonly mediaType: string }
|
||||
|
||||
export type OperationInput = {
|
||||
readonly fields: ReadonlyArray<InputField>
|
||||
readonly body: Body | undefined
|
||||
}
|
||||
|
||||
/** One OR alternative: scheme name -> required scopes. Empty object = unauthenticated is acceptable. */
|
||||
export type SecurityRequirement = Readonly<Record<string, ReadonlyArray<string>>>
|
||||
|
||||
export type Plan = {
|
||||
readonly operation: Operation
|
||||
readonly url: string
|
||||
readonly fields: ReadonlyArray<InputField>
|
||||
readonly body: Body | undefined
|
||||
readonly security: ReadonlyArray<SecurityRequirement>
|
||||
readonly schemes: Readonly<Record<string, SecurityScheme>>
|
||||
readonly auth: { readonly resolve: AuthResolver } | undefined
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
}
|
||||
|
||||
export type AppliedAuth = {
|
||||
readonly headers: Readonly<Record<string, string>>
|
||||
readonly query: Readonly<Record<string, string>>
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
export const arrayMethods = new Set([
|
||||
"map",
|
||||
"filter",
|
||||
"find",
|
||||
"findIndex",
|
||||
"findLast",
|
||||
"findLastIndex",
|
||||
"some",
|
||||
"every",
|
||||
"includes",
|
||||
"join",
|
||||
"reduce",
|
||||
"reduceRight",
|
||||
"flatMap",
|
||||
"forEach",
|
||||
"sort",
|
||||
"toSorted",
|
||||
"slice",
|
||||
"concat",
|
||||
"indexOf",
|
||||
"lastIndexOf",
|
||||
"at",
|
||||
"flat",
|
||||
"reverse",
|
||||
"toReversed",
|
||||
"with",
|
||||
"push",
|
||||
"pop",
|
||||
"shift",
|
||||
"unshift",
|
||||
"splice",
|
||||
"fill",
|
||||
"copyWithin",
|
||||
"keys",
|
||||
"values",
|
||||
"entries",
|
||||
])
|
||||
|
||||
export const mapMethods = new Set(["get", "set", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
||||
|
||||
export const setMethods = new Set(["add", "has", "delete", "clear", "forEach", "keys", "values", "entries"])
|
||||
|
||||
export const spreadItems = (value: unknown): Array<unknown> | undefined => {
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === "string") return Array.from(value)
|
||||
if (value instanceof SandboxMap) return Array.from(value.map.entries(), ([key, item]) => [key, item])
|
||||
if (value instanceof SandboxSet) return Array.from(value.set.values())
|
||||
if (value instanceof SandboxURLSearchParams) return Array.from(value.params.entries(), ([key, item]) => [key, item])
|
||||
return undefined
|
||||
}
|
||||
import { SandboxMap, SandboxSet, SandboxURLSearchParams } from "../values.js"
|
||||
@@ -0,0 +1,4 @@
|
||||
export const consoleMethods = new Set(["log", "info", "debug", "warn", "error", "dir", "table"])
|
||||
|
||||
/** Console formatting recursion ceiling; deeper values render as "...". */
|
||||
export const MAX_CONSOLE_DEPTH = 32
|
||||
@@ -0,0 +1,94 @@
|
||||
export const dateMethods = new Set([
|
||||
"getTime",
|
||||
"valueOf",
|
||||
"toISOString",
|
||||
"toJSON",
|
||||
"toString",
|
||||
"getFullYear",
|
||||
"getMonth",
|
||||
"getDate",
|
||||
"getDay",
|
||||
"getHours",
|
||||
"getMinutes",
|
||||
"getSeconds",
|
||||
"getMilliseconds",
|
||||
"getUTCFullYear",
|
||||
"getUTCMonth",
|
||||
"getUTCDate",
|
||||
"getUTCDay",
|
||||
"getUTCHours",
|
||||
"getUTCMinutes",
|
||||
"getUTCSeconds",
|
||||
"getUTCMilliseconds",
|
||||
"getTimezoneOffset",
|
||||
])
|
||||
|
||||
export const dateStatics = new Set(["now", "parse", "UTC"])
|
||||
|
||||
export const invokeDateStatic = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
switch (name) {
|
||||
case "now":
|
||||
return Date.now()
|
||||
case "parse":
|
||||
return Date.parse(coerceToString(args[0]))
|
||||
case "UTC":
|
||||
return Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Date.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
export const invokeDateMethod = (value: SandboxDate, name: string, node: AstNode): unknown => {
|
||||
const hosted = new Date(value.time)
|
||||
switch (name) {
|
||||
case "getTime":
|
||||
case "valueOf":
|
||||
return value.time
|
||||
case "toISOString":
|
||||
if (!Number.isFinite(value.time)) throw new InterpreterRuntimeError("Invalid time value.", node)
|
||||
return hosted.toISOString()
|
||||
case "toJSON":
|
||||
return Number.isFinite(value.time) ? hosted.toISOString() : null
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
case "getFullYear":
|
||||
return hosted.getFullYear()
|
||||
case "getMonth":
|
||||
return hosted.getMonth()
|
||||
case "getDate":
|
||||
return hosted.getDate()
|
||||
case "getDay":
|
||||
return hosted.getDay()
|
||||
case "getHours":
|
||||
return hosted.getHours()
|
||||
case "getMinutes":
|
||||
return hosted.getMinutes()
|
||||
case "getSeconds":
|
||||
return hosted.getSeconds()
|
||||
case "getMilliseconds":
|
||||
return hosted.getMilliseconds()
|
||||
case "getUTCFullYear":
|
||||
return hosted.getUTCFullYear()
|
||||
case "getUTCMonth":
|
||||
return hosted.getUTCMonth()
|
||||
case "getUTCDate":
|
||||
return hosted.getUTCDate()
|
||||
case "getUTCDay":
|
||||
return hosted.getUTCDay()
|
||||
case "getUTCHours":
|
||||
return hosted.getUTCHours()
|
||||
case "getUTCMinutes":
|
||||
return hosted.getUTCMinutes()
|
||||
case "getUTCSeconds":
|
||||
return hosted.getUTCSeconds()
|
||||
case "getUTCMilliseconds":
|
||||
return hosted.getUTCMilliseconds()
|
||||
case "getTimezoneOffset":
|
||||
return hosted.getTimezoneOffset()
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Date method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { SandboxDate } from "../values.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
@@ -0,0 +1,42 @@
|
||||
import {
|
||||
type AstNode,
|
||||
CodeModeFunction,
|
||||
InterpreterRuntimeError,
|
||||
supportedSyntaxMessage,
|
||||
} from "../interpreter/model.js"
|
||||
import { copyIn, copyOut } from "../tool-runtime.js"
|
||||
|
||||
export const jsonStatics = new Set(["stringify", "parse"])
|
||||
|
||||
export const invokeJsonMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!jsonStatics.has(name)) throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
switch (name) {
|
||||
case "stringify": {
|
||||
const replacer = args[1]
|
||||
if (Array.isArray(replacer) || replacer instanceof CodeModeFunction) {
|
||||
throw new InterpreterRuntimeError(
|
||||
"JSON.stringify replacers are not supported in CodeMode.",
|
||||
node,
|
||||
"UnsupportedSyntax",
|
||||
[supportedSyntaxMessage],
|
||||
)
|
||||
}
|
||||
const space = args[2]
|
||||
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
|
||||
return JSON.stringify(copyOut(copyIn(args[0], "JSON.stringify value")), null, indent)
|
||||
}
|
||||
case "parse": {
|
||||
const text = args[0]
|
||||
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
|
||||
try {
|
||||
return copyIn(JSON.parse(text), "JSON.parse result")
|
||||
} catch (error) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new InterpreterRuntimeError(`JSON.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export const mathConstants = new Set(["PI", "E", "LN2", "LN10", "LOG2E", "LOG10E", "SQRT2", "SQRT1_2"])
|
||||
|
||||
export const mathMethods = new Set([
|
||||
"max",
|
||||
"min",
|
||||
"abs",
|
||||
"floor",
|
||||
"ceil",
|
||||
"round",
|
||||
"trunc",
|
||||
"sign",
|
||||
"sqrt",
|
||||
"cbrt",
|
||||
"pow",
|
||||
"hypot",
|
||||
"log",
|
||||
"log2",
|
||||
"log10",
|
||||
"exp",
|
||||
])
|
||||
|
||||
export const invokeMathMethod = (name: string, args: Array<unknown>, node: AstNode): number => {
|
||||
if (!mathMethods.has(name)) throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
|
||||
const nums = args.map((arg) => {
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
|
||||
return arg
|
||||
})
|
||||
const [a = Number.NaN, b = Number.NaN] = nums
|
||||
switch (name) {
|
||||
case "max":
|
||||
return Math.max(...nums)
|
||||
case "min":
|
||||
return Math.min(...nums)
|
||||
case "abs":
|
||||
return Math.abs(a)
|
||||
case "floor":
|
||||
return Math.floor(a)
|
||||
case "ceil":
|
||||
return Math.ceil(a)
|
||||
case "round":
|
||||
return Math.round(a)
|
||||
case "trunc":
|
||||
return Math.trunc(a)
|
||||
case "sign":
|
||||
return Math.sign(a)
|
||||
case "sqrt":
|
||||
return Math.sqrt(a)
|
||||
case "cbrt":
|
||||
return Math.cbrt(a)
|
||||
case "pow":
|
||||
return Math.pow(a, b)
|
||||
case "hypot":
|
||||
return Math.hypot(...nums)
|
||||
case "log":
|
||||
return Math.log(a)
|
||||
case "log2":
|
||||
return Math.log2(a)
|
||||
case "log10":
|
||||
return Math.log10(a)
|
||||
case "exp":
|
||||
return Math.exp(a)
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Math.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
@@ -0,0 +1,66 @@
|
||||
export const numberMethods = new Set(["toFixed", "toPrecision", "toExponential", "toString"])
|
||||
|
||||
export const numberConstants = new Set(["MAX_SAFE_INTEGER", "MIN_SAFE_INTEGER", "MAX_VALUE", "MIN_VALUE", "EPSILON"])
|
||||
|
||||
export const numberStatics = new Set(["isInteger", "isFinite", "isNaN", "isSafeInteger", "parseInt", "parseFloat"])
|
||||
|
||||
export const invokeNumberMethod = (value: number, name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const optNum = (index: number): number | undefined => {
|
||||
const arg = args[index]
|
||||
if (arg === undefined) return undefined
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
|
||||
return arg
|
||||
}
|
||||
let result: unknown
|
||||
switch (name) {
|
||||
case "toFixed":
|
||||
result = value.toFixed(optNum(0))
|
||||
break
|
||||
case "toExponential":
|
||||
result = value.toExponential(optNum(0))
|
||||
break
|
||||
case "toPrecision": {
|
||||
const digits = optNum(0)
|
||||
result = digits === undefined ? value.toString() : value.toPrecision(digits)
|
||||
break
|
||||
}
|
||||
case "toString": {
|
||||
const radix = optNum(0)
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw new InterpreterRuntimeError("Number.toString radix must be between 2 and 36.", node)
|
||||
}
|
||||
result = value.toString(radix)
|
||||
break
|
||||
}
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Number method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
return boundedData(result, `Number.${name} result`)
|
||||
}
|
||||
|
||||
export const invokeNumberStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const value = args[0]
|
||||
switch (name) {
|
||||
case "isInteger":
|
||||
return Number.isInteger(value)
|
||||
case "isFinite":
|
||||
return Number.isFinite(value)
|
||||
case "isNaN":
|
||||
return Number.isNaN(value)
|
||||
case "isSafeInteger":
|
||||
return Number.isSafeInteger(value)
|
||||
case "parseInt": {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") {
|
||||
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
|
||||
}
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
case "parseFloat":
|
||||
return parseFloat(coerceToString(value))
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`Number.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
@@ -0,0 +1,81 @@
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember } from "../tool-runtime.js"
|
||||
import { isSandboxValue, SandboxMap, SandboxURLSearchParams } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
|
||||
export const objectStatics = new Set(["keys", "values", "entries", "hasOwn", "assign", "fromEntries"])
|
||||
|
||||
export const invokeObjectMethod = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!objectStatics.has(name)) throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
||||
const requireObject = (): Record<string, unknown> => {
|
||||
const value = boundedData(args[0], `Object.${name} input`)
|
||||
if (isSandboxValue(value)) return {}
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError(`Object.${name} expects a data object.`, node)
|
||||
}
|
||||
return value as Record<string, unknown>
|
||||
}
|
||||
const guardedSet = (out: Record<string, unknown>, key: string, item: unknown): void => {
|
||||
if (isBlockedMember(key)) throw new InterpreterRuntimeError(`Property '${key}' is not available in CodeMode.`, node)
|
||||
out[key] = item
|
||||
}
|
||||
switch (name) {
|
||||
case "keys": {
|
||||
const value = boundedData(args[0], "Object.keys input")
|
||||
if (isSandboxValue(value)) return []
|
||||
if (Array.isArray(value)) return Object.keys(value)
|
||||
if (value === null || typeof value !== "object") {
|
||||
throw new InterpreterRuntimeError("Object.keys expects a data object or array.", node)
|
||||
}
|
||||
return Object.keys(value)
|
||||
}
|
||||
case "values":
|
||||
return Object.values(requireObject())
|
||||
case "entries":
|
||||
return Object.entries(requireObject()).map(([key, item]) => [key, item])
|
||||
case "hasOwn":
|
||||
return Object.hasOwn(requireObject(), String(args[1]))
|
||||
case "assign": {
|
||||
const target = args[0]
|
||||
if (target === null || typeof target !== "object" || Array.isArray(target) || isSandboxValue(target)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects a data object target.", node)
|
||||
}
|
||||
const out = target as Record<string, unknown>
|
||||
for (const source of args.slice(1)) {
|
||||
if (source === null || source === undefined) continue
|
||||
const value = source
|
||||
if (isSandboxValue(value)) continue
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new InterpreterRuntimeError("Object.assign expects data objects.", node)
|
||||
}
|
||||
for (const [key, item] of Object.entries(value)) guardedSet(out, key, item)
|
||||
}
|
||||
return out
|
||||
}
|
||||
case "fromEntries": {
|
||||
if (args[0] instanceof SandboxMap) {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const [key, item] of args[0].map.entries()) guardedSet(out, coerceToString(key), item)
|
||||
return out
|
||||
}
|
||||
if (args[0] instanceof SandboxURLSearchParams) {
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const [key, value] of args[0].params.entries()) guardedSet(out, key, value)
|
||||
return out
|
||||
}
|
||||
const pairs = boundedData(args[0], "Object.fromEntries input")
|
||||
if (!Array.isArray(pairs)) {
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects an array of [key, value] pairs.", node)
|
||||
}
|
||||
const out: Record<string, unknown> = Object.create(null)
|
||||
for (const pair of pairs) {
|
||||
if (!Array.isArray(pair)) {
|
||||
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] pairs.", node)
|
||||
}
|
||||
guardedSet(out, String(pair[0]), pair[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
throw new InterpreterRuntimeError(`Object.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { PromiseMethodName } from "../interpreter/model.js"
|
||||
|
||||
export const promiseStatics = new Set<PromiseMethodName>(["all", "allSettled", "race", "resolve", "reject"])
|
||||
|
||||
/** Maximum number of eagerly forked tool calls that may run concurrently. */
|
||||
export const TOOL_CALL_CONCURRENCY = 8
|
||||
@@ -0,0 +1,74 @@
|
||||
export const regexpMethods = new Set(["test", "exec", "toString"])
|
||||
|
||||
export const regexpProperties = new Set([
|
||||
"source",
|
||||
"flags",
|
||||
"lastIndex",
|
||||
"global",
|
||||
"ignoreCase",
|
||||
"multiline",
|
||||
"sticky",
|
||||
"unicode",
|
||||
"dotAll",
|
||||
])
|
||||
|
||||
export const regexFailureReason = (error: unknown): string =>
|
||||
(error instanceof Error ? error.message : String(error)).replace(/^Invalid regular expression:\s*/i, "")
|
||||
|
||||
export const escapeRegexHint =
|
||||
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
|
||||
|
||||
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
|
||||
if (arg instanceof SandboxRegExp) return arg.regex
|
||||
if (typeof arg === "string") {
|
||||
try {
|
||||
return new RegExp(arg, extraFlags)
|
||||
} catch (error) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
|
||||
node,
|
||||
).as("SyntaxError")
|
||||
}
|
||||
}
|
||||
throw new InterpreterRuntimeError(
|
||||
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
|
||||
node,
|
||||
)
|
||||
}
|
||||
|
||||
export const matchToValue = (match: RegExpMatchArray): Array<unknown> => {
|
||||
const result: Array<unknown> = Array.from(match, (group) => group)
|
||||
if (match.index !== undefined) (result as Record<string, unknown> & Array<unknown>).index = match.index
|
||||
if (match.groups) {
|
||||
const groups: SafeObject = Object.create(null) as SafeObject
|
||||
for (const [key, group] of Object.entries(match.groups)) {
|
||||
if (!isBlockedMember(key)) groups[key] = group
|
||||
}
|
||||
;(result as Record<string, unknown> & Array<unknown>).groups = groups
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const invokeRegExpMethod = (
|
||||
value: SandboxRegExp,
|
||||
name: string,
|
||||
args: Array<unknown>,
|
||||
node: AstNode,
|
||||
): unknown => {
|
||||
switch (name) {
|
||||
case "test":
|
||||
return value.regex.test(coerceToString(args[0]))
|
||||
case "exec": {
|
||||
const matched = value.regex.exec(coerceToString(args[0]))
|
||||
return matched === null ? null : matchToValue(matched)
|
||||
}
|
||||
case "toString":
|
||||
return coerceToString(value)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`RegExp method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { isBlockedMember, type SafeObject } from "../tool-runtime.js"
|
||||
import { SandboxRegExp } from "../values.js"
|
||||
import { coerceToString } from "./value.js"
|
||||
@@ -0,0 +1,52 @@
|
||||
export const stringMethods = new Set([
|
||||
"toLowerCase",
|
||||
"toUpperCase",
|
||||
"trim",
|
||||
"trimStart",
|
||||
"trimEnd",
|
||||
"trimLeft",
|
||||
"trimRight",
|
||||
"split",
|
||||
"slice",
|
||||
"substring",
|
||||
"substr",
|
||||
"includes",
|
||||
"startsWith",
|
||||
"endsWith",
|
||||
"indexOf",
|
||||
"lastIndexOf",
|
||||
"replace",
|
||||
"replaceAll",
|
||||
"repeat",
|
||||
"padStart",
|
||||
"padEnd",
|
||||
"charAt",
|
||||
"charCodeAt",
|
||||
"codePointAt",
|
||||
"at",
|
||||
"concat",
|
||||
"toString",
|
||||
"match",
|
||||
"matchAll",
|
||||
"search",
|
||||
"localeCompare",
|
||||
"normalize",
|
||||
])
|
||||
|
||||
export const stringStatics = new Set(["fromCharCode", "fromCodePoint"])
|
||||
|
||||
export const invokeStringStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const codes = args.map((arg) => {
|
||||
if (typeof arg !== "number") throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
|
||||
return arg
|
||||
})
|
||||
switch (name) {
|
||||
case "fromCharCode":
|
||||
return String.fromCharCode(...codes)
|
||||
case "fromCodePoint":
|
||||
return String.fromCodePoint(...codes)
|
||||
default:
|
||||
throw new InterpreterRuntimeError(`String.${name} is not available in CodeMode.`, node)
|
||||
}
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
@@ -0,0 +1,90 @@
|
||||
export const urlProperties = new Set([
|
||||
"href",
|
||||
"origin",
|
||||
"protocol",
|
||||
"username",
|
||||
"password",
|
||||
"host",
|
||||
"hostname",
|
||||
"port",
|
||||
"pathname",
|
||||
"search",
|
||||
"hash",
|
||||
])
|
||||
|
||||
export const urlWritableProperties = new Set([
|
||||
"href",
|
||||
"protocol",
|
||||
"username",
|
||||
"password",
|
||||
"host",
|
||||
"hostname",
|
||||
"port",
|
||||
"pathname",
|
||||
"search",
|
||||
"hash",
|
||||
])
|
||||
|
||||
export const urlMethods = new Set(["toString", "toJSON"])
|
||||
export const urlStatics = new Set(["canParse", "parse"])
|
||||
export const urlSearchParamsMethods = new Set([
|
||||
"append",
|
||||
"delete",
|
||||
"get",
|
||||
"getAll",
|
||||
"has",
|
||||
"set",
|
||||
"sort",
|
||||
"forEach",
|
||||
"keys",
|
||||
"values",
|
||||
"entries",
|
||||
"toString",
|
||||
])
|
||||
|
||||
export const uriArgument = (value: unknown, label: string): string => coerceToString(boundedData(value, label))
|
||||
|
||||
export const invokeUriFunction = (ref: UriFunction, args: Array<unknown>, node: AstNode): string => {
|
||||
const value = uriArgument(args[0], `${ref.name} input`)
|
||||
try {
|
||||
switch (ref.name) {
|
||||
case "encodeURI":
|
||||
return encodeURI(value)
|
||||
case "encodeURIComponent":
|
||||
return encodeURIComponent(value)
|
||||
case "decodeURI":
|
||||
return decodeURI(value)
|
||||
case "decodeURIComponent":
|
||||
return decodeURIComponent(value)
|
||||
}
|
||||
} catch (error) {
|
||||
throw new InterpreterRuntimeError(
|
||||
`${ref.name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
|
||||
node,
|
||||
).as("URIError")
|
||||
}
|
||||
}
|
||||
|
||||
export const urlArgument = (value: unknown, label: string): string =>
|
||||
value instanceof SandboxURL ? value.url.href : uriArgument(value, label)
|
||||
|
||||
export const invokeURLStatic = (name: string, args: Array<unknown>, node: AstNode): unknown => {
|
||||
if (!urlStatics.has(name)) throw new InterpreterRuntimeError(`URL.${name} is not available in CodeMode.`, node)
|
||||
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node).as("TypeError")
|
||||
const input = urlArgument(args[0], `URL.${name} input`)
|
||||
const base = args[1] === undefined ? undefined : urlArgument(args[1], `URL.${name} base`)
|
||||
try {
|
||||
const url = new URL(input, base)
|
||||
return name === "canParse" ? true : new SandboxURL(url)
|
||||
} catch {
|
||||
return name === "canParse" ? false : null
|
||||
}
|
||||
}
|
||||
|
||||
export const invokeURLMethod = (value: SandboxURL, name: string, node: AstNode): string => {
|
||||
if (name === "toString" || name === "toJSON") return value.url.href
|
||||
throw new InterpreterRuntimeError(`URL method '${name}' is not available in CodeMode.`, node)
|
||||
}
|
||||
import { type AstNode, InterpreterRuntimeError, UriFunction } from "../interpreter/model.js"
|
||||
import { SandboxURL } from "../values.js"
|
||||
import { boundedData, coerceToString } from "./value.js"
|
||||
@@ -0,0 +1,86 @@
|
||||
export const errorConstructors = new Set([
|
||||
"Error",
|
||||
"TypeError",
|
||||
"RangeError",
|
||||
"SyntaxError",
|
||||
"ReferenceError",
|
||||
"EvalError",
|
||||
"URIError",
|
||||
])
|
||||
|
||||
export const valueConstructors = new Set(["Date", "RegExp", "Map", "Set", "URL", "URLSearchParams"])
|
||||
|
||||
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
|
||||
|
||||
const ErrorBrand: unique symbol = Symbol("codemode.error")
|
||||
|
||||
export const createErrorValue = (name: string, message: string): SafeObject => {
|
||||
const value = Object.assign(Object.create(null) as SafeObject, { name, message })
|
||||
Object.defineProperty(value, ErrorBrand, { value: name })
|
||||
return value
|
||||
}
|
||||
|
||||
export const errorBrandName = (value: unknown): string | undefined =>
|
||||
value !== null && typeof value === "object"
|
||||
? ((value as Record<PropertyKey, unknown>)[ErrorBrand] as string | undefined)
|
||||
: undefined
|
||||
|
||||
export const boundedData = (value: unknown, label: string): unknown => copyIn(value, label, true)
|
||||
|
||||
export const coerceToString = (value: unknown): string => {
|
||||
if (value === null) return "null"
|
||||
if (value === undefined) return "undefined"
|
||||
if (value instanceof SandboxDate)
|
||||
return Number.isFinite(value.time) ? new Date(value.time).toISOString() : "Invalid Date"
|
||||
if (value instanceof SandboxRegExp) return `/${value.regex.source}/${value.regex.flags}`
|
||||
if (value instanceof SandboxMap) return "[object Map]"
|
||||
if (value instanceof SandboxSet) return "[object Set]"
|
||||
if (value instanceof SandboxURL) return value.url.href
|
||||
if (value instanceof SandboxURLSearchParams) return value.params.toString()
|
||||
if (typeof value === "object") {
|
||||
return Array.isArray(value)
|
||||
? value.map((item) => (item === null || item === undefined ? "" : coerceToString(item))).join(",")
|
||||
: "[object Object]"
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export const coerceToNumber = (value: unknown): number => {
|
||||
if (value instanceof SandboxDate) return value.time
|
||||
if (isSandboxValue(value)) return Number.NaN
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value) ? Number.NaN : Number(value)
|
||||
}
|
||||
|
||||
export const invokeCoercion = (ref: CoercionFunction, args: Array<unknown>, node: AstNode): unknown => {
|
||||
const raw = args[0]
|
||||
if (isSandboxValue(raw)) {
|
||||
if (ref.name === "Boolean") return true
|
||||
if (ref.name === "Number") return coerceToNumber(raw)
|
||||
if (ref.name === "String") return coerceToString(raw)
|
||||
if (ref.name === "parseInt") return parseInt(coerceToString(raw))
|
||||
return parseFloat(coerceToString(raw))
|
||||
}
|
||||
const value = boundedData(args[0], `${ref.name} input`)
|
||||
if (ref.name === "Number") return coerceToNumber(value)
|
||||
if (ref.name === "Boolean") return Boolean(value)
|
||||
if (ref.name === "parseInt") {
|
||||
const radix = args[1]
|
||||
if (radix !== undefined && typeof radix !== "number") {
|
||||
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
|
||||
}
|
||||
return parseInt(coerceToString(value), radix)
|
||||
}
|
||||
if (ref.name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
return coerceToString(value)
|
||||
}
|
||||
import { type AstNode, CoercionFunction, InterpreterRuntimeError } from "../interpreter/model.js"
|
||||
import { copyIn, type SafeObject } from "../tool-runtime.js"
|
||||
import {
|
||||
isSandboxValue,
|
||||
SandboxDate,
|
||||
SandboxMap,
|
||||
SandboxRegExp,
|
||||
SandboxSet,
|
||||
SandboxURL,
|
||||
SandboxURLSearchParams,
|
||||
} from "../values.js"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user