mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-28 19:47:52 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80241e9718 | ||
|
|
4b2dda60d6 | ||
|
|
c1c9a13993 | ||
|
|
917d904f18 | ||
|
|
ee5b67eb84 | ||
|
|
048a47e89e | ||
|
|
5335347e80 | ||
|
|
16b18dff13 | ||
|
|
61c2349cef | ||
|
|
684721efb8 | ||
|
|
962c14a49c | ||
|
|
85b98e7da4 | ||
|
|
8061220b08 | ||
|
|
b02cc35f13 | ||
|
|
e23d89c9a9 | ||
|
|
e8b3e19e85 | ||
|
|
5256f30957 |
@@ -597,8 +597,8 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opencode/theme": "workspace:*",
|
||||
"@opentui/core": ">=0.5.10",
|
||||
"@opentui/solid": ">=0.5.10",
|
||||
"@opentui/core": ">=0.5.12",
|
||||
"@opentui/solid": ">=0.5.12",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
@@ -1114,9 +1114,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@pierre/diffs": "1.2.10",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.71.0",
|
||||
@@ -2252,27 +2252,27 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.5.10", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.10", "@opentui/core-darwin-x64": "0.5.10", "@opentui/core-linux-arm64": "0.5.10", "@opentui/core-linux-arm64-musl": "0.5.10", "@opentui/core-linux-x64": "0.5.10", "@opentui/core-linux-x64-musl": "0.5.10", "@opentui/core-win32-arm64": "0.5.10", "@opentui/core-win32-x64": "0.5.10" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-C3a2UbmefeAjIxAgm4BqjuSxKT4oqutfvYFwVvUgMxmGRHkNbBc/s7sukV0JgwcxFcV3uMFrXxo+E+BQtvuOiw=="],
|
||||
"@opentui/core": ["@opentui/core@0.5.12", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.12", "@opentui/core-darwin-x64": "0.5.12", "@opentui/core-linux-arm64": "0.5.12", "@opentui/core-linux-arm64-musl": "0.5.12", "@opentui/core-linux-x64": "0.5.12", "@opentui/core-linux-x64-musl": "0.5.12", "@opentui/core-win32-arm64": "0.5.12", "@opentui/core-win32-x64": "0.5.12" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-ZXBE5gmvdovmV8zJQrOQf6E44v1tJRDEgrM2MYhEglzgXZ+smIUp95O8zeRYGsuIzQIiMPMgQqKtTJuzvAb7BQ=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Vyb+nTbhab8ZcRy5gg1loEEGwRcIbjAeVRIBfHBcbFDqmITBOg7x2gqJ+x/TnoOy4uwMhCmICUN2wiyREw3r1Q=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YdVnP0tAyerBNl0mIcmQEOotPeZzW1VnSXKBl5cyZ5e6nDd2Y+ui/8eRPpn1oqcamf1NCnzS4ohMgejOvna8Zg=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.10", "", { "os": "darwin", "cpu": "x64" }, "sha512-tTFLcM7Oj1gTyhm/bUdAt3C6grZdCxPk6+/g2azcZBUlI3/62LwbeRS6HbQKFFmm+1fUmX8cq6kWrtul885mVg=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-uRrQJdHmLUSj3PV23QPi3WSimYTTxcXnVouxF6U4xMXlOv4N3SxnHfVwMRQkPqbGOfvVWHeLE6FdK4C+ubU0sQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-ncJXcgudhBf2GdJyF3xVQN/Ec+1F7GOL+pRrURmgBYSj2v1w6EyoDQFAACtPTK2c3R38W6fvZwL4JSLlm4EFXQ=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-XeKhuIaEtgipvuPHbl4qPOBj+Ut+2zObmsxMVM1jDcjz/FatG9PGeGQPx1G1SnvH2AgpT4K+eCu7DUF0+yIqoQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.10", "", { "os": "linux", "cpu": "arm64" }, "sha512-dGMphDKexSdeYqwl0wgoFBP88Ta/cdi1Zc1mk29/ENkSCGz+74zlCHgqTHRNGLmI8W5TfuUtCyktQH11/Z+TBQ=="],
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-VZ2sNMw1d/r1SLPjUbOP9LKscKz1CQjID8adTL6gG8Lrrq+mYcIUxutyB+P/eG0J/7oRZLPR6OMt7dUOap6RTg=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-5qtYaOgwVycZD1GaGshTRsi0rXPAmVExO03N1JQaHu+NYxK/vXSOc7Bu4QW0sPXx3Sp0SpzpP+FHjXABfoK66g=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-eZiCjEzwbb6qClPPfk32Nha9xmr9obt69Xj0+9SKsXxWLBKkjQEGOMRoh/R9ObaQF4aq8If1xV3VEY0sD9W9vg=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.10", "", { "os": "linux", "cpu": "x64" }, "sha512-Oj4H9hApuvuTKPWxh4SoZAgGJorR7vbvnrZA/cAkSMAk2VGSoHRRcqeXQbcH8IcdjVZ0KFpv8Zkl/D5Ye+2mew=="],
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.12", "", { "os": "linux", "cpu": "x64" }, "sha512-WWW0hVBoSYZ3D6AgZ4u2Y5/u/IyIq2pDb+4yI3WgJ70Wyt6ofHy+6kRGRgbXFn1p+rPInAHjCXD2v6C7iEKSrA=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.10", "", { "os": "win32", "cpu": "arm64" }, "sha512-A9VhgvTxQoUdZ+8LmUumEng1sQNbj9QQQT3NYG9mSxI54qTANi7vOWNSphMiY6RMVsr22pgm6nUvSSvJXv7Jog=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-aLbm6870Ybls6CYL4zMOCImTBPLZHZMUXJFGqMI44lIWxitkAtT6zg5lYA4oRqFRzzryDclxr29+hDgT3p3Blw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.10", "", { "os": "win32", "cpu": "x64" }, "sha512-u3KHa7kEeWrmKVDRJYpxSGO+g5E9cMGlrmTsPN3GVPHUmQMiREUawLXUvsU8+IHaQnqG3Q5nuE1yf4fPBzS+Qw=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.12", "", { "os": "win32", "cpu": "x64" }, "sha512-KTwtwpfd2zF9opVh3SyRJYDd1o3Xv4XL8OZb8Zi+CqWUel6Y2IDCiVivCv8fGJt3J7wOIXXtuZI9ZUkLyKJCiQ=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.10", "", { "dependencies": { "@opentui/core": "0.5.10" }, "peerDependencies": { "@opentui/react": "0.5.10", "@opentui/solid": "0.5.10", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-8vDJF+ltXscSnLEv3rgCa4m7PcoYZeUT9BngugpFCmVoNevbaRtYijjdfiUuLmXfT61lO5QbR6nEhn2RZMK8ow=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.5.12", "", { "dependencies": { "@opentui/core": "0.5.12" }, "peerDependencies": { "@opentui/react": "0.5.12", "@opentui/solid": "0.5.12", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-yWPvJjRhJTRoRSUucQq9Ua8ZW7n/2YQ/j6JxWq5Qekm4WuFiTplEkebR/Aj2/xA8tX68NOE5qv1LrY0Jk3NLNQ=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.5.10", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.10", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-KrmMIsHiKBHOABTC0brOwqWm+sGq1ZX2sGCAx6WgtBbE3STMup9n8TAy/6gUYhwcjC9zugT53ytfSVwCwVWZUg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.5.12", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.5.12", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-hAiVlVMtT7AkHGblKwcW1YAuXtxkSy1XSf/RRc4j3IlG3mTNX0bhJdnGOo3Xw14EqeZMp41Mcp5WzHAzMm/DzA=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-+Clo0VPDdruHSoBNvV/wKAM8iR6HJPtB00oa8yl9ujU=",
|
||||
"aarch64-linux": "sha256-4wU5v36GTXjwyt5ls4FH+5G43Ujd+dKVSJR21w3lhbA=",
|
||||
"aarch64-darwin": "sha256-pThjoD6baddQ6biy7k1ByXwGwLAeWe/+w0tcYmt1uWs=",
|
||||
"x86_64-darwin": "sha256-bCBl63CqBiqilb+YdaOLBYYZx/yf47c1aqgDOkgdegg="
|
||||
"x86_64-linux": "sha256-aQQQhaUlAhpfqzH0vNi0IJ1cg7FQHIKYzxeq5d8PZoU=",
|
||||
"aarch64-linux": "sha256-r9aDFu3UYmudmmYPhzCrpFvQlaejXc8V1IzLtG3jZPc=",
|
||||
"aarch64-darwin": "sha256-B0m41LelD7d61vPHGIZZSO/cU7gbjHDJHt6oxNRRM8Q=",
|
||||
"x86_64-darwin": "sha256-9TWJsyI3Y6BMomtGSgqA1th9LpxpqP4F5Tl/GyexVYw="
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -52,9 +52,9 @@
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/standard-validator": "0.2.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.5.10",
|
||||
"@opentui/keymap": "0.5.10",
|
||||
"@opentui/solid": "0.5.10",
|
||||
"@opentui/core": "0.5.12",
|
||||
"@opentui/keymap": "0.5.12",
|
||||
"@opentui/solid": "0.5.12",
|
||||
"@tanstack/solid-virtual": "3.13.37",
|
||||
"@shikijs/stream": "4.4.3",
|
||||
"@standard-schema/spec": "1.1.0",
|
||||
|
||||
+10
-3
@@ -789,7 +789,7 @@ await ai.write(video.video, "./kite.mp4")
|
||||
|
||||
Speech (text-to-speech) is one request whose response is parsed incrementally, so every route supports both
|
||||
`Speech.generate` (the whole file) and `Speech.stream` (audio chunks as they arrive). Models come from `.speech(...)`
|
||||
selectors on the `OpenAI`, `Google` (Gemini TTS), `ElevenLabs`, `Cartesia`, and `Deepgram` facades. Common fields
|
||||
selectors on the `OpenAI`, `Google` (Gemini TTS), `ElevenLabs`, `Cartesia`, `Deepgram`, and `XAI` facades. Common fields
|
||||
(`voice`, `format`, `speed`, `language`, `instructions`, `timestamps`) lower natively or fail with a typed `AIError`
|
||||
before any network call; provider-native controls live under `providerOptions`, inferred from the selected model.
|
||||
|
||||
@@ -854,6 +854,10 @@ Provider notes:
|
||||
- **Deepgram** Aura's voice is the model id (`aura-2-thalia-en`), so `voice` and `language` fail typed. `format`
|
||||
and `providerOptions` lower to query parameters (`encoding`, `container`, `sample_rate`, `bit_rate`); `pcm` is
|
||||
`linear16` without a container. Auth is `Authorization: Token <DEEPGRAM_API_KEY>`.
|
||||
- **xAI** (`POST /v1/tts`) has no model field, so the `.speech(...)` id (for example `"grok-tts"`) only names the
|
||||
model. `voice` is the `voice_id` (default `eve`), `language` defaults to `auto`, and `format` is the codec (`mp3`,
|
||||
`wav`, `pcm`, `mulaw`, `alaw`); `providerOptions.sampleRate` and `bitRate` complete `output_format`. Both
|
||||
`generate` and `stream` read the raw audio body. `instructions` and `timestamps` are not supported.
|
||||
|
||||
The promise client mirrors the Effect API; `ai.speech.stream` is an `AsyncIterable`.
|
||||
|
||||
@@ -873,8 +877,8 @@ for await (const event of ai.speech.stream({ model, text: "Hello from OpenCode."
|
||||
Transcription (speech-to-text) is the one modality whose providers use every route kind: OpenAI and Gemini stream,
|
||||
Deepgram answers inline, and AssemblyAI is queued. `Transcription.generate` and `Transcription.stream` work on all of
|
||||
them; `Transcription.start` / `resume` return a `Generation` on queued routes and fail with `UnsupportedOperation`
|
||||
elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`, `Deepgram`, and `AssemblyAI`
|
||||
facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower
|
||||
elsewhere. Models come from `.transcription(...)` selectors on the `OpenAI`, `Google`, `Deepgram`, `AssemblyAI`, and
|
||||
`XAI` facades. Common fields (`language`, `prompt`, `timestamps: "none" | "segment" | "word"`, `diarize`, `speakers`) lower
|
||||
natively or fail with a typed `AIError` before any network call; a route may return more than asked.
|
||||
|
||||
```ts
|
||||
@@ -922,6 +926,9 @@ Provider notes:
|
||||
- **Gemini** needs a transcribe model (`gemini-3.5-transcribe`); `prompt` and `speakers` fail typed.
|
||||
- **Deepgram** detects the language unless `language` is set; vocabulary goes in `providerOptions.keyterm`.
|
||||
- **AssemblyAI** uploads inline audio before submitting and is the only route that accepts `speakers`.
|
||||
- **xAI** (`grok-voice-transcribe-2.0`) answers inline and always returns words; `diarize` or `timestamps: "segment"`
|
||||
groups them into speaker-turn segments. Vocabulary goes in `providerOptions.keyterm`, and headerless PCM uploads
|
||||
send `audio_format` and `sample_rate` from `audio.info`. `prompt` and `speakers` fail typed.
|
||||
|
||||
The promise client mirrors the Effect API:
|
||||
|
||||
|
||||
@@ -199,7 +199,8 @@ hints (none of the four providers emit one). Later providers: Luma, Kling, MiniM
|
||||
#### Speech (TTS)
|
||||
|
||||
Shipped in phase 3 (`src/speech.ts`, `src/speech-client.ts`, protocols `openai-speech`, `google-speech`,
|
||||
`elevenlabs-speech`, `cartesia-speech`, `deepgram-speech`; new `ElevenLabs`, `Cartesia`, and `Deepgram` facades).
|
||||
`elevenlabs-speech`, `cartesia-speech`, `deepgram-speech`, `xai-speech`; new `ElevenLabs`, `Cartesia`, and `Deepgram`
|
||||
facades).
|
||||
|
||||
```ts
|
||||
const request = Speech.request({
|
||||
@@ -248,7 +249,7 @@ unsupported field: `UnsupportedOperation` with `operation: "media.format"`.
|
||||
|
||||
**Timestamps.** `timestamps: true` on the request asks for alignment. ElevenLabs selects the `with-timestamps`
|
||||
endpoints (character-level, NDJSON when streaming); Cartesia sets `add_timestamps` on `/tts/sse` (word-level; a
|
||||
`generate` with timestamps collects the SSE stream). OpenAI, Gemini, and Deepgram reject it.
|
||||
`generate` with timestamps collects the SSE stream). OpenAI, Gemini, Deepgram, and xAI reject it.
|
||||
|
||||
Common-field lowering per provider:
|
||||
|
||||
@@ -259,6 +260,7 @@ Common-field lowering per provider:
|
||||
| ElevenLabs | path voice id (required) | `voice_settings.speed` | `language_code` | unsupported | `with-timestamps` | `credits` from `character-cost` header |
|
||||
| Cartesia | `voice` (required) | `generation_config.speed` | `language` | unsupported | `add_timestamps` | none |
|
||||
| Deepgram | unsupported (voice is the model) | `speed` query | unsupported | unsupported | unsupported | `characters` from `dg-char-count` header |
|
||||
| xAI | `voice_id` (defaults to `eve`) | `speed` | `language` (`auto` when omitted) | unsupported | unsupported | none |
|
||||
|
||||
Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incrementally over a WebSocket (ElevenLabs
|
||||
`stream-input`, Cartesia WebSocket contexts, Deepgram WebSocket speak) — is a separate scoped resource, not part of
|
||||
@@ -267,8 +269,8 @@ Deferred: `Speech.session(...)` — input-streaming TTS where text arrives incre
|
||||
#### Transcription (STT)
|
||||
|
||||
Shipped as the second half of phase 3 (`src/transcription.ts`, `src/transcription-client.ts`, protocols
|
||||
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `assemblyai-transcription`; new `AssemblyAI`
|
||||
facade).
|
||||
`openai-transcription`, `google-transcription`, `deepgram-transcription`, `assemblyai-transcription`,
|
||||
`xai-transcription`; new `AssemblyAI` facade).
|
||||
|
||||
```ts
|
||||
const request = Transcription.request({
|
||||
@@ -327,6 +329,7 @@ Settled rules:
|
||||
| Gemini | stream (`generateContent` / `streamGenerateContent`) | `inlineData` or Gemini Files `fileData` | `audioTranscriptionConfig.wordTimestamp` | `audioTranscriptionConfig.diarization` | `prompt`, `speakers` | `tokens` |
|
||||
| Deepgram | inline | raw body, or JSON `{ url }` | words always; `segment` → `utterances` | `diarize_model=latest` + `utterances` | `prompt`, `speakers` | `seconds` (`metadata.duration`) |
|
||||
| AssemblyAI | queued (upload → submit → poll) | `/v2/upload` then `audio_url`, or a URL | words always; `segment` → `speaker_labels` | `speaker_labels` | — | `seconds` (`audio_duration`) |
|
||||
| xAI | inline (batch `/v1/stt`) | multipart `file` (last field), or `url` | words always; `segment` → `diarize` speaker turns | `diarize` | `prompt`, `speakers` | `seconds` (`duration`) |
|
||||
|
||||
Deferred: `Transcription.session(...)` — realtime STT over WebSocket (Deepgram live, AssemblyAI streaming, ElevenLabs
|
||||
realtime, OpenAI realtime transcription) — is the same future scoped `session` shape as input-streaming TTS and ships
|
||||
@@ -406,7 +409,7 @@ Existing facades gain per-modality selectors; the modality routes each facade pr
|
||||
|---|---|---|---|---|---|---|
|
||||
| `OpenAI` | responses (default), chat | Images API (stream) | Sora (deprecated 2026-09-24) | ✓ | ✓ | |
|
||||
| `Google` | Gemini | Gemini-native | Veo | Gemini TTS | `gemini-3.5-transcribe` | |
|
||||
| `XAI` | ✓ | ✓ | ✓ | | | |
|
||||
| `XAI` | ✓ | ✓ | ✓ | ✓ | ✓ (batch) | |
|
||||
| `ElevenLabs` | | | | ✓ | Scribe | soundEffect, music |
|
||||
| `Cartesia` | | | | ✓ | | |
|
||||
| `Deepgram` | | | | Aura | ✓ | |
|
||||
@@ -460,7 +463,7 @@ Foundation + Image ship together as the reference implementation, serially. Vide
|
||||
|
||||
1. **Foundation** — per-modality selectors, `Media`, `Generation`, `Poll`, `Usage` union, `MediaProtocol` kinds, `@opencode/ai/promise` with `llm` + `image`. Port the five existing image protocols onto it. Unify `MediaPart` and add the `media` LLM event (fixes Gemini image output being dropped).
|
||||
2. **Video** — ✅ Veo, xAI, fal, Runway shipped (`MediaProtocol.queued`, `Video.start/generate/resume/stream`, promise `ai.video`). Deferred: `Video.complete` (webhooks), Luma, Kling, MiniMax, Replicate.
|
||||
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Pending: ElevenLabs Scribe. Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
|
||||
3. **Speech + Transcription** — ✅ Speech: OpenAI, Gemini TTS, ElevenLabs, Cartesia, Deepgram, xAI shipped (`MediaProtocol.stream`, `Speech.generate/stream`, promise `ai.speech`). ✅ Transcription: OpenAI, Gemini, Deepgram, AssemblyAI, xAI shipped across all three route kinds (`Transcription.generate/stream/start/resume`, promise `ai.transcription`). Pending: ElevenLabs Scribe. Deferred: `Speech.session` and `Transcription.session` (WebSocket streaming).
|
||||
4. **Image queued routes and partials** — ✅ BFL, fal, Replicate, and Stability creative upscale queued; Stability generate inline; OpenAI `partial_images` streaming (`image-partial` restored). Imagen dropped: shut down on the Gemini API and discontinued on Vertex (2026-06-30). Deferred: Stability's synchronous edit and fast/conservative upscale endpoints.
|
||||
5. **Later** — ElevenLabs music/SFX, Lyria, `Speech.session` / `Transcription.session`, realtime.
|
||||
|
||||
|
||||
@@ -178,7 +178,7 @@ const fromRequest = Effect.fn("OpenAITranscription.fromRequest")(function* (requ
|
||||
{
|
||||
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
|
||||
reserved: RESERVED_FORM_FIELDS,
|
||||
repeatArrays: true,
|
||||
repeatArrays: "key[]",
|
||||
},
|
||||
)
|
||||
return MediaProtocol.multipart(form)
|
||||
|
||||
@@ -71,8 +71,8 @@ export const imageOutput = (
|
||||
}
|
||||
|
||||
/**
|
||||
* Append multipart text fields: strings as-is, other values as JSON, or arrays as repeated `key[]` parts with
|
||||
* `repeatArrays`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace route-owned fields.
|
||||
* Append multipart text fields: strings as-is, other values as JSON, or arrays as repeated parts named `key[]` or
|
||||
* `key` with `repeatArrays`. `overlay` keys in `reserved` are dropped so `http.body` cannot replace route-owned fields.
|
||||
*/
|
||||
export const appendFields = (
|
||||
form: FormData,
|
||||
@@ -80,13 +80,13 @@ export const appendFields = (
|
||||
options: {
|
||||
readonly overlay?: Record<string, unknown>
|
||||
readonly reserved: ReadonlySet<string>
|
||||
readonly repeatArrays?: true
|
||||
readonly repeatArrays?: "key[]" | "key"
|
||||
},
|
||||
) => {
|
||||
const overlay = Object.entries(options.overlay ?? {}).filter(([key]) => !options.reserved.has(key))
|
||||
Object.entries(mergeJsonRecords(fields, Object.fromEntries(overlay)) ?? {}).forEach(([key, value]) => {
|
||||
if (Array.isArray(value) && options.repeatArrays)
|
||||
return value.forEach((item) => form.append(`${key}[]`, String(item)))
|
||||
if (Array.isArray(value) && options.repeatArrays !== undefined)
|
||||
return value.forEach((item) => form.append(options.repeatArrays === "key[]" ? `${key}[]` : key, String(item)))
|
||||
form.append(key, typeof value === "string" ? value : encodeJson(value))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Effect } from "effect"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { mergeJsonRecords } from "../schema/index.js"
|
||||
import { SpeechModel, type SpeechEvent, type SpeechRequestFor } from "../speech.js"
|
||||
import { SpeechStream } from "./utils/speech-stream.js"
|
||||
|
||||
const route = MediaProtocol.identity({ id: "xai-speech", name: "xAI Speech", provider: "xai" })
|
||||
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||
export const PATH = "/tts"
|
||||
const DEFAULT_SAMPLE_RATE = 24000
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `voice`, `format`, `speed`, and `language` are common request fields; other native body fields pass through. */
|
||||
export type XAISpeechOptions = {
|
||||
readonly sampleRate?: 8000 | 16000 | 22050 | 24000 | 44100 | 48000
|
||||
/** MP3 only. */
|
||||
readonly bitRate?: 32000 | 64000 | 96000 | 128000 | 192000
|
||||
readonly optimize_streaming_latency?: number
|
||||
readonly text_normalization?: boolean
|
||||
readonly replace?: Readonly<Record<string, string>>
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = SpeechRequestFor<XAISpeechOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Parser state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type State = SpeechStream.Audio
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** `output_format.codec` values; headerless codecs map to the PCM encoding of their samples. */
|
||||
const CODECS = new Map<string, SpeechStream.PcmEncoding | undefined>([
|
||||
["mp3", undefined],
|
||||
["wav", undefined],
|
||||
["pcm", "pcm_s16le"],
|
||||
["mulaw", "pcm_mulaw"],
|
||||
["alaw", "pcm_alaw"],
|
||||
])
|
||||
|
||||
const outputFormat = Effect.fn("XAISpeech.outputFormat")(function* (request: Request) {
|
||||
const codec = request.format ?? "mp3"
|
||||
if (!CODECS.has(codec))
|
||||
return yield* route.unsupported(
|
||||
"media.format",
|
||||
`${route.name} supports the mp3, wav, pcm, mulaw, and alaw formats, not "${codec}"`,
|
||||
)
|
||||
return { codec, sample_rate: request.providerOptions?.sampleRate, bit_rate: request.providerOptions?.bitRate }
|
||||
})
|
||||
|
||||
// The TTS API has no model field, so the selected model id only names the model.
|
||||
const fromRequest = Effect.fn("XAISpeech.fromRequest")(function* (request: MediaProtocol.Addressed<Request>) {
|
||||
const { sampleRate: _sampleRate, bitRate: _bitRate, ...native } = request.providerOptions ?? {}
|
||||
return MediaProtocol.json(
|
||||
mergeJsonRecords(
|
||||
{
|
||||
text: request.text,
|
||||
voice_id: SpeechStream.voiceID(request.voice),
|
||||
// `language` is required; `auto` detects it from the text.
|
||||
language: request.language ?? "auto",
|
||||
output_format: yield* outputFormat(request),
|
||||
speed: request.speed,
|
||||
},
|
||||
native,
|
||||
request.http?.body,
|
||||
) ?? {},
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Stream parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const finish = Effect.fn("XAISpeech.finish")(function* (state: State, context: MediaProtocol.ResponseContext<Request>) {
|
||||
const format = yield* outputFormat(context.request)
|
||||
const sampleRate = format.sample_rate ?? DEFAULT_SAMPLE_RATE
|
||||
const encoding = CODECS.get(format.codec)
|
||||
return yield* SpeechStream.finish(
|
||||
route,
|
||||
state,
|
||||
encoding === undefined ? SpeechStream.container(format.codec, sampleRate) : SpeechStream.pcm(encoding, sampleRate),
|
||||
)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The response body is the raw audio in both modes, so `stream` forwards body chunks as they arrive. */
|
||||
export const protocol = MediaProtocol.stream<Request, SpeechEvent, Uint8Array, State>(route, {
|
||||
unsupported: ["instructions", "timestamps"],
|
||||
body: { from: fromRequest },
|
||||
frames: (bytes) => bytes,
|
||||
initial: () => ({ chunks: [] }),
|
||||
step: (state, frame) => Effect.succeed(SpeechStream.delta(state, frame)),
|
||||
finish,
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
SpeechModel.fromRoute<XAISpeechOptions, Uint8Array, State>({ protocol, baseURL: DEFAULT_BASE_URL, path: PATH }, input)
|
||||
|
||||
export const XAISpeech = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { MediaProtocol } from "../route/media-protocol.js"
|
||||
import { MediaRoute } from "../route/media.js"
|
||||
import { mergeJsonRecords, type OpenString } from "../schema/index.js"
|
||||
import {
|
||||
TranscriptionModel,
|
||||
TranscriptionResponse,
|
||||
type TranscriptionRequestFor,
|
||||
type TranscriptionSegment,
|
||||
type TranscriptionWord,
|
||||
} from "../transcription.js"
|
||||
import { mediaTypeExtension } from "../utils/media-type.js"
|
||||
import { ProviderShared } from "./shared.js"
|
||||
import { MediaInput } from "./utils/media-input.js"
|
||||
|
||||
const route = MediaProtocol.identity({ id: "xai-transcription", name: "xAI Transcription", provider: "xai" })
|
||||
export const DEFAULT_BASE_URL = "https://api.x.ai/v1"
|
||||
export const PATH = "/stt"
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Public model input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type XAITranscriptionOptions = {
|
||||
/** Inverse text normalization ("one hundred dollars" → "$100"); requires `language`. */
|
||||
readonly format?: boolean
|
||||
readonly keyterm?: ReadonlyArray<string>
|
||||
readonly filler_words?: boolean
|
||||
/** Headerless audio only; derived from `audio.info.encoding` and `audio.info.sampleRate` when omitted. */
|
||||
readonly audio_format?: OpenString<"pcm" | "mulaw" | "alaw">
|
||||
readonly sample_rate?: 8000 | 16000 | 22050 | 24000 | 44100 | 48000
|
||||
readonly multichannel?: boolean
|
||||
readonly channels?: number
|
||||
readonly vad_threshold?: number
|
||||
} & Record<string, unknown>
|
||||
|
||||
export type Request = TranscriptionRequestFor<XAITranscriptionOptions>
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Response schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const Word = Schema.Struct({
|
||||
text: Schema.String,
|
||||
start: Schema.Number,
|
||||
end: Schema.Number,
|
||||
confidence: Schema.optional(Schema.Number),
|
||||
speaker: Schema.optional(Schema.Number),
|
||||
})
|
||||
|
||||
const SttResponse = Schema.Struct({
|
||||
text: Schema.String,
|
||||
language: Schema.optional(Schema.String),
|
||||
duration: Schema.optional(Schema.Number),
|
||||
words: Schema.optional(Schema.Array(Word)),
|
||||
channels: Schema.optional(
|
||||
Schema.Array(
|
||||
Schema.Struct({
|
||||
index: Schema.Number,
|
||||
text: Schema.String,
|
||||
language: Schema.optional(Schema.String),
|
||||
words: Schema.optional(Schema.Array(Word)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Request body construction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** xAI returns only words, so segments are diarized speaker turns, as with AssemblyAI utterances. */
|
||||
const wantsSegments = (request: Request) => request.diarize === true || request.timestamps === "segment"
|
||||
|
||||
const RAW_AUDIO_FORMATS: Readonly<Record<string, string>> = {
|
||||
pcm_s16le: "pcm",
|
||||
pcm_mulaw: "mulaw",
|
||||
pcm_alaw: "alaw",
|
||||
}
|
||||
|
||||
const RESERVED_FORM_FIELDS = new Set(["file", "url", "model", "language", "diarize"])
|
||||
|
||||
const fromRequest = Effect.fn("XAITranscription.fromRequest")(function* (request: Request) {
|
||||
const audioFormat = RAW_AUDIO_FORMATS[request.audio.info?.encoding ?? ""]
|
||||
const form = new FormData()
|
||||
MediaInput.appendFields(
|
||||
form,
|
||||
{
|
||||
model: request.model.id,
|
||||
language: request.language,
|
||||
diarize: wantsSegments(request) ? true : undefined,
|
||||
audio_format: audioFormat,
|
||||
sample_rate: audioFormat === undefined ? undefined : request.audio.info?.sampleRate,
|
||||
},
|
||||
{
|
||||
overlay: mergeJsonRecords(request.providerOptions, request.http?.body),
|
||||
reserved: RESERVED_FORM_FIELDS,
|
||||
repeatArrays: "key",
|
||||
},
|
||||
)
|
||||
// `file` must be the last field: options after it may be ignored for streamed uploads.
|
||||
const url = ProviderShared.mediaUrl(request.audio)
|
||||
if (url !== undefined) {
|
||||
form.append("url", url)
|
||||
return MediaProtocol.multipart(form)
|
||||
}
|
||||
const audio = yield* MediaInput.inlineBytes(route.id, request.audio)
|
||||
const extension = mediaTypeExtension(request.audio.mediaType)
|
||||
form.append(
|
||||
"file",
|
||||
MediaInput.blob(audio, request.audio.mediaType),
|
||||
extension === undefined ? "audio" : `audio.${extension}`,
|
||||
)
|
||||
return MediaProtocol.multipart(form)
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Response decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const decodeStt = route.decodeJson(SttResponse)
|
||||
|
||||
const word = (value: typeof Word.Type): TranscriptionWord => ({
|
||||
text: value.text,
|
||||
startSeconds: value.start,
|
||||
endSeconds: value.end,
|
||||
speaker: value.speaker === undefined ? undefined : String(value.speaker),
|
||||
confidence: value.confidence,
|
||||
})
|
||||
|
||||
const speakerSegments = (words: ReadonlyArray<TranscriptionWord>) =>
|
||||
words.reduce<Array<TranscriptionSegment>>((turns, next) => {
|
||||
const last = turns.at(-1)
|
||||
if (last === undefined || last.speaker !== next.speaker)
|
||||
return [
|
||||
...turns,
|
||||
{ text: next.text, startSeconds: next.startSeconds, endSeconds: next.endSeconds, speaker: next.speaker },
|
||||
]
|
||||
turns[turns.length - 1] = { ...last, text: `${last.text} ${next.text}`, endSeconds: next.endSeconds }
|
||||
return turns
|
||||
}, [])
|
||||
|
||||
const decodeResponse = Effect.fn("XAITranscription.decodeResponse")(function* (
|
||||
response: HttpClientResponse.HttpClientResponse,
|
||||
context: MediaProtocol.DecodeContext<Request>,
|
||||
) {
|
||||
const output = yield* decodeStt(response)
|
||||
const transcript = output.value
|
||||
const words = transcript.words?.map(word)
|
||||
const duration = transcript.duration
|
||||
return new TranscriptionResponse({
|
||||
text: transcript.text,
|
||||
segments: words === undefined || !wantsSegments(context.request) ? undefined : speakerSegments(words),
|
||||
words,
|
||||
language: transcript.language?.toLowerCase(),
|
||||
durationSeconds: duration,
|
||||
usage: duration === undefined ? undefined : { type: "seconds", seconds: duration },
|
||||
providerMetadata: transcript.channels === undefined ? undefined : { xai: { channels: transcript.channels } },
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Protocol and route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const protocol = MediaProtocol.inline<Request, TranscriptionResponse>(route, {
|
||||
unsupported: ["prompt", "speakers"],
|
||||
body: { from: fromRequest },
|
||||
response: { decode: decodeResponse },
|
||||
})
|
||||
|
||||
export const model = (input: MediaRoute.ModelInput) =>
|
||||
TranscriptionModel.fromRoute<XAITranscriptionOptions>({ protocol, baseURL: DEFAULT_BASE_URL, path: PATH }, input)
|
||||
|
||||
export const XAITranscription = {
|
||||
protocol,
|
||||
model,
|
||||
} as const
|
||||
@@ -7,6 +7,8 @@ import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { OpenResponsesChannel } from "../protocols/open-responses-channel.js"
|
||||
import { XAIResponses } from "../protocols/xai-responses.js"
|
||||
import { XAIImages } from "../protocols/xai-images.js"
|
||||
import { XAISpeech } from "../protocols/xai-speech.js"
|
||||
import { XAITranscription } from "../protocols/xai-transcription.js"
|
||||
import { XAIVideo } from "../protocols/xai-video.js"
|
||||
import type { OpenAIOptionsInput } from "./openai-options.js"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
@@ -29,6 +31,8 @@ export type Settings = ProviderPackage.Settings &
|
||||
}
|
||||
|
||||
export type { XAIImageOptions } from "../protocols/xai-images.js"
|
||||
export type { XAISpeechOptions } from "../protocols/xai-speech.js"
|
||||
export type { XAITranscriptionOptions } from "../protocols/xai-transcription.js"
|
||||
export type { XAIVideoOptions } from "../protocols/xai-video.js"
|
||||
|
||||
const RESPONSES_WEBSOCKET_ROTATE_AFTER_MS = 24 * 60 * 1000
|
||||
@@ -98,6 +102,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
|
||||
chat,
|
||||
image: (modelID: string | ModelID) => XAIImages.model({ ...media, id: modelID }),
|
||||
video: (modelID: string | ModelID) => XAIVideo.model({ ...media, id: modelID }),
|
||||
speech: (modelID: string | ModelID) => XAISpeech.model({ ...media, id: modelID }),
|
||||
transcription: (modelID: string | ModelID) => XAITranscription.model({ ...media, id: modelID }),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
@@ -119,3 +125,5 @@ export const responses = provider.responses
|
||||
export const chat = provider.chat
|
||||
export const image = provider.image
|
||||
export const video = provider.video
|
||||
export const speech = provider.speech
|
||||
export const transcription = provider.transcription
|
||||
|
||||
@@ -88,15 +88,19 @@ const decodeProviderBody = Schema.decodeUnknownOption(
|
||||
Schema.fromJsonString(
|
||||
Schema.Struct({
|
||||
message: Schema.optionalKey(Schema.String),
|
||||
error: Schema.optionalKey(Schema.Struct({ message: Schema.optionalKey(Schema.String) })),
|
||||
// xAI sends `{ code, error }` with the readable reason as a plain string.
|
||||
error: Schema.optionalKey(
|
||||
Schema.Union([Schema.String, Schema.Struct({ message: Schema.optionalKey(Schema.String) })]),
|
||||
),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const providerMessage = (status: number, body: string | void) => {
|
||||
const decoded = body === undefined ? undefined : Option.getOrUndefined(decodeProviderBody(body))
|
||||
const error = typeof decoded?.error === "string" ? decoded.error : decoded?.error?.message
|
||||
return (
|
||||
[decoded?.error?.message, decoded?.message].find((message) => message?.trim()) ??
|
||||
[error, decoded?.message].find((message) => message?.trim()) ??
|
||||
`Provider request failed with HTTP ${status}`
|
||||
)
|
||||
}
|
||||
|
||||
@@ -299,6 +299,26 @@ describe("RequestExecutor", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("reads provider messages sent as a plain error string", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
const error = yield* executor.execute(request).pipe(Effect.flip)
|
||||
|
||||
expectAIError(error)
|
||||
expect(error.message).toBe("Your team has no credits for this endpoint")
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
JSON.stringify({
|
||||
code: "The caller does not have permission to execute the specified operation",
|
||||
error: "Your team has no credits for this endpoint",
|
||||
}),
|
||||
{ status: 403 },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("falls back when structured provider messages are empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const executor = yield* RequestExecutor.Service
|
||||
|
||||
@@ -152,6 +152,10 @@ describe("public exports", () => {
|
||||
expect(XAI.configure({ apiKey: "fixture" }).responses("grok-4.3").route.id).toBe("openai-responses")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).chat("grok-4.3").route.id).toBe("openai-compatible-chat")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).video("grok-imagine-video-1.5").route.id).toBe("xai-video")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).speech("grok-tts").route.id).toBe("xai-speech")
|
||||
expect(XAI.configure({ apiKey: "fixture" }).transcription("grok-voice-transcribe-2.0").route.kind).toBe("inline")
|
||||
expect(XAI.provider.speech).toBe(XAI.speech)
|
||||
expect(XAI.provider.transcription).toBe(XAI.transcription)
|
||||
expect(Fal.configure({ apiKey: "fixture" }).video("fal-ai/veo3.1").route.id).toBe("fal-video")
|
||||
expect(Runway.configure({ apiKey: "fixture" }).video("gen4.5").route.id).toBe("runway-video")
|
||||
expect(Runway.provider.video).toBe(Runway.video)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Speech } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { TEXT, collectSpeech } from "./speech-recording.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).speech("grok-tts")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "xai-speech",
|
||||
provider: "xai",
|
||||
protocol: "xai-speech",
|
||||
requires: ["XAI_API_KEY"],
|
||||
})
|
||||
|
||||
describe("xAI Speech recorded", () => {
|
||||
recorded.effect("generates speech", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Speech.generate({ model, text: TEXT, voice: "eve", language: "en" })
|
||||
|
||||
expect(response.audio.mediaType).toBe("audio/mpeg")
|
||||
expect((yield* response.audio.bytes()).length).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("streams speech", () =>
|
||||
Effect.gen(function* () {
|
||||
const { finish } = yield* collectSpeech(
|
||||
Speech.stream({ model, text: TEXT, voice: "eve", language: "en", format: "pcm" }),
|
||||
)
|
||||
|
||||
expect(finish.audio.mediaType).toBe("audio/pcm")
|
||||
expect(finish.audio.info).toMatchObject({ encoding: "pcm_s16le", sampleRate: 24000, channels: 1 })
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { Speech, SpeechClient, SpeechEvent } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, type Call, type Handler, observe } from "../lib/http.js"
|
||||
|
||||
const layer = (handler: Handler) => SpeechClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
||||
|
||||
const xai = XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" })
|
||||
const model = xai.speech("grok-tts")
|
||||
|
||||
const respondAudio = (calls: Array<Call>, body: Uint8Array | ReadableStream<Uint8Array>, contentType: string) =>
|
||||
layer((input) =>
|
||||
observe(calls, input).pipe(Effect.map(() => input.respond(body, { headers: { "content-type": contentType } }))),
|
||||
)
|
||||
|
||||
describe("xAI Speech", () => {
|
||||
it.effect("lowers common fields into the TTS body and describes the requested container", () => {
|
||||
const calls: Array<Call> = []
|
||||
return Effect.gen(function* () {
|
||||
const wav = Uint8Array.from([0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x41, 0x56, 0x45])
|
||||
const response = yield* Speech.generate({
|
||||
model,
|
||||
text: "Hello from OpenCode.",
|
||||
voice: { id: "nlbqfwie" },
|
||||
speed: 1.2,
|
||||
language: "en",
|
||||
format: "wav",
|
||||
providerOptions: { sampleRate: 44100, text_normalization: true },
|
||||
http: { body: { replace: { OpenCode: "Open Code" } } },
|
||||
}).pipe(Effect.provide(respondAudio(calls, wav, "audio/wav")))
|
||||
|
||||
expect(calls.map((call) => [call.method, call.url, call.headers.get("authorization")])).toEqual([
|
||||
["POST", "https://api.xai.test/v1/tts", "Bearer test"],
|
||||
])
|
||||
expect(JSON.parse(calls[0].body)).toEqual({
|
||||
text: "Hello from OpenCode.",
|
||||
voice_id: "nlbqfwie",
|
||||
language: "en",
|
||||
output_format: { codec: "wav", sample_rate: 44100 },
|
||||
speed: 1.2,
|
||||
text_normalization: true,
|
||||
replace: { OpenCode: "Open Code" },
|
||||
})
|
||||
expect(response.audio.mediaType).toBe("audio/wav")
|
||||
expect(response.audio.info).toEqual({ format: "wav", sampleRate: 44100 })
|
||||
expect(yield* response.audio.bytes()).toEqual(wav)
|
||||
expect(response.usage).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("defaults to auto-detected language and 24 kHz MP3", () => {
|
||||
const calls: Array<Call> = []
|
||||
return Effect.gen(function* () {
|
||||
const response = yield* Speech.generate({ model, text: "Hi" }).pipe(
|
||||
Effect.provide(respondAudio(calls, Uint8Array.from([0x49, 0x44, 0x33, 4]), "audio/mpeg")),
|
||||
)
|
||||
|
||||
expect(JSON.parse(calls[0].body)).toEqual({ text: "Hi", language: "auto", output_format: { codec: "mp3" } })
|
||||
expect(response.audio.mediaType).toBe("audio/mpeg")
|
||||
expect(response.audio.info).toEqual({ format: "mp3", sampleRate: 24000 })
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("streams raw body chunks as audio deltas and describes headerless PCM", () => {
|
||||
const calls: Array<Call> = []
|
||||
return Effect.gen(function* () {
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(Uint8Array.from([1, 2]))
|
||||
controller.enqueue(Uint8Array.from([3, 4, 5]))
|
||||
controller.close()
|
||||
},
|
||||
})
|
||||
const events = Array.from(
|
||||
yield* Stream.runCollect(Speech.stream({ model, text: "Hi", voice: "eve", format: "pcm" })).pipe(
|
||||
Effect.provide(respondAudio(calls, body, "audio/pcm")),
|
||||
),
|
||||
)
|
||||
|
||||
expect(JSON.parse(calls[0].body)).toEqual({
|
||||
text: "Hi",
|
||||
voice_id: "eve",
|
||||
language: "auto",
|
||||
output_format: { codec: "pcm" },
|
||||
})
|
||||
expect(events.map((event) => event.type)).toEqual(["audio-delta", "audio-delta", "finish"])
|
||||
expect(events.filter(SpeechEvent.is.audioDelta).map((event) => Array.from(event.chunk))).toEqual([
|
||||
[1, 2],
|
||||
[3, 4, 5],
|
||||
])
|
||||
const finish = events.find(SpeechEvent.is.finish)
|
||||
expect(finish?.audio.mediaType).toBe("audio/pcm")
|
||||
expect(finish?.audio.info).toEqual({ format: "pcm", encoding: "pcm_s16le", sampleRate: 24000, channels: 1 })
|
||||
expect(yield* finish!.audio.bytes()).toEqual(Uint8Array.from([1, 2, 3, 4, 5]))
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("describes telephony codecs at the requested sample rate", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Speech.generate({
|
||||
model,
|
||||
text: "Hi",
|
||||
format: "mulaw",
|
||||
providerOptions: { sampleRate: 8000 },
|
||||
}).pipe(Effect.provide(respondAudio([], Uint8Array.from([0xff, 0x7f]), "audio/basic")))
|
||||
|
||||
expect(response.audio.mediaType).toBe("audio/mulaw")
|
||||
expect(response.audio.info).toEqual({ format: "pcm", encoding: "pcm_mulaw", sampleRate: 8000, channels: 1 })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects what xAI cannot lower before sending anything", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Speech.generate({ model, text: "Hi", instructions: "Warm." }),
|
||||
Speech.generate({ model, text: "Hi", timestamps: true }),
|
||||
Stream.runCollect(Speech.stream({ model, text: "Hi", format: "opus" })),
|
||||
].map((effect) => Effect.flip(effect)),
|
||||
)
|
||||
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "media.instructions"],
|
||||
["UnsupportedOperation", "media.timestamps"],
|
||||
["UnsupportedOperation", "media.format"],
|
||||
],
|
||||
)
|
||||
expect(errors[2].reason).toMatchObject({ provider: "xai", route: "xai-speech" })
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
it.effect("fails typed when the provider returns no audio", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Speech.generate({ model, text: "Hi" }).pipe(
|
||||
Effect.provide(respondAudio([], new Uint8Array(), "audio/mpeg")),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", route: "xai-speech" })
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Transcription } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
import { TRANSCRIPT, audio, audioRecording, dialog } from "./transcription-recording.js"
|
||||
|
||||
const model = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" }).transcription("grok-voice-transcribe-2.0")
|
||||
|
||||
const recorded = recordedTests({
|
||||
prefix: "xai-transcription",
|
||||
provider: "xai",
|
||||
protocol: "xai-transcription",
|
||||
requires: ["XAI_API_KEY"],
|
||||
options: audioRecording,
|
||||
})
|
||||
|
||||
describe("xAI Transcription recorded", () => {
|
||||
recorded.effect("transcribes with word timestamps", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Transcription.generate({ model, audio: yield* audio, language: "en" })
|
||||
|
||||
expect(response.text).toMatch(TRANSCRIPT)
|
||||
expect(response.words?.length).toBeGreaterThan(2)
|
||||
expect(response.words?.every((word) => word.endSeconds >= word.startSeconds)).toBe(true)
|
||||
expect(response.language).toBe("en")
|
||||
expect(response.usage).toEqual({ type: "seconds", seconds: response.durationSeconds })
|
||||
}),
|
||||
)
|
||||
|
||||
recorded.effect("diarizes speakers into segments", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* Transcription.generate({ model, audio: yield* dialog, diarize: true })
|
||||
|
||||
expect(new Set(response.segments?.map((segment) => segment.speaker)).size).toBe(2)
|
||||
expect(response.words?.every((word) => word.speaker !== undefined)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,168 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { Media, Transcription, TranscriptionClient } from "../../src/index.js"
|
||||
import { XAI } from "../../src/providers.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse, json, type Handler } from "../lib/http.js"
|
||||
|
||||
const layer = (handler: Handler) => TranscriptionClient.layer.pipe(Layer.provideMerge(dynamicResponse(handler)))
|
||||
|
||||
const model = XAI.configure({ apiKey: "test", baseURL: "https://api.xai.test/v1" }).transcription(
|
||||
"grok-voice-transcribe-2.0",
|
||||
)
|
||||
const audio = Media.bytes(Uint8Array.from([0x49, 0x44, 0x33, 1, 2, 3]), "audio/mpeg")
|
||||
|
||||
interface Upload {
|
||||
readonly url: string
|
||||
readonly authorization: string | null
|
||||
readonly form: FormData
|
||||
}
|
||||
|
||||
/** Reply with `body` and keep every request's parsed multipart form. */
|
||||
const respondTranscript = (uploads: Array<Upload>, body: unknown) =>
|
||||
layer((input) =>
|
||||
Effect.gen(function* () {
|
||||
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||
const form = yield* Effect.promise(() => web.formData())
|
||||
uploads.push({ url: web.url, authorization: web.headers.get("authorization"), form })
|
||||
return json(input, body)
|
||||
}),
|
||||
)
|
||||
|
||||
describe("xAI Transcription", () => {
|
||||
it.effect("uploads inline audio as multipart with file last and splits diarized words into speaker turns", () => {
|
||||
const uploads: Array<Upload> = []
|
||||
return Effect.gen(function* () {
|
||||
const request = Transcription.request({
|
||||
model,
|
||||
audio,
|
||||
language: "en",
|
||||
diarize: true,
|
||||
timestamps: "word",
|
||||
providerOptions: { format: true, keyterm: ["OpenCode", "Grok"], filler_words: false },
|
||||
http: { body: { vad_threshold: 0.3, model: "ignored" } },
|
||||
})
|
||||
const response = yield* Transcription.generate(request)
|
||||
const events = Array.from(yield* Stream.runCollect(Transcription.stream(request)))
|
||||
|
||||
const upload = uploads[0]
|
||||
expect([upload.url, upload.authorization]).toEqual(["https://api.xai.test/v1/stt", "Bearer test"])
|
||||
expect(Array.from(upload.form.keys())).toEqual([
|
||||
"model",
|
||||
"language",
|
||||
"diarize",
|
||||
"format",
|
||||
"keyterm",
|
||||
"keyterm",
|
||||
"filler_words",
|
||||
"vad_threshold",
|
||||
"file",
|
||||
])
|
||||
expect(upload.form.get("model")).toBe("grok-voice-transcribe-2.0")
|
||||
expect(upload.form.get("language")).toBe("en")
|
||||
expect(upload.form.get("diarize")).toBe("true")
|
||||
expect(upload.form.get("format")).toBe("true")
|
||||
expect(upload.form.getAll("keyterm")).toEqual(["OpenCode", "Grok"])
|
||||
expect(upload.form.get("filler_words")).toBe("false")
|
||||
expect(upload.form.get("vad_threshold")).toBe("0.3")
|
||||
const file = upload.form.get("file")
|
||||
if (!(file instanceof File)) throw new Error("Expected a file upload")
|
||||
expect([file.name, file.type]).toEqual(["audio.mp3", "audio/mpeg"])
|
||||
expect(new Uint8Array(yield* Effect.promise(() => file.arrayBuffer()))).toEqual(yield* audio.bytes())
|
||||
|
||||
expect(response.text).toBe("Did it ship? Yes.")
|
||||
expect(response.words).toEqual([
|
||||
{ text: "Did", startSeconds: 0.2, endSeconds: 0.4, speaker: "0", confidence: 0.9 },
|
||||
{ text: "it", startSeconds: 0.4, endSeconds: 0.5, speaker: "0", confidence: undefined },
|
||||
{ text: "ship?", startSeconds: 0.5, endSeconds: 0.9, speaker: "0", confidence: undefined },
|
||||
{ text: "Yes.", startSeconds: 1.2, endSeconds: 1.6, speaker: "1", confidence: 0.8 },
|
||||
])
|
||||
expect(response.segments).toEqual([
|
||||
{ text: "Did it ship?", startSeconds: 0.2, endSeconds: 0.9, speaker: "0" },
|
||||
{ text: "Yes.", startSeconds: 1.2, endSeconds: 1.6, speaker: "1" },
|
||||
])
|
||||
expect(response).toMatchObject({
|
||||
language: "en",
|
||||
durationSeconds: 1.75,
|
||||
usage: { type: "seconds", seconds: 1.75 },
|
||||
})
|
||||
expect(events.map((event) => event.type)).toEqual(["finish"])
|
||||
}).pipe(
|
||||
Effect.provide(
|
||||
respondTranscript(uploads, {
|
||||
text: "Did it ship? Yes.",
|
||||
language: "EN",
|
||||
duration: 1.75,
|
||||
words: [
|
||||
{ text: "Did", start: 0.2, end: 0.4, confidence: 0.9, speaker: 0 },
|
||||
{ text: "it", start: 0.4, end: 0.5, speaker: 0 },
|
||||
{ text: "ship?", start: 0.5, end: 0.9, speaker: 0 },
|
||||
{ text: "Yes.", start: 1.2, end: 1.6, confidence: 0.8, speaker: 1 },
|
||||
],
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("sends remote audio by URL and describes headerless PCM uploads", () => {
|
||||
const uploads: Array<Upload> = []
|
||||
return Effect.gen(function* () {
|
||||
const remote = yield* Transcription.generate({
|
||||
model,
|
||||
audio: Media.url("https://cdn.test/call.mp3", { mediaType: "audio/mpeg" }),
|
||||
timestamps: "segment",
|
||||
})
|
||||
const pcm = yield* Transcription.generate({
|
||||
model,
|
||||
audio: Media.bytes(Uint8Array.from([0, 1, 0, 1]), "audio/pcm", {
|
||||
info: { format: "pcm", encoding: "pcm_s16le", sampleRate: 16000, channels: 1 },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(Array.from(uploads[0].form.entries())).toEqual([
|
||||
["model", "grok-voice-transcribe-2.0"],
|
||||
["diarize", "true"],
|
||||
["url", "https://cdn.test/call.mp3"],
|
||||
])
|
||||
expect(Array.from(uploads[1].form.keys())).toEqual(["model", "audio_format", "sample_rate", "file"])
|
||||
expect(uploads[1].form.get("audio_format")).toBe("pcm")
|
||||
expect(uploads[1].form.get("sample_rate")).toBe("16000")
|
||||
expect(remote.segments).toBeUndefined()
|
||||
expect(pcm).toMatchObject({ text: "Hi", words: undefined, segments: undefined })
|
||||
}).pipe(Effect.provide(respondTranscript(uploads, { text: "Hi", language: "en", duration: 0.5 })))
|
||||
})
|
||||
|
||||
it.effect("rejects what xAI cannot lower before sending anything", () =>
|
||||
Effect.gen(function* () {
|
||||
const errors = yield* Effect.all(
|
||||
[
|
||||
Transcription.generate({ model, audio, prompt: "OpenCode" }),
|
||||
Transcription.generate({ model, audio, speakers: 2 }),
|
||||
Transcription.start({ model, audio }),
|
||||
Transcription.generate({ model, audio: Media.ref("file_1", { provider: "xai", mediaType: "audio/mpeg" }) }),
|
||||
].map((effect) => Effect.flip(effect)),
|
||||
)
|
||||
|
||||
expect(errors.map((error) => [error.reason._tag, "operation" in error.reason && error.reason.operation])).toEqual(
|
||||
[
|
||||
["UnsupportedOperation", "media.prompt"],
|
||||
["UnsupportedOperation", "media.speakers"],
|
||||
["UnsupportedOperation", "transcription.start"],
|
||||
["InvalidRequest", false],
|
||||
],
|
||||
)
|
||||
expect(errors[0].reason).toMatchObject({ provider: "xai", route: "xai-transcription" })
|
||||
}).pipe(Effect.provide(layer(() => Effect.die("an unsupported request reached the network")))),
|
||||
)
|
||||
|
||||
it.effect("keeps the raw body when the transcript cannot be decoded", () => {
|
||||
const uploads: Array<Upload> = []
|
||||
return Effect.gen(function* () {
|
||||
const error = yield* Transcription.generate({ model, audio }).pipe(Effect.flip)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput", body: JSON.stringify({ words: [] }) })
|
||||
expect(error.reason.http?.status).toBe(200)
|
||||
}).pipe(Effect.provide(respondTranscript(uploads, { words: [] })))
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { DialogProvider } from "@opencode/ui/context/dialog"
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { render } from "solid-js/web"
|
||||
import { Portal, render } from "solid-js/web"
|
||||
import { LanguageProvider, UiI18nBridge } from "../src/runtime/i18n/language"
|
||||
import type { BrowserPaneLayout, BrowserPaneRegistration } from "../src/runtime/platform/browser-pane"
|
||||
import type { createSessionBrowser } from "../src/session/browser/model"
|
||||
@@ -27,7 +27,12 @@ export function mountBrowserPane() {
|
||||
loadErrors: {} as Record<string, string | undefined>,
|
||||
error: undefined as string | undefined,
|
||||
layouts: {} as Record<string, BrowserPaneLayout | undefined>,
|
||||
covered: false,
|
||||
captures: 0,
|
||||
holdCapture: false,
|
||||
})
|
||||
// Each capture waits until the fixture releases it, so a spec can observe the pending state.
|
||||
const held: (() => void)[] = []
|
||||
const tabs = ["Alpha", "Beta"].map((name) => ({
|
||||
id: Browser.TabID.make(`tab_${name === "Alpha" ? "11111111" : "22222222"}-1111-1111-1111-111111111111`),
|
||||
title: name,
|
||||
@@ -44,6 +49,17 @@ export function mountBrowserPane() {
|
||||
{
|
||||
setLayout: (layout) => setStore("layouts", tab.title, layout),
|
||||
command: async () => undefined,
|
||||
capture: async () => {
|
||||
setStore("captures", (count) => count + 1)
|
||||
if (store.holdCapture) await new Promise<void>((resolve) => held.push(resolve))
|
||||
const canvas = new OffscreenCanvas(4, 4)
|
||||
const paint = canvas.getContext("2d")
|
||||
if (paint) {
|
||||
paint.fillStyle = "#3b82f6"
|
||||
paint.fillRect(0, 0, 4, 4)
|
||||
}
|
||||
return canvas.convertToBlob()
|
||||
},
|
||||
close: () => undefined,
|
||||
},
|
||||
]),
|
||||
@@ -118,12 +134,34 @@ export function mountBrowserPane() {
|
||||
Complete navigation
|
||||
</button>
|
||||
<button onClick={() => setStore("visible", (visible) => !visible)}>Toggle Review tab</button>
|
||||
<button onClick={() => setStore("holdCapture", true)}>Hold capture</button>
|
||||
<button onClick={() => held.splice(0).forEach((resolve) => resolve())}>Release capture</button>
|
||||
<button onClick={() => setStore("covered", (covered) => !covered)}>Toggle popover</button>
|
||||
</nav>
|
||||
<div style={{ width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<p>Captures: {store.captures}</p>
|
||||
<div style={{ position: "relative", width: "640px", height: "360px", border: "1px solid #555" }}>
|
||||
<Show when={store.mounted}>
|
||||
<SessionBrowserPane browser={browser} visible={store.visible} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={store.covered}>
|
||||
{/* Floating content portals into <body> like a menu or hover card over the page. */}
|
||||
<Portal mount={document.body}>
|
||||
<div
|
||||
data-popper-positioner
|
||||
data-testid="fixture-popover"
|
||||
style={{
|
||||
position: "fixed",
|
||||
top: "0",
|
||||
left: "0",
|
||||
width: "320px",
|
||||
height: "480px",
|
||||
"z-index": "1001",
|
||||
"pointer-events": "none",
|
||||
}}
|
||||
/>
|
||||
</Portal>
|
||||
</Show>
|
||||
<h2 style={{ "font-size": "18px", margin: "20px 0 12px" }}>Native layout recorder</h2>
|
||||
<p>The desktop boundary keeps each session's page visible until its registration is hidden.</p>
|
||||
<For each={tabs}>
|
||||
|
||||
@@ -58,6 +58,27 @@ story("hides the native view immediately while the pane stays mounted", async ({
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
})
|
||||
|
||||
story("keeps a still of the page under floating content that covers it", async ({ page }, testInfo) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
const still = root.locator("#browser-panel img")
|
||||
await root.getByRole("button", { name: "Hold capture", exact: true }).click()
|
||||
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
// The native page stays up until its still is ready, so the pane never shows blank.
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(still).toHaveCount(0)
|
||||
|
||||
await root.getByRole("button", { name: "Release capture", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "false")
|
||||
await expect(still).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath("covered.png") })
|
||||
|
||||
await root.getByRole("button", { name: "Toggle popover", exact: true }).click()
|
||||
await expect(root.getByTestId("native-Alpha")).toHaveAttribute("data-visible", "true")
|
||||
await expect(still).toHaveCount(0)
|
||||
await expect(root.getByText("Captures: 1", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
story("shows the empty state over a blank native page and restores navigation", async ({ page }) => {
|
||||
const root = page.getByTestId("browser-pane-fixture")
|
||||
await root.getByRole("button", { name: "Blank page", exact: true }).click()
|
||||
|
||||
@@ -86,6 +86,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
sessions: indexedSessions,
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
resolveProject: (session) => home.server.focusedContext()?.projects.forSession(session),
|
||||
}),
|
||||
)
|
||||
const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT))
|
||||
@@ -291,6 +292,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
sessions: () => [result],
|
||||
projectDirectories,
|
||||
projects: home.project.list,
|
||||
resolveProject: ctx.projects.forSession,
|
||||
})[0]
|
||||
},
|
||||
create: home.project.openNewSession,
|
||||
|
||||
@@ -18,6 +18,7 @@ export function buildHomeSessionRecords(input: {
|
||||
sessions: () => SessionInfo[]
|
||||
projectDirectories: () => string[] | undefined
|
||||
projects: () => LocalProject[]
|
||||
resolveProject?: (session: SessionInfo) => LocalProject | undefined
|
||||
}) {
|
||||
const selected = input.projectDirectories()
|
||||
const directories = selected ? new Set(selected.map(pathKey)) : undefined
|
||||
@@ -27,11 +28,12 @@ export function buildHomeSessionRecords(input: {
|
||||
return [...new Map(sessions.map((session) => [session.id, session] as const)).values()]
|
||||
.sort(compareSessionTime)
|
||||
.map((session) => {
|
||||
const project = homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
const project = input.resolveProject?.(session) ??
|
||||
homeProjectForSession(session, input.projects()) ?? {
|
||||
id: session.projectID,
|
||||
worktree: session.location.directory,
|
||||
expanded: false,
|
||||
}
|
||||
return { session, project, projectName: displayName(project) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1439,6 +1439,15 @@ export const dict = {
|
||||
"settings.providers.section.connected": "Connected providers",
|
||||
"settings.providers.connected.empty": "No connected providers",
|
||||
"settings.providers.connected.environmentDescription": "Connected from your environment variables",
|
||||
"settings.providers.account.manage": "Manage {{provider}} accounts",
|
||||
"settings.providers.account.group": "Accounts",
|
||||
"settings.providers.account.add": "Add account",
|
||||
"settings.providers.account.remove": "Remove account…",
|
||||
"settings.providers.account.active": "Active",
|
||||
"settings.providers.account.switched.title": "{{provider}} account switched",
|
||||
"settings.providers.account.switched.description": "Now using {{account}}.",
|
||||
"settings.providers.account.removed.title": "{{account}} removed",
|
||||
"settings.providers.account.removed.description": "{{provider}} will no longer use this account.",
|
||||
"settings.providers.console.available.one": "{{count}} provider available",
|
||||
"settings.providers.console.available.other": "{{count}} providers available",
|
||||
"settings.providers.section.popular": "Popular providers",
|
||||
|
||||
@@ -25,6 +25,8 @@ export type BrowserPaneEvent =
|
||||
export type BrowserPaneRegistration = {
|
||||
setLayout(layout?: BrowserPaneLayout): void
|
||||
command(command: BrowserPaneCommand): Promise<void>
|
||||
/** Captures the shown page, or resolves null when nothing is on screen. */
|
||||
capture(tabID: Browser.TabID): Promise<Blob | null>
|
||||
close(): void
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import { showToast } from "@/shell/notifications/toast"
|
||||
import { formatServerError } from "./errors"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { timelinePreset } from "@opencode/session-ui/timeline/detail"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
import { resolveProjectForSession, resolveSessionDetailsProject } from "@/shell/layout/helpers"
|
||||
|
||||
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
|
||||
name: "Global",
|
||||
@@ -180,6 +182,13 @@ function createServerController(
|
||||
}
|
||||
|
||||
const projectsList = createMemo(() => projects.list().map(enrich))
|
||||
const forSession = (session: SessionInfo) => {
|
||||
const project = resolveProjectForSession(session, projectsList(), sync.data.project)
|
||||
if (!project) return
|
||||
return "expanded" in project ? project : { ...project, expanded: false }
|
||||
}
|
||||
const detailsForSession = (session: SessionInfo) =>
|
||||
resolveSessionDetailsProject(session, projectsList(), sync.data.project)
|
||||
const recentlyClosedList = createMemo(() => {
|
||||
const known = new Set(sync.data.project.map((project) => pathKey(project.worktree)))
|
||||
return projects
|
||||
@@ -200,6 +209,8 @@ function createServerController(
|
||||
projects: {
|
||||
...projects,
|
||||
list: projectsList,
|
||||
forSession,
|
||||
detailsForSession,
|
||||
resolve: enrich,
|
||||
recentlyClosed: recentlyClosedList,
|
||||
},
|
||||
|
||||
@@ -43,6 +43,9 @@ function fixture() {
|
||||
async command(command) {
|
||||
call.commands.push(command)
|
||||
},
|
||||
async capture() {
|
||||
return null
|
||||
},
|
||||
close() {
|
||||
call.closed = true
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { createStore } from "solid-js/store"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import type { createSessionBrowser } from "./model"
|
||||
|
||||
export function SessionBrowserPane(props: { browser: ReturnType<typeof createSessionBrowser>; visible: boolean }) {
|
||||
@@ -30,6 +31,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
// A submitted navigation the browser has not reported yet; keeps the empty state hidden meanwhile.
|
||||
navigating: false,
|
||||
visible: typeof document === "undefined" || document.visibilityState === "visible",
|
||||
// A still of the page shown in the DOM while floating content covers the hidden native view.
|
||||
snapshot: undefined as { tabID: Browser.TabID; url: string } | undefined,
|
||||
})
|
||||
const empty = () => !address() && !state()?.loading && !store.navigating
|
||||
let surface: HTMLDivElement | undefined
|
||||
@@ -37,6 +40,8 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
let frame: number | undefined
|
||||
let layout: string | undefined
|
||||
let until = 0
|
||||
let capturing: Browser.TabID | undefined
|
||||
let release: ReturnType<typeof setTimeout> | undefined
|
||||
const canvas = document.createElement("canvas")
|
||||
canvas.width = canvas.height = 1
|
||||
const paint = canvas.getContext("2d", { willReadFrequently: true })
|
||||
@@ -69,6 +74,45 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const r = el.getBoundingClientRect()
|
||||
return r.width > 0 && r.left < rect.right && r.right > rect.left && r.top < rect.bottom && r.bottom > rect.top
|
||||
})
|
||||
const replaceSnapshot = (next?: { tabID: Browser.TabID; url: string }) => {
|
||||
if (store.snapshot?.url) URL.revokeObjectURL(store.snapshot.url)
|
||||
setStore("snapshot", next)
|
||||
}
|
||||
// Keep the page on screen as a still under the floating content. The native view
|
||||
// stays visible until the still has decoded, so the pane never flashes blank.
|
||||
const freeze = (tabID: Browser.TabID) => {
|
||||
clearTimeout(release)
|
||||
release = undefined
|
||||
if (store.snapshot?.tabID === tabID || capturing === tabID) return
|
||||
capturing = tabID
|
||||
void (registration()?.capture(tabID) ?? Promise.resolve(null))
|
||||
.catch(() => null)
|
||||
.then(async (blob) => {
|
||||
const url = blob ? URL.createObjectURL(blob) : ""
|
||||
if (url) {
|
||||
const image = new Image()
|
||||
image.src = url
|
||||
await image.decode().catch(() => undefined)
|
||||
}
|
||||
if (capturing !== tabID) {
|
||||
if (url) URL.revokeObjectURL(url)
|
||||
return
|
||||
}
|
||||
capturing = undefined
|
||||
// A failed capture still hides the page; the pane shows its background as before.
|
||||
replaceSnapshot({ tabID, url })
|
||||
schedule()
|
||||
})
|
||||
}
|
||||
const thaw = () => {
|
||||
capturing = undefined
|
||||
if (!store.snapshot || release !== undefined) return
|
||||
// Keep the still under the native view until the view has painted again.
|
||||
release = setTimeout(() => {
|
||||
release = undefined
|
||||
replaceSnapshot()
|
||||
}, 150)
|
||||
}
|
||||
const measure = () => {
|
||||
if (!surface) return
|
||||
const tab = state()
|
||||
@@ -84,7 +128,11 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
const bottom = Math.round(rect.bottom * zoom)
|
||||
// The desktop page hides blank and loading documents itself; only hide here
|
||||
// while the pane shows its own empty or failed state over the surface.
|
||||
const visible = props.visible && store.visible && !empty() && !failed() && !dialog.active && !covered(rect)
|
||||
const shown = props.visible && store.visible && !empty() && !failed() && !dialog.active
|
||||
const cover = covered(rect)
|
||||
if (shown && cover) freeze(tab.id)
|
||||
if (!cover) thaw()
|
||||
const visible = shown && !(cover && store.snapshot?.tabID === tab.id)
|
||||
// The cutout exposes the app backdrop outside the rounded Review card,
|
||||
// not the browser surface inside it.
|
||||
const color = getComputedStyle(
|
||||
@@ -186,6 +234,9 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
createEventListener(document, "visibilitychange", () => setStore("visible", document.visibilityState === "visible"))
|
||||
onCleanup(() => {
|
||||
if (frame !== undefined) cancelAnimationFrame(frame)
|
||||
clearTimeout(release)
|
||||
capturing = undefined
|
||||
replaceSnapshot()
|
||||
})
|
||||
|
||||
return (
|
||||
@@ -296,7 +347,17 @@ export function SessionBrowserPane(props: { browser: ReturnType<typeof createSes
|
||||
{error()}
|
||||
</div>
|
||||
</Show>
|
||||
<div ref={surface} class="min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<div ref={surface} class="relative min-h-0 flex-1 bg-v2-background-bg-base flex items-center justify-center">
|
||||
<Show when={store.snapshot?.tabID === state()?.id && !empty() && !failed() && store.snapshot?.url}>
|
||||
{(url) => (
|
||||
<img
|
||||
src={url()}
|
||||
alt=""
|
||||
draggable={false}
|
||||
class="absolute inset-0 size-full pointer-events-none select-none"
|
||||
/>
|
||||
)}
|
||||
</Show>
|
||||
<Show when={(empty() || failed()) && !props.browser.suspended()}>
|
||||
{/* Add the 40px toolbar to the file empty state's 160px bottom padding to align their centers. */}
|
||||
<div
|
||||
|
||||
@@ -14,7 +14,6 @@ import { createStore } from "solid-js/store"
|
||||
import { ResizeHandle } from "@opencode/ui/resize-handle"
|
||||
import { MessageTimeline } from "@/session/timeline/message-timeline"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { ComposerDropzone } from "@/composer/dropzone"
|
||||
import type { SessionModel } from "@/session/model"
|
||||
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
|
||||
@@ -66,7 +65,7 @@ function SessionScreenContent(props: { session: SessionModel; browser: ReturnTyp
|
||||
const server = useServer()
|
||||
const detailsProject = createMemo(() => {
|
||||
const info = session.data.info()
|
||||
return info ? projectForSession(info, server.ctx.sync.data.project) : undefined
|
||||
return info ? server.ctx.projects.detailsForSession(info) : undefined
|
||||
})
|
||||
const isDesktop = session.isDesktop
|
||||
const btw = createSessionBtw(session)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { useServer } from "@/runtime/server/current"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import { displayName, errorMessage, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName, errorMessage, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { tabKey, useTabs } from "@/shell/tabs/tabs"
|
||||
import { useSettingsSurface } from "@/settings/surface"
|
||||
@@ -319,11 +319,8 @@ export function SessionIdentityHeader(props: { sessionID: string; session?: Sess
|
||||
: sessionTitle(props.session?.title ?? (parentID() ? undefined : info()?.title)),
|
||||
)
|
||||
const project = createMemo(() => {
|
||||
if (props.session) return server.ctx.projects.forSession(props.session)
|
||||
const projects = server.ctx.projects.list()
|
||||
if (props.session)
|
||||
return (
|
||||
projectForSession(props.session, projects) ?? projectForSession(props.session, server.ctx.sync.data.project)
|
||||
)
|
||||
const value = directory()
|
||||
if (!value) return undefined
|
||||
const key = pathKey(value)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DiffChanges } from "@opencode/ui/diff-changes"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { createMemo, Show, type JSX } from "solid-js"
|
||||
import { createMemo, Show } from "solid-js"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
@@ -16,7 +16,6 @@ export function SessionSummaryPanel(props: {
|
||||
shown?: boolean
|
||||
mobile?: boolean
|
||||
project: Project
|
||||
avatar?: JSX.Element
|
||||
directory: string
|
||||
local: boolean
|
||||
branch?: string
|
||||
@@ -44,7 +43,7 @@ export function SessionSummaryPanel(props: {
|
||||
return (
|
||||
<div data-component="session-summary-panel" data-mobile={props.mobile || undefined}>
|
||||
<div>
|
||||
<ProjectSummaryCard project={props.project} avatar={props.avatar}>
|
||||
<ProjectSummaryCard project={props.project}>
|
||||
<SessionWorkspaceMenu
|
||||
eligible={props.moveEligible}
|
||||
sessionID={props.sessionID}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { ProjectAvatar } from "@opencode/ui/project-avatar"
|
||||
import { createUniqueId, Show, type ParentProps, type JSX } from "solid-js"
|
||||
import { createUniqueId, Show, type ParentProps } from "solid-js"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { useSettings } from "@/settings/model"
|
||||
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant } from "@/shell/state/layout"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { ProjectIcon } from "@/shell/layout/project-icon"
|
||||
import "./summary.css"
|
||||
|
||||
export function ProjectSummaryCard(
|
||||
props: ParentProps<{
|
||||
project: Pick<Project, "name" | "worktree" | "icon"> & { id?: string }
|
||||
avatar?: JSX.Element
|
||||
}>,
|
||||
) {
|
||||
const settings = useSettings()
|
||||
@@ -26,13 +24,7 @@ export function ProjectSummaryCard(
|
||||
aria-controls={contentID}
|
||||
onClick={() => settings.sessionSummary.setProjectExpanded(!expanded())}
|
||||
>
|
||||
{props.avatar ?? (
|
||||
<ProjectAvatar
|
||||
fallback={displayName(props.project)}
|
||||
src={getProjectAvatarSource(props.project.id, props.project.icon)}
|
||||
variant={getProjectAvatarVariant(props.project.icon?.color)}
|
||||
/>
|
||||
)}
|
||||
<ProjectIcon project={props.project} />
|
||||
<span dir="auto" class="session-summary-label">
|
||||
{displayName(props.project)}
|
||||
</span>
|
||||
|
||||
@@ -31,7 +31,6 @@ import { getReadyMarkdown, preloadMarkdown } from "@opencode/session-ui/markdown
|
||||
import { createTimelineController, type TimelineController, type TimelineSessionSource } from "./controller"
|
||||
import { createTimelineVirtualizer } from "./virtualizer"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/workspaces/paths"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
|
||||
import { useCommand } from "@/shell/commands/command"
|
||||
import { SessionAncestorTrail, SessionProjectMenu, SessionTitleHeader } from "../session-identity-header"
|
||||
@@ -143,14 +142,14 @@ function MessageTimelineView(
|
||||
const session = props.session.data.info()
|
||||
const projects = server.ctx.sync.data.project
|
||||
return session
|
||||
? projectForSession(session, projects)
|
||||
? server.ctx.projects.detailsForSession(session)
|
||||
: projects.find((item) => containsDirectory(item.worktree, sessionDirectory()))
|
||||
})
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const avatarProject = createMemo(() => {
|
||||
const headerProject = createMemo(() => {
|
||||
const session = props.session.data.info()
|
||||
if (!session) return
|
||||
return projectForSession(session, server.ctx.projects.list()) ?? project()
|
||||
return server.ctx.projects.forSession(session)
|
||||
})
|
||||
createEffect(() => {
|
||||
const directory = project()?.worktree
|
||||
@@ -408,7 +407,7 @@ function MessageTimelineView(
|
||||
<div class="flex items-center gap-1 min-w-0 flex-1">
|
||||
<div class="flex items-center gap-0.5 min-w-0 flex-1 w-full">
|
||||
<SessionProjectMenu
|
||||
project={avatarProject()}
|
||||
project={headerProject()}
|
||||
directory={sessionDirectory()}
|
||||
workspace={workspaceSession()}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { IntegrationInfo } from "@opencode/client/promise"
|
||||
import { activeProviderAccount, providerAccounts } from "./accounts"
|
||||
|
||||
const integration = (connections: IntegrationInfo["connections"]): IntegrationInfo => ({
|
||||
id: "openai",
|
||||
name: "OpenAI",
|
||||
methods: [],
|
||||
connections,
|
||||
})
|
||||
|
||||
describe("provider accounts", () => {
|
||||
test("preserves the server's active-first credential order", () => {
|
||||
const value = integration([
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "env", name: "OPENAI_API_KEY" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
|
||||
])
|
||||
|
||||
expect(providerAccounts(value)).toEqual([
|
||||
{ type: "credential", id: "cred_work", label: "Work", method: "key" },
|
||||
{ type: "credential", id: "cred_personal", label: "Personal", method: "oauth" },
|
||||
])
|
||||
expect(activeProviderAccount(value)).toEqual({ type: "credential", id: "cred_work", label: "Work", method: "key" })
|
||||
})
|
||||
|
||||
test("returns no active account for environment-only integrations", () => {
|
||||
const value = integration([{ type: "env", name: "OPENAI_API_KEY" }])
|
||||
|
||||
expect(providerAccounts(value)).toEqual([])
|
||||
expect(activeProviderAccount(value)).toBeUndefined()
|
||||
expect(providerAccounts(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { ConnectionInfo, IntegrationInfo } from "@opencode/client/promise"
|
||||
|
||||
export type ProviderAccount = Extract<ConnectionInfo, { type: "credential" }>
|
||||
|
||||
export function providerAccounts(integration: IntegrationInfo | undefined) {
|
||||
return integration?.connections.filter((connection): connection is ProviderAccount => connection.type === "credential") ?? []
|
||||
}
|
||||
|
||||
export function activeProviderAccount(integration: IntegrationInfo | undefined) {
|
||||
return providerAccounts(integration)[0]
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Button } from "@opencode/ui/button"
|
||||
import { Badge } from "@opencode/ui/badge"
|
||||
import { useDialog } from "@opencode/ui/context/dialog"
|
||||
import { Icon } from "@opencode/ui/icon"
|
||||
import { Menu } from "@opencode/ui/menu"
|
||||
import { OpenCodeLogo } from "@/providers/opencode-logo"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { popularProviders, useProviders } from "@/providers/catalog/providers"
|
||||
@@ -16,6 +17,7 @@ import { CONSOLE_INTEGRATION, CONSOLE_PROVIDERS } from "@/providers/connect/cont
|
||||
import { DialogConnectProvider, useProviderConnectController } from "@/providers/connect/dialog"
|
||||
import { ProviderModelIcon } from "@/providers/models/provider-group"
|
||||
import { SettingsList } from "@/settings/list"
|
||||
import { activeProviderAccount, providerAccounts, type ProviderAccount } from "./accounts"
|
||||
import "@/settings/settings.css"
|
||||
|
||||
type ProviderSource = "env" | "api" | "account" | "config" | "custom"
|
||||
@@ -47,6 +49,7 @@ export const SettingsProviders: Component<{
|
||||
disconnecting: {} as Record<string, "removing" | "removed" | "absent" | undefined>,
|
||||
consoleExpanded: false,
|
||||
connecting: false,
|
||||
credentialID: undefined as string | undefined,
|
||||
})
|
||||
const updateDisconnecting = (ids: string[], status: "removing" | "removed" | "absent" | undefined) =>
|
||||
setState("disconnecting", (current) => ({
|
||||
@@ -190,6 +193,8 @@ export const SettingsProviders: Component<{
|
||||
return currentSource !== "env" && currentSource !== "config"
|
||||
}
|
||||
|
||||
const canManageAccounts = (item: ProviderItem) => providerAccounts(integration(item)).length > 0
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const disconnect = async (item: ProviderItem, name: string) => {
|
||||
@@ -230,6 +235,132 @@ export const SettingsProviders: Component<{
|
||||
})
|
||||
}
|
||||
|
||||
const refreshAccounts = async () => {
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
data.location.integration.invalidate(location)
|
||||
data.location.provider.invalidate(location)
|
||||
data.location.model.invalidate(location)
|
||||
await Promise.all([
|
||||
data.location.integration.sync(location),
|
||||
data.location.provider.sync(location),
|
||||
data.location.model.sync(location),
|
||||
])
|
||||
}
|
||||
|
||||
const accountError = (error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
showToast({ title: language.t("common.requestFailed"), description: message })
|
||||
}
|
||||
|
||||
const activate = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
if (activeProviderAccount(integration(provider))?.id === account.id) return
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.activate({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t("settings.providers.account.switched.title", { provider: providerName }),
|
||||
description: language.t("settings.providers.account.switched.description", { account: account.label }),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
const remove = async (provider: ProviderItem, providerName: string, account: ProviderAccount) => {
|
||||
const final = providerAccounts(integration(provider)).length === 1
|
||||
setState("credentialID", account.id)
|
||||
await serverSdk.api.credential
|
||||
.remove({ credentialID: account.id })
|
||||
.then(refreshAccounts)
|
||||
.then(() =>
|
||||
showToast({
|
||||
variant: "success",
|
||||
icon: "circle-check",
|
||||
title: language.t(
|
||||
final ? "provider.disconnect.toast.disconnected.title" : "settings.providers.account.removed.title",
|
||||
final ? { provider: providerName } : { account: account.label },
|
||||
),
|
||||
description: language.t(
|
||||
final
|
||||
? "provider.disconnect.toast.disconnected.description"
|
||||
: "settings.providers.account.removed.description",
|
||||
{ provider: providerName },
|
||||
),
|
||||
}),
|
||||
)
|
||||
.catch(accountError)
|
||||
.finally(() => setState("credentialID", undefined))
|
||||
}
|
||||
|
||||
function AccountMenu(menuProps: { provider: ProviderItem; name?: string }) {
|
||||
const accounts = () => providerAccounts(integration(menuProps.provider))
|
||||
const active = () => activeProviderAccount(integration(menuProps.provider))
|
||||
const name = () => menuProps.name ?? menuProps.provider.name
|
||||
|
||||
return (
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger
|
||||
as={Button}
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
class="settings-provider-account-trigger"
|
||||
aria-label={language.t("settings.providers.account.manage", { provider: name() })}
|
||||
>
|
||||
<span>{active()?.label}</span>
|
||||
<Icon name="chevron-down" size="small" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content class="settings-provider-account-menu" onEscapeKeyDown={(event) => event.stopPropagation()}>
|
||||
<Menu.Group>
|
||||
<Menu.GroupLabel>{language.t("settings.providers.account.group")}</Menu.GroupLabel>
|
||||
<Menu.RadioGroup
|
||||
class="settings-provider-account-list"
|
||||
value={active()?.id}
|
||||
onChange={(credentialID) => {
|
||||
const account = accounts().find((item) => item.id === credentialID)
|
||||
if (account) void activate(menuProps.provider, name(), account)
|
||||
}}
|
||||
>
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.RadioItem value={account.id} closeOnSelect disabled={state.credentialID !== undefined}>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.RadioItem>
|
||||
)}
|
||||
</For>
|
||||
</Menu.RadioGroup>
|
||||
</Menu.Group>
|
||||
<Menu.Separator />
|
||||
<Menu.Item disabled={state.credentialID !== undefined} onSelect={() => connect(menuProps.provider.id)}>
|
||||
{language.t("settings.providers.account.add")}
|
||||
</Menu.Item>
|
||||
<Menu.Sub placement="left-start">
|
||||
<Menu.SubTrigger disabled={state.credentialID !== undefined || accounts().length === 0}>
|
||||
{language.t("settings.providers.account.remove")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.SubContent class="settings-provider-account-submenu">
|
||||
<For each={accounts()}>
|
||||
{(account) => (
|
||||
<Menu.Item
|
||||
badge={account.id === active()?.id ? language.t("settings.providers.account.active") : undefined}
|
||||
onSelect={() => void remove(menuProps.provider, name(), account)}
|
||||
>
|
||||
<span class="settings-provider-account-label">{account.label}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.SubContent>
|
||||
</Menu.Sub>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div class="settings-tab-header">
|
||||
@@ -268,22 +399,27 @@ export const SettingsProviders: Component<{
|
||||
</div>
|
||||
</div>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
when={canManageAccounts(item)}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
<Show
|
||||
when={canDisconnect(item)}
|
||||
fallback={
|
||||
<span class="settings-provider-env-hint">
|
||||
{language.t("settings.providers.connected.environmentDescription")}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, item.name)}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() =>
|
||||
void disconnect(item, item.name)
|
||||
}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
<AccountMenu provider={item} />
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
@@ -326,13 +462,20 @@ export const SettingsProviders: Component<{
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
|
||||
<Show
|
||||
when={canManageAccounts(item)}
|
||||
fallback={
|
||||
<Button
|
||||
size="normal"
|
||||
variant="ghost-muted"
|
||||
onClick={() => void disconnect(item, language.t("provider.connect.opencode.name"))}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{language.t("common.disconnect")}
|
||||
</Button>
|
||||
<AccountMenu provider={item} name={language.t("provider.connect.opencode.name")} />
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={state.consoleExpanded}>
|
||||
<div class="settings-provider-console-list">
|
||||
|
||||
@@ -887,6 +887,47 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger {
|
||||
min-width: 0;
|
||||
max-width: min(240px, 45%);
|
||||
}
|
||||
|
||||
.settings-provider-account-trigger > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-provider-account-menu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.settings-provider-account-list {
|
||||
max-height: min(240px, calc(var(--kb-popper-content-available-height) - 120px));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-submenu[data-component="menu-v2-content"] {
|
||||
width: min(260px, calc(100vw - 32px));
|
||||
max-height: min(360px, var(--kb-popper-content-available-height));
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-provider-account-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@container settings-panel (max-width: 520px) {
|
||||
.settings-provider-account-trigger {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-providers-view-all {
|
||||
margin-top: 20px;
|
||||
padding: 0;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useLayout, type LocalProject } from "@/shell/state/layout"
|
||||
import { ServerConnection } from "@/runtime/server/registry"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useTabs } from "@/shell/tabs/tabs"
|
||||
import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName, resolveProjectForSession } from "@/shell/layout/helpers"
|
||||
import { createSessionTabs } from "@/session/helpers"
|
||||
import { useSessionLayout } from "@/session/session-layout"
|
||||
import { useServer } from "@/runtime/server/current"
|
||||
@@ -253,9 +253,7 @@ export function createServerSessionEntries(props: {
|
||||
})
|
||||
if (current.signal.aborted) return []
|
||||
const opened = props.opened()
|
||||
const openedByID = new Map(opened.flatMap((project) => (project.id ? [[project.id, project] as const] : [])))
|
||||
const stored = props.stored().map((project) => ({ ...project, expanded: false }))
|
||||
const storedByID = new Map(stored.map((project) => [project.id, project] as const))
|
||||
return Promise.all([
|
||||
props.load(search, current.signal).then(
|
||||
(result) => result.data,
|
||||
@@ -271,8 +269,7 @@ export function createServerSessionEntries(props: {
|
||||
[...new Map([...exact, ...listed].map((session) => [session.id, session] as const)).values()]
|
||||
.filter((session) => !session.time.archived)
|
||||
.map((session) => {
|
||||
const project =
|
||||
projectForSession(session, opened, openedByID) ?? projectForSession(session, stored, storedByID)
|
||||
const project = resolveProjectForSession(session, opened, stored)
|
||||
return {
|
||||
id: `session:${props.server}:${session.id}`,
|
||||
type: "session" as const,
|
||||
|
||||
@@ -50,6 +50,17 @@ export const childSessionOnPath = (sessions: SessionInfo[] | undefined, rootID:
|
||||
export const displayName = (project: { name?: string; worktree: string }) =>
|
||||
project.name || getFilename(project.worktree) || project.worktree
|
||||
|
||||
type ProjectAppearance = {
|
||||
name?: string
|
||||
worktree: string
|
||||
icon?: { color?: string; url?: string; override?: string }
|
||||
}
|
||||
|
||||
function withProjectAppearance<T extends ProjectAppearance>(metadata: T, appearance?: ProjectAppearance) {
|
||||
if (!appearance || appearance === metadata) return metadata
|
||||
return { ...metadata, name: displayName(appearance), icon: appearance.icon }
|
||||
}
|
||||
|
||||
export function toggleHomeProjectSelection(
|
||||
current: HomeProjectSelection | undefined,
|
||||
server: ServerConnection.Key,
|
||||
@@ -97,11 +108,59 @@ export function getProjectAvatarSource(id?: string, icon?: { color?: string; url
|
||||
export function projectForSession<T extends { id?: string; worktree: string; sandboxes?: string[] }>(
|
||||
session: SessionInfo,
|
||||
projects: T[],
|
||||
byID: Map<string, T> = new Map(projects.flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
|
||||
) {
|
||||
const direct = byID.get(session.projectID)
|
||||
if (direct) return direct
|
||||
return projects.find((project) => isProjectDirectory(project, session.location.directory))
|
||||
const matching = projects.filter((project) => project.id === session.projectID)
|
||||
if (matching.length === 1) return matching[0]
|
||||
if (matching.length > 1) {
|
||||
const directory = pathKey(session.location.directory)
|
||||
const exact =
|
||||
matching.find((project) => pathKey(project.worktree) === directory) ??
|
||||
matching.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
|
||||
if (exact) return exact
|
||||
return (
|
||||
matching
|
||||
.filter((project) => isProjectDirectory(project, session.location.directory))
|
||||
.sort((a, b) => b.worktree.length - a.worktree.length)[0] ?? matching.at(-1)
|
||||
)
|
||||
}
|
||||
const directory = pathKey(session.location.directory)
|
||||
const exact =
|
||||
projects.find((project) => pathKey(project.worktree) === directory) ??
|
||||
projects.find((project) => project.sandboxes?.some((sandbox) => pathKey(sandbox) === directory))
|
||||
if (exact) return exact
|
||||
return projects
|
||||
.filter((project) => isProjectDirectory(project, session.location.directory))
|
||||
.sort((a, b) => b.worktree.length - a.worktree.length)[0]
|
||||
}
|
||||
|
||||
export function resolveProjectForSession<
|
||||
T extends { id?: string; worktree: string; sandboxes?: string[] },
|
||||
U extends { id?: string; worktree: string; sandboxes?: string[] },
|
||||
>(session: SessionInfo, opened: T[], stored: U[]) {
|
||||
const current = projectForSession(session, opened)
|
||||
if (current?.id === session.projectID) {
|
||||
const unresolved = opened.find(
|
||||
(project) => !project.id && pathKey(project.worktree) === pathKey(session.location.directory),
|
||||
)
|
||||
if (!unresolved) return current
|
||||
const canonical = projectForSession(session, stored)
|
||||
if (canonical?.id === session.projectID && pathKey(canonical.worktree) === pathKey(unresolved.worktree))
|
||||
return unresolved
|
||||
return current
|
||||
}
|
||||
const synced = projectForSession(session, stored)
|
||||
if (synced?.id !== session.projectID) return current ?? synced
|
||||
if (current && !current.id && pathKey(current.worktree) === pathKey(session.location.directory)) return current
|
||||
return synced
|
||||
}
|
||||
|
||||
export function resolveSessionDetailsProject<
|
||||
T extends ProjectAppearance & { id?: string; sandboxes?: string[] },
|
||||
U extends ProjectAppearance & { id?: string; sandboxes?: string[] },
|
||||
>(session: SessionInfo, opened: T[], stored: U[]) {
|
||||
const metadata = projectForSession(session, stored)
|
||||
if (!metadata) return
|
||||
return withProjectAppearance(metadata, resolveProjectForSession(session, opened, stored))
|
||||
}
|
||||
|
||||
export const errorMessage = (err: unknown, fallback: string) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
|
||||
import { getProjectAvatarVariant, type LocalProject } from "@/shell/state/layout"
|
||||
|
||||
type ProjectIconProps = Omit<ProjectAvatarProps, "fallback" | "src" | "variant"> & {
|
||||
project: LocalProject
|
||||
project: Pick<LocalProject, "id" | "name" | "worktree" | "icon">
|
||||
fallback?: string
|
||||
icon?: LocalProject["icon"]
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Menu } from "@opencode/ui/menu"
|
||||
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { ServerConnection, serverName, useServers } from "@/runtime/server/registry"
|
||||
import { displayName, projectForSession } from "@/shell/layout/helpers"
|
||||
import { displayName } from "@/shell/layout/helpers"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import type { SessionInfo } from "@opencode/client/promise"
|
||||
@@ -57,7 +57,7 @@ export function TabNavItem(props: {
|
||||
const project = createMemo(() => {
|
||||
const session = props.session
|
||||
if (!session) return
|
||||
return projectForSession(session, serverCtx()?.projects.list() ?? [])
|
||||
return serverCtx()?.projects.forSession(session)
|
||||
})
|
||||
const title = createMemo(() => {
|
||||
const session = props.session
|
||||
|
||||
@@ -29,7 +29,6 @@ import { MobileDrawer, MobileDrawerContent, MobileDrawerLabel, MobileDrawerTrigg
|
||||
import { sessionTabTitle } from "./tab-title"
|
||||
import { SessionTabAvatar } from "@/shell/layout/session-tab-avatar"
|
||||
import { SessionProgressIndicatorV2 } from "@opencode/session-ui/v2/session-progress-indicator-v2"
|
||||
import { projectForSession } from "@/shell/layout/helpers"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { updaterAction } from "@/shell/updates/action"
|
||||
import type { UpdaterState } from "@/shell/updates/types"
|
||||
@@ -441,7 +440,7 @@ export function Titlebar(props: {
|
||||
const value = session()
|
||||
if (!tab || !value) return
|
||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
|
||||
return projectForSession(value, conn ? global.ensureServerCtx(conn).projects.list() : [])
|
||||
return conn ? global.ensureServerCtx(conn).projects.forSession(value) : undefined
|
||||
})
|
||||
const currentTitle = () => {
|
||||
const tab = currentTab()
|
||||
|
||||
@@ -235,6 +235,12 @@ export type SessionForkInput = { readonly sessionID: Session.ID; readonly before
|
||||
export type SessionForkOutput = Session.Info
|
||||
export type SessionForkOperation<E = never> = (input: SessionForkInput) => Effect.Effect<SessionForkOutput, E>
|
||||
|
||||
export type SessionCompanionInput = { readonly sessionID: Session.ID }
|
||||
export type SessionCompanionOutput = Session.Info
|
||||
export type SessionCompanionOperation<E = never> = (
|
||||
input: SessionCompanionInput,
|
||||
) => Effect.Effect<SessionCompanionOutput, E>
|
||||
|
||||
export type SessionSwitchAgentInput = { readonly sessionID: Session.ID; readonly agent: Agent.ID }
|
||||
export type SessionSwitchAgentOutput = void
|
||||
export type SessionSwitchAgentOperation<E = never> = (
|
||||
@@ -445,6 +451,7 @@ export type SessionLogOutput =
|
||||
}
|
||||
readonly subpath?: RelativePath | undefined
|
||||
readonly parentID?: Session.ID | undefined
|
||||
readonly kind?: Session.Kind | undefined
|
||||
readonly slug: string
|
||||
readonly title?: string | undefined
|
||||
readonly agent?: Agent.ID | undefined
|
||||
@@ -1418,6 +1425,7 @@ export interface SessionApi<E = never> {
|
||||
readonly get: SessionGetOperation<E>
|
||||
readonly remove: SessionRemoveOperation<E>
|
||||
readonly fork: SessionForkOperation<E>
|
||||
readonly companion: SessionCompanionOperation<E>
|
||||
readonly switchAgent: SessionSwitchAgentOperation<E>
|
||||
readonly switchModel: SessionSwitchModelOperation<E>
|
||||
readonly update: SessionUpdateOperation<E>
|
||||
@@ -1513,6 +1521,30 @@ export interface GenerateApi<E = never> {
|
||||
readonly text: GenerateTextOperation<E>
|
||||
}
|
||||
|
||||
export type VoiceTranscribeInput = { readonly mediaType: string; readonly payload: globalThis.Uint8Array }
|
||||
export type VoiceTranscribeOutput = { readonly text: string }
|
||||
export type VoiceTranscribeOperation<E = never> = (
|
||||
input: VoiceTranscribeInput,
|
||||
) => Effect.Effect<VoiceTranscribeOutput, E>
|
||||
|
||||
export type VoiceSpeechInput = { readonly text: string }
|
||||
export type VoiceSpeechOutput =
|
||||
| {
|
||||
readonly type: "format"
|
||||
readonly format:
|
||||
| { readonly type: "mp3" }
|
||||
| { readonly type: "pcm"; readonly sampleRate: number; readonly channels: number }
|
||||
}
|
||||
| { readonly type: "audio"; readonly data: string }
|
||||
| { readonly type: "done" }
|
||||
| { readonly type: "error"; readonly message: string }
|
||||
export type VoiceSpeechOperation<E = never> = (input: VoiceSpeechInput) => Stream.Stream<VoiceSpeechOutput, E>
|
||||
|
||||
export interface VoiceApi<E = never> {
|
||||
readonly transcribe: VoiceTranscribeOperation<E>
|
||||
readonly speech: VoiceSpeechOperation<E>
|
||||
}
|
||||
|
||||
export type ProviderListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
|
||||
export type ProviderListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Provider.Info> }
|
||||
export type ProviderListOperation<E = never> = (input?: ProviderListInput) => Effect.Effect<ProviderListOutput, E>
|
||||
@@ -2342,6 +2374,7 @@ export interface AppApi<E = never> {
|
||||
readonly message: MessageApi<E>
|
||||
readonly model: ModelApi<E>
|
||||
readonly generate: GenerateApi<E>
|
||||
readonly voice: VoiceApi<E>
|
||||
readonly provider: ProviderApi<E>
|
||||
readonly integration: IntegrationApi<E>
|
||||
readonly mcp: McpApi<E>
|
||||
|
||||
@@ -39,6 +39,8 @@ import type {
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
SessionForkOutput,
|
||||
SessionCompanionInput,
|
||||
SessionCompanionOutput,
|
||||
SessionSwitchAgentInput,
|
||||
SessionSwitchAgentOutput,
|
||||
SessionSwitchModelInput,
|
||||
@@ -115,6 +117,10 @@ import type {
|
||||
ModelDefaultOutput,
|
||||
GenerateTextInput,
|
||||
GenerateTextOutput,
|
||||
VoiceTranscribeInput,
|
||||
VoiceTranscribeOutput,
|
||||
VoiceSpeechInput,
|
||||
VoiceSpeechOutput,
|
||||
ProviderListInput,
|
||||
ProviderListOutput,
|
||||
ProviderGetInput,
|
||||
@@ -450,6 +456,14 @@ const EndpointSessionFork = (raw: RawClient["server.session"]) => (input: Sessio
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionCompanion = (raw: RawClient["server.session"]) => (input: SessionCompanionInput) =>
|
||||
preserveEffect<SessionCompanionOutput>()(
|
||||
raw["session.companion"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionSwitchAgent = (raw: RawClient["server.session"]) => (input: SessionSwitchAgentInput) =>
|
||||
preserveEffect<SessionSwitchAgentOutput>()(
|
||||
raw["session.switchAgent"]({ params: { sessionID: input["sessionID"] }, payload: { agent: input["agent"] } }).pipe(
|
||||
@@ -762,6 +776,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
get: EndpointSessionGet(raw),
|
||||
remove: EndpointSessionRemove(raw),
|
||||
fork: EndpointSessionFork(raw),
|
||||
companion: EndpointSessionCompanion(raw),
|
||||
switchAgent: EndpointSessionSwitchAgent(raw),
|
||||
switchModel: EndpointSessionSwitchModel(raw),
|
||||
update: EndpointSessionUpdate(raw),
|
||||
@@ -843,6 +858,33 @@ const EndpointGenerateText = (raw: RawClient["server.generate"]) => (input: Gene
|
||||
|
||||
const adaptGroupGenerate = (raw: RawClient["server.generate"]) => ({ text: EndpointGenerateText(raw) })
|
||||
|
||||
type VoiceTranscribeRequest = Parameters<RawClient["server.voice"]["voice.transcribe"]>[0]
|
||||
const EndpointVoiceTranscribe = (raw: RawClient["server.voice"]) => (input: VoiceTranscribeInput) =>
|
||||
preserveEffect<VoiceTranscribeOutput>()(
|
||||
raw["voice.transcribe"]({
|
||||
query: { mediaType: input["mediaType"] },
|
||||
payload: input["payload"],
|
||||
} as VoiceTranscribeRequest).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointVoiceSpeech = (raw: RawClient["server.voice"]) => (input: VoiceSpeechInput) =>
|
||||
preserveStream<VoiceSpeechOutput>()(
|
||||
Stream.unwrap(
|
||||
raw["voice.speech"]({ payload: { text: input["text"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((stream) => stream.pipe(Stream.mapError(mapClientError))),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const adaptGroupVoice = (raw: RawClient["server.voice"]) => ({
|
||||
transcribe: EndpointVoiceTranscribe(raw),
|
||||
speech: EndpointVoiceSpeech(raw),
|
||||
})
|
||||
|
||||
const EndpointProviderList = (raw: RawClient["server.provider"]) => (input?: ProviderListInput) =>
|
||||
preserveEffect<ProviderListOutput>()(
|
||||
raw["provider.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
@@ -1550,6 +1592,7 @@ const adaptClient = (raw: RawClient) => ({
|
||||
message: adaptGroupMessage(raw["server.message"]),
|
||||
model: adaptGroupModel(raw["server.model"]),
|
||||
generate: adaptGroupGenerate(raw["server.generate"]),
|
||||
voice: adaptGroupVoice(raw["server.voice"]),
|
||||
provider: adaptGroupProvider(raw["server.provider"]),
|
||||
integration: adaptGroupIntegration(raw["server.integration"]),
|
||||
mcp: adaptGroupMcp(raw["server.mcp"]),
|
||||
|
||||
@@ -33,6 +33,8 @@ import type {
|
||||
SessionRemoveOutput,
|
||||
SessionForkInput,
|
||||
SessionForkOutput,
|
||||
SessionCompanionInput,
|
||||
SessionCompanionOutput,
|
||||
SessionSwitchAgentInput,
|
||||
SessionSwitchAgentOutput,
|
||||
SessionSwitchModelInput,
|
||||
@@ -109,6 +111,10 @@ import type {
|
||||
ModelDefaultOutput,
|
||||
GenerateTextInput,
|
||||
GenerateTextOutput,
|
||||
VoiceTranscribeInput,
|
||||
VoiceTranscribeOutput,
|
||||
VoiceSpeechInput,
|
||||
VoiceSpeechOutput,
|
||||
ProviderListInput,
|
||||
ProviderListOutput,
|
||||
ProviderGetInput,
|
||||
@@ -656,6 +662,17 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
companion: (input: SessionCompanionInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionCompanionOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/companion`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
switchAgent: (input: SessionSwitchAgentInput, requestOptions?: RequestOptions) =>
|
||||
request<SessionSwitchAgentOutput>(
|
||||
{
|
||||
@@ -1141,6 +1158,34 @@ export function make(options: ClientOptions) {
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
},
|
||||
voice: {
|
||||
transcribe: (input: VoiceTranscribeInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: VoiceTranscribeOutput }>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/voice/transcribe`,
|
||||
query: { mediaType: input["mediaType"] },
|
||||
body: input["payload"],
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 503],
|
||||
empty: false,
|
||||
binaryBody: true,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
speech: (input: VoiceSpeechInput, requestOptions?: RequestOptions): AsyncIterable<VoiceSpeechOutput> =>
|
||||
sse<VoiceSpeechOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/experimental/voice/speech`,
|
||||
body: { text: input["text"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 503],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
provider: {
|
||||
list: (input?: ProviderListInput, requestOptions?: RequestOptions) =>
|
||||
request<ProviderListOutput>(
|
||||
|
||||
@@ -30,6 +30,8 @@ export type PluginFeatures = { server?: true; tui?: true; rpc?: true }
|
||||
|
||||
export type PluginState = { status: "active" } | { status: "failed"; error: string; ref?: string }
|
||||
|
||||
export type SessionKind = "companion"
|
||||
|
||||
export type SessionForkBoundary = { type: "before"; messageID: string } | { type: "through"; messageID: string }
|
||||
|
||||
export type MoneyUSD = number
|
||||
@@ -231,6 +233,10 @@ export type MoneyUSDPerMillionTokens = number
|
||||
|
||||
export type GenerateTextResponse = { data: { text: string } }
|
||||
|
||||
export type VoiceTranscribeResponse = { data: { text: string } }
|
||||
|
||||
export type VoiceSpeechFormat = { type: "mp3" } | { type: "pcm"; sampleRate: number; channels: number }
|
||||
|
||||
export type IntegrationCommandMethod = { id: string; type: "command"; label: string; command: Array<string> }
|
||||
|
||||
export type IntegrationEnvMethod = { type: "env"; names: Array<string> }
|
||||
@@ -1469,6 +1475,12 @@ export type ModelCost = {
|
||||
cache: { read: MoneyUSDPerMillionTokens; write: MoneyUSDPerMillionTokens }
|
||||
}
|
||||
|
||||
export type VoiceSpeechEvent =
|
||||
| { type: "format"; format: VoiceSpeechFormat }
|
||||
| { type: "audio"; data: string }
|
||||
| { type: "done" }
|
||||
| { type: "error"; message: string }
|
||||
|
||||
export type ConnectionInfo = ConnectionCredentialInfo | ConnectionEnvInfo
|
||||
|
||||
export type McpServer = {
|
||||
@@ -1957,6 +1969,7 @@ export type SessionPermissions = {
|
||||
export type SessionInfo = {
|
||||
id: string
|
||||
parentID?: string
|
||||
kind?: SessionKind
|
||||
fork?: { sessionID: string; boundary: SessionForkBoundary }
|
||||
projectID: string
|
||||
agent?: string
|
||||
@@ -1986,6 +1999,7 @@ export type SessionCreated = {
|
||||
location: LocationRef
|
||||
subpath?: string
|
||||
parentID?: string
|
||||
kind?: SessionKind
|
||||
slug: string
|
||||
title?: string
|
||||
agent?: string
|
||||
@@ -2052,6 +2066,16 @@ export type ConfigEntry =
|
||||
media?: {
|
||||
image?: { auto_resize?: boolean; max_width?: number; max_height?: number; max_base64_bytes?: number }
|
||||
}
|
||||
voice?: {
|
||||
transcription?: { model: string | { providerID: string; model: string; variant?: string }; language?: string }
|
||||
speech?: {
|
||||
model: string | { providerID: string; model: string; variant?: string }
|
||||
voice?: string
|
||||
language?: string
|
||||
speed?: number
|
||||
instructions?: string
|
||||
}
|
||||
}
|
||||
tool_output?: { max_lines?: number; max_bytes?: number }
|
||||
mcp?: {
|
||||
timeout?: { startup?: number; catalog?: number; execution?: number }
|
||||
@@ -2975,6 +2999,7 @@ export type SessionImportInput = {
|
||||
readonly info: {
|
||||
readonly id: string
|
||||
readonly parentID?: string
|
||||
readonly kind?: "companion"
|
||||
readonly fork?: {
|
||||
readonly sessionID: string
|
||||
readonly boundary:
|
||||
@@ -3292,6 +3317,7 @@ export type SessionImportInput = {
|
||||
readonly info: {
|
||||
readonly id: string
|
||||
readonly parentID?: string
|
||||
readonly kind?: "companion"
|
||||
readonly fork?: {
|
||||
readonly sessionID: string
|
||||
readonly boundary:
|
||||
@@ -3609,6 +3635,7 @@ export type SessionImportInput = {
|
||||
readonly info: {
|
||||
readonly id: string
|
||||
readonly parentID?: string
|
||||
readonly kind?: "companion"
|
||||
readonly fork?: {
|
||||
readonly sessionID: string
|
||||
readonly boundary:
|
||||
@@ -3950,6 +3977,10 @@ export type SessionForkInput = {
|
||||
|
||||
export type SessionForkOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionCompanionInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionCompanionOutput = { data: SessionInfo }["data"]
|
||||
|
||||
export type SessionSwitchAgentInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly agent: { readonly agent: string }["agent"]
|
||||
@@ -5483,6 +5514,17 @@ export type GenerateTextInput = {
|
||||
|
||||
export type GenerateTextOutput = GenerateTextResponse["data"]
|
||||
|
||||
export type VoiceTranscribeInput = {
|
||||
readonly mediaType: { readonly mediaType: string }["mediaType"]
|
||||
readonly payload: globalThis.Uint8Array
|
||||
}
|
||||
|
||||
export type VoiceTranscribeOutput = VoiceTranscribeResponse["data"]
|
||||
|
||||
export type VoiceSpeechInput = { readonly text: { readonly text: string }["text"] }
|
||||
|
||||
export type VoiceSpeechOutput = VoiceSpeechEvent
|
||||
|
||||
export type ProviderListInput = {
|
||||
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
"message",
|
||||
"model",
|
||||
"generate",
|
||||
"voice",
|
||||
"provider",
|
||||
"integration",
|
||||
"mcp",
|
||||
|
||||
@@ -85,8 +85,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Array binding and assignment destructuring from strings, Maps, Sets, URLSearchParams, custom synchronous
|
||||
iterators, and synchronous generators, including stepwise elisions/rest and `IteratorClose` on early completion
|
||||
or binding/default failure.
|
||||
- [ ] Object destructuring from primitives follows ToObject (`const { length } = "abc"`, `const {} = 1`); non-object
|
||||
sources are rejected.
|
||||
- [x] Object destructuring from primitives follows ToObject: `const { length } = "abc"` is `3`, `const { toFixed } = 1`
|
||||
finds the built-in, `const {} = 1` is a no-op, and a rest element copies a string's indexes (`{ 1: "y", 2: "z" }`).
|
||||
Only `null` and `undefined` sources throw (`Cannot destructure null as it is null.`).
|
||||
- [x] Destructuring reads through the prototype chain like member access: `const { constructor } = error` and
|
||||
`const { slice } = values` find the inherited built-in.
|
||||
- [x] Any assignment target as a `for...in` head, like `for...of`: `for (x.y in obj)`, `for (a[i++] in obj)`, and
|
||||
@@ -130,7 +131,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Built-in method references as callbacks, such as `values.map(Math.abs)`, `records.map(JSON.stringify)`,
|
||||
`items.forEach(console.log)`, and `Promise.resolve(-1).then(Math.abs)`. Extra callback arguments a built-in
|
||||
does not consume are ignored, like JS, and consumed arguments coerce, like JS (`"3.7".replace(/\d\.\d/,
|
||||
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
Math.floor)` is `"3"`). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
|
||||
because `includes` is called without a string `this`.
|
||||
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
|
||||
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
|
||||
@@ -154,8 +155,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
array, an array-like object (its `length` clamped and capped like `Array.from`), or `null`/`undefined`. A
|
||||
bound function is named `bound f`, has its remaining `length`, and is not constructible.
|
||||
- [x] `JSON.parse` revivers and `JSON.stringify` function replacers see the holder object as `this`.
|
||||
- [ ] The optional `thisArg` of iteration methods (`map`, `forEach`, `Map.prototype.forEach`, `Array.from`, …) is
|
||||
accepted but not yet passed as `this`; callbacks run with `this` undefined.
|
||||
- [x] The optional `thisArg` of the Array, Uint8Array, and `Array.from` callback methods and of Map, Set,
|
||||
URLSearchParams, and Headers `forEach` is the callback's `this`: `[1, 2].forEach(function () { this.n++ }, c)`
|
||||
increments `c.n` twice. Arrows ignore it, as in JS; `reduce`/`reduceRight` take an initial value instead.
|
||||
- [ ] User-defined constructor calls.
|
||||
- [ ] Classes and private fields.
|
||||
- [x] Functions are objects: they hold own properties (`fn.count = 1`), enumerate them, and expose read-only `name`
|
||||
@@ -224,8 +226,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Coercion helpers and template interpolation accept functions and namespaces: `String(fn)` and `${fn}` give
|
||||
`"[object Function]"` rather than the source text, `isNaN(fn)` is `true`.
|
||||
- [x] `==` and `!=` follow IsLooselyEqual: objects (including functions and tool references) compare by identity, a
|
||||
nullish operand never coerces the other side, and a data object facing a primitive coerces through its built-in
|
||||
primitive form (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
|
||||
nullish operand never coerces the other side, and a data object facing a primitive converts through its own
|
||||
`valueOf`/`toString` (default hint) (`fn == null` is `false`, `fn == fn` is `true`, `[1] == 1` and `[1, 2] == "1,2"` are `true`).
|
||||
`switch` matches cases with `===`, so `switch (fn) { case fn: }` selects, and `Object.is` compares any two
|
||||
values. Operators inspect only their direct operands, so `rows == null` on a large array costs the same as
|
||||
`rows === null`, and an object merely holding a function inside (`[fn] + ""`, `-[fn]`) coerces like any other
|
||||
@@ -233,13 +235,26 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [ ] Coercing a function, promise, generator, or tool reference itself: `fn + ""`, `-fn`, `fn++`, and `fn == 1`
|
||||
throw `TypeError: Binary operators require data values.` (or the unary/update form) where JavaScript would use
|
||||
the source text or `NaN`.
|
||||
- [ ] ToPrimitive on program objects: operators, `Number`/`String`, `Error(message)`, `parseInt` radix, multi-argument
|
||||
`Date` construction and `Date.UTC`, and numeric built-in arguments (`Math.max`, `at`, `indexOf` start) should call
|
||||
the object's own `valueOf`/`toString` in spec order and surface their throws. Today they use the built-in form
|
||||
(`NaN`, `"[object Object]"`) and ignore own methods. Date setters and one-argument `Date` construction already
|
||||
follow ToPrimitive.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]`, `x[true]`, and objects (via their built-in string form) become
|
||||
string keys.
|
||||
- [x] ToPrimitive on program objects: `+ - * / % **`, the relational and bitwise operators, unary `+ - ~`, `++`/`--`,
|
||||
compound assignment, `${x}`, `Number`/`String`/`isNaN`/`isFinite`, `parseInt`/`parseFloat` (text and radix),
|
||||
`Math.*` arguments, `Error(message)`, and `Array.prototype.join`/`toString` elements call the object's own
|
||||
`valueOf`/`toString` in spec order (both operands left then right, `+` with the default hint) and surface their
|
||||
throws: `{ valueOf() { return 7 } } * 2` is `14`, `` `${{ toString() { return "x" } }}` `` is `"x"`, and
|
||||
`[1, 2]` with `arr.toString = () => "x"` makes `arr + ""` `"x"`. Dates keep their `Symbol.toPrimitive`
|
||||
behavior (`date + 1` concatenates, `date - date` subtracts).
|
||||
- [x] String and Number method arguments convert through ToPrimitive in spec order, receiver first: search strings,
|
||||
separators, fills, and replacements with the string hint, indexes, counts, digits, and radixes with the number
|
||||
hint (`"abc".indexOf({ toString() { return "b" } })` is `1`, `(255).toString({ valueOf() { return 16 } })` is
|
||||
`"ff"`, `String.prototype.trim.call({ toString() { return " a " } })` is `"a"`). Only consumed positions
|
||||
convert; a RegExp pattern is used as is, and `includes`/`startsWith`/`endsWith` reject one before converting.
|
||||
- [ ] ToPrimitive elsewhere: `Error.prototype.toString` on an object `message` and numeric arguments of the Array and
|
||||
Uint8Array methods (`at`, `indexOf` start, `slice`) still use the built-in form (`NaN`, `"[object Object]"`) and
|
||||
ignore own methods.
|
||||
- [x] Property keys follow ToPropertyKey: `x[null]` and `x[true]` become string keys, and a data object key
|
||||
converts through its own `toString`/`valueOf` (string hint) exactly once per access, in reads, writes,
|
||||
compound assignment, `++`, `delete`, `in`, object literals, and destructuring:
|
||||
`o[{ toString() { return "id" } }] += 1` updates `o.id`. A nullish base throws before the key converts, as
|
||||
in JS. Opaque values (functions, promises, tool references) keep their built-in string form.
|
||||
|
||||
## Promises and tools
|
||||
|
||||
@@ -300,8 +315,9 @@ reject }` object.
|
||||
- [x] `Object()` and `new Object()` return `{}` for nullish arguments and pass objects through unchanged;
|
||||
primitive wrapper objects (`Object(1)`) are rejected explicitly.
|
||||
- [x] Computed property names and object spread. Any value works as a key (ToPropertyKey): strings, numbers, and the
|
||||
two confined symbols as themselves, everything else as its string form (`o[null]` is `o["null"]`, `o[{}]` is
|
||||
`o["[object Object]"]`), in reads, writes, literals, `in`, and destructuring.
|
||||
two confined symbols as themselves, data objects through their own `toString` (`o[[1, 2]]` is `o["1,2"]`), and
|
||||
everything else as its string form (`o[null]` is `o["null"]`), in reads, writes, literals, `in`, and
|
||||
destructuring.
|
||||
- [x] `Object.keys`, `Object.values`, `Object.entries`, `Object.hasOwn`, `Object.assign`, and `Object.fromEntries`, with
|
||||
synchronous iterator support for `fromEntries`. Sources follow ToObject: strings enumerate by index, other
|
||||
primitives and wrappers contribute nothing, and `null`/`undefined` throw. `Object.assign` accepts array
|
||||
@@ -362,7 +378,6 @@ reject }` object.
|
||||
shares one prototype, where JavaScript gives each collection its own; `Object.getPrototypeOf` shows the
|
||||
difference.
|
||||
- [x] `length`, numeric indexing, index assignment, spread, and `for...of`.
|
||||
- [x] The `thisArg` argument of `Array.from` is accepted and ignored, like JS arrows.
|
||||
- [x] `Array.prototype.toSpliced`.
|
||||
- [x] Canonical array/string index parsing: keys such as `"01"` are ordinary properties rather than aliases of index
|
||||
`1`.
|
||||
@@ -375,8 +390,9 @@ reject }` object.
|
||||
`flat(1.9)`, `with(1.5, v)`, `Math.max("3", "2")`, `parseInt("11", "2")`, `(1.5).toFixed("2")`,
|
||||
`String.fromCharCode("65")`, and the Uint8Array equivalents. `join(sep)` and `JSON.parse(text)` apply ToString
|
||||
(`join(null)` is `"1null2"`, `JSON.parse(123)` is `123`). `Array.from({ length: "2" })` applies ToLength; a
|
||||
promise source still throws with an `await` hint rather than JS's silent `[]`. A program object's own
|
||||
`valueOf`/`toString` is not consulted yet (see ToPrimitive above).
|
||||
promise source still throws with an `await` hint rather than JS's silent `[]`. `join`, `Math.*`, `parseInt`,
|
||||
and the String and Number methods consult a program object's own `valueOf`/`toString`; the array methods do not
|
||||
yet (see ToPrimitive above).
|
||||
|
||||
## Strings
|
||||
|
||||
@@ -393,14 +409,15 @@ reject }` object.
|
||||
- [x] Static `String.fromCharCode` and `String.fromCodePoint`.
|
||||
- [x] Native argument coercion for supported String methods; for example, `includes(1)` and `slice("1")` coerce like
|
||||
native JS, `split(undefined)` returns the whole string, and `includes`/`startsWith`/`endsWith` reject regular
|
||||
expressions with a native-style `TypeError`. Opaque runtime references still reject as data errors, and
|
||||
`repeat` still requires a finite non-negative count.
|
||||
expressions with a native-style `TypeError`. Data objects convert through their own `toString`/`valueOf` (see
|
||||
ToPrimitive above). Opaque runtime references still reject as data errors, and `repeat` still requires a finite
|
||||
non-negative count.
|
||||
- [x] Native no-argument parity for `match()`, `matchAll()`, and `search()`; all behave as an empty pattern.
|
||||
- [x] `String.raw`, on a template object or any `{ raw }` object; raw strings and substitutions coerce through their own
|
||||
`toString`.
|
||||
- [x] `match`, `matchAll`, `search`, and `split` read any non-RegExp argument as a pattern string, as `new RegExp(arg)`
|
||||
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, and `undefined` is the empty pattern.
|
||||
Objects use their built-in string form until ToPrimitive lands.
|
||||
would: `"a1b".match(1)` matches `/1/`, `search(null)` looks for `"null"`, `undefined` is the empty pattern, and
|
||||
an object supplies its own `toString`.
|
||||
|
||||
## Numbers and Math
|
||||
|
||||
@@ -454,15 +471,15 @@ reject }` object.
|
||||
- [x] `getTimezoneOffset`, arithmetic, relational comparison, and `instanceof Date`.
|
||||
- [x] Date values serialize to ISO strings; invalid dates serialize to `null`.
|
||||
- [x] Local and UTC Date setters, including native argument coercion, mutation, rollover, invalid-Date recovery, and
|
||||
`TimeClip` behavior.
|
||||
`TimeClip` behavior. On an invalid Date every setter but `setTime` and `set(UTC)FullYear` answers `NaN` without
|
||||
writing, so a time set inside an argument's `valueOf` survives.
|
||||
- [x] `Date.prototype.toUTCString` and its `toGMTString` alias.
|
||||
- [x] `toDateString` and `toTimeString` in the host's local timezone.
|
||||
- [x] `toLocaleString`, `toLocaleDateString`, and `toLocaleTimeString` always format as `en-US` in UTC
|
||||
(`"1/1/1970, 12:00:00 AM"`) so output does not depend on the host.
|
||||
- [x] Native one-argument Date coercion for supported values, including booleans, null, arrays, and plain objects.
|
||||
- [x] Date setters and one-argument construction coerce object arguments through their own `valueOf`/`toString` and
|
||||
surface their throws.
|
||||
- [ ] Multi-argument construction and `Date.UTC` coerce object arguments the same way (see ToPrimitive above).
|
||||
- [x] Date setters, construction, and `Date.UTC` coerce object arguments through their own `valueOf`/`toString` in
|
||||
argument order and surface their throws; only the first seven components are converted.
|
||||
- [x] Native Date loose-equality and default primitive-coercion semantics, using CodeMode's deterministic ISO string
|
||||
representation for the string primitive.
|
||||
- [x] Native `RangeError` branding for invalid `toISOString()` calls.
|
||||
@@ -509,6 +526,11 @@ reject }` object.
|
||||
- [x] Map and Set values serialize to `{}` at host/JSON boundaries.
|
||||
- [x] Set composition and relation methods: `union`, `intersection`, `difference`, `symmetricDifference`, `isSubsetOf`,
|
||||
`isSupersetOf`, and `isDisjointFrom`, including supported Set-like operands.
|
||||
- [x] `WeakMap` (`get`, `set`, `has`, `delete`, `getOrInsert`, `getOrInsertComputed`) and `WeakSet` (`add`, `has`,
|
||||
`delete`), constructed from iterables. Keys must be program objects: a primitive or tool reference throws
|
||||
`Invalid value used as weak map key`, while `has`/`delete`/`get` with one answer `false`/`undefined`. Entries are
|
||||
held by a host weak collection, so nothing is retained past the key's own lifetime. As in JS they have no `size`,
|
||||
iteration, or `clear`, `structuredClone` rejects them, and they serialize to `{}` at host boundaries.
|
||||
|
||||
## URL and URI helpers
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type Cursor,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { typeofValue } from "./references.js"
|
||||
import { isOpaque, typeofValue } from "./references.js"
|
||||
|
||||
/** IteratorClose: a consumer failure closes the iterator and wins over any close failure, except that a generator's
|
||||
* return() is a return completion, so a failing close wins over it, as after `break`. */
|
||||
@@ -31,16 +31,15 @@ export const preserveConsumerError = <A, R>(
|
||||
})
|
||||
})
|
||||
|
||||
export type Hint = "number" | "string" | "default"
|
||||
|
||||
/**
|
||||
* ToPrimitive: calls `valueOf`/`toString` in hint order and returns the first primitive result. Dates treat the
|
||||
* default hint as "string", like their `Symbol.toPrimitive`.
|
||||
* default hint as "string", like their `Symbol.toPrimitive`. Opaque values (functions, promises, generators, tool
|
||||
* references) pass through unchanged so callers reject or describe them in their built-in form.
|
||||
*/
|
||||
export const toPrimitive = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
value: Value,
|
||||
hint: "number" | "string" | "default",
|
||||
): Effect.Effect<Value, unknown, R> => {
|
||||
if (!(value instanceof Obj)) return Effect.succeed(value)
|
||||
export const toPrimitive = <R>(ctx: Interpreter<R>, value: Value, hint: Hint): Effect.Effect<Value, unknown, R> => {
|
||||
if (!(value instanceof Obj) || isOpaque(value)) return Effect.succeed(value)
|
||||
const asString = hint === "string" || (hint === "default" && value instanceof DateObj)
|
||||
const order = asString ? ["toString", "valueOf"] : ["valueOf", "toString"]
|
||||
return Effect.gen(function* () {
|
||||
@@ -67,6 +66,28 @@ export const toPrimitiveString = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
export const toPrimitiveNumber = <R>(ctx: Interpreter<R>, value: Value) =>
|
||||
Effect.map(toPrimitive(ctx, value, "number"), coerceToNumber)
|
||||
|
||||
/**
|
||||
* Runs a native body on its arguments after ToPrimitive, in order, with one hint for all positions or one per
|
||||
* position. Primitive arguments skip the Effect entirely.
|
||||
*/
|
||||
export const withPrimitives = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
hints: Hint | ReadonlyArray<Hint>,
|
||||
values: Array<Value>,
|
||||
body: (primitives: Array<Value>) => Value | Effect.Effect<Value, unknown, R>,
|
||||
): Value | Effect.Effect<Value, unknown, R> => {
|
||||
if (!values.some((value) => value instanceof Obj)) return body(values)
|
||||
return Effect.flatMap(
|
||||
Effect.forEach(values, (value, index) =>
|
||||
toPrimitive(ctx, value, typeof hints === "string" ? hints : hints[index]!),
|
||||
),
|
||||
(primitives) => {
|
||||
const result = body(primitives)
|
||||
return Effect.isEffect(result) ? result : Effect.succeed(result)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// The single acceptance list for callbacks: collections, sort, string replacers,
|
||||
// Array.from mappers, and promise reactions all admit exactly these callables.
|
||||
// Admission means dispatchable, not necessarily invocable: new-requiring
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { toPrimitiveString } from "./callback.js"
|
||||
import { formatValue } from "../stdlib/console.js"
|
||||
|
||||
export const normalizeError = (error: unknown): Diagnostic => {
|
||||
@@ -139,14 +140,13 @@ const constructAggregateErrorValue = <R>(
|
||||
proto: Obj,
|
||||
): Effect.Effect<ErrorObj, unknown, R> =>
|
||||
Effect.gen(function* () {
|
||||
const message = args[1] === undefined ? "" : yield* toPrimitiveString(ctx, args[1])
|
||||
const cursor = yield* ctx.iterate(args[0])
|
||||
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
|
||||
const errors: Array<Value> = []
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) {
|
||||
return createAggregateErrorValue(ctx, errors, args[1] === undefined ? "" : coerceToString(args[1]), proto)
|
||||
}
|
||||
if (step.done) return createAggregateErrorValue(ctx, errors, message, proto)
|
||||
errors.push(step.value)
|
||||
}
|
||||
})
|
||||
@@ -160,7 +160,9 @@ export const errorGlobal = <R>(type: ErrorType, ctx: Interpreter<R>) => {
|
||||
const created =
|
||||
type === "AggregateError"
|
||||
? constructAggregateErrorValue(ctx, args, proto)
|
||||
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
|
||||
: Effect.map(args[0] === undefined ? Effect.undefined : toPrimitiveString(ctx, args[0]), (message) =>
|
||||
createErrorValue(proto, message),
|
||||
)
|
||||
// ES2022 `new Error(message, { cause })`: installed only when the options object has the property at all.
|
||||
const options = args[type === "AggregateError" ? 2 : 1]
|
||||
if (!(options instanceof Obj) || !has(options, "cause")) return created
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { Arr, Callable, coerceToInteger, coerceToString, get, Obj, type Value } from "./objects.js"
|
||||
import { arrayGlobal } from "../stdlib/array.js"
|
||||
import { textDecoderGlobal, textEncoderGlobal, uint8ArrayGlobal } from "../stdlib/bytes.js"
|
||||
import { mapGlobal, setGlobal } from "../stdlib/collections.js"
|
||||
import { mapGlobal, setGlobal, weakMapGlobal, weakSetGlobal } from "../stdlib/collections.js"
|
||||
import { consoleGlobal } from "../stdlib/console.js"
|
||||
import { dateGlobal } from "../stdlib/date.js"
|
||||
import { jsonGlobal } from "../stdlib/json.js"
|
||||
@@ -111,6 +111,8 @@ const table: Record<string, Factory> = {
|
||||
RegExp: (ctx) => regexpGlobal(ctx),
|
||||
Map: (ctx) => mapGlobal(ctx),
|
||||
Set: (ctx) => setGlobal(ctx),
|
||||
WeakMap: (ctx) => weakMapGlobal(ctx),
|
||||
WeakSet: (ctx) => weakSetGlobal(ctx),
|
||||
URL: (ctx) => urlGlobal(ctx),
|
||||
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
|
||||
Headers: (ctx) => headersGlobal(ctx),
|
||||
|
||||
@@ -95,7 +95,7 @@ import {
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "./objects.js"
|
||||
import { preserveConsumerError } from "./callback.js"
|
||||
import { type Hint, preserveConsumerError, toPrimitive } from "./callback.js"
|
||||
import { Pending, resolvePromise, resolvePromiseValue } from "./promises.js"
|
||||
import { describeValue, isOpaque, rejectCircularInsertion, typeofValue } from "./references.js"
|
||||
import { ScopeStack } from "./scope.js"
|
||||
@@ -103,6 +103,30 @@ import { constructRegExp } from "../stdlib/regexp.js"
|
||||
import { enumerableSource } from "../stdlib/object.js"
|
||||
import { compoundOperators } from "../stdlib/value.js"
|
||||
|
||||
/** The binary operators that convert object operands through ToPrimitive before acting on primitives. */
|
||||
const primitiveOperators = new Set([
|
||||
"+",
|
||||
"-",
|
||||
"*",
|
||||
"/",
|
||||
"%",
|
||||
"**",
|
||||
"<",
|
||||
"<=",
|
||||
">",
|
||||
">=",
|
||||
"&",
|
||||
"|",
|
||||
"^",
|
||||
"<<",
|
||||
">>",
|
||||
">>>",
|
||||
])
|
||||
|
||||
/** ToPropertyKey on a primitive (or an opaque value, which keeps its built-in string form). */
|
||||
const propertyKey = (value: Value): PropertyKey =>
|
||||
typeof value === "string" || typeof value === "number" || typeof value === "symbol" ? value : coerceToString(value)
|
||||
|
||||
// What a loop does with its body's result: exit with a StatementResult, or undefined to keep iterating.
|
||||
// Unlabelled break ends this loop; a label the loop does not carry propagates outward.
|
||||
const loopExit = (result: StatementResult, labels: ReadonlySet<string> | undefined): StatementResult | undefined => {
|
||||
@@ -1116,18 +1140,15 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (!(value instanceof Obj)) {
|
||||
throw typeError(
|
||||
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
|
||||
pattern,
|
||||
)
|
||||
if (value === null || value === undefined) {
|
||||
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
|
||||
}
|
||||
|
||||
const consumed = new Set<PropertyKey>()
|
||||
for (const property of pattern.properties) {
|
||||
if (property.type === "RestElement") {
|
||||
const rest = new Obj(self.ctx.builtins.Object)
|
||||
assign(rest, value, consumed)
|
||||
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
|
||||
yield* self.declarePattern(property.argument, rest, mutable, property, initialize)
|
||||
continue
|
||||
}
|
||||
@@ -1136,7 +1157,7 @@ class Frame<R> {
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
yield* self.declarePattern(
|
||||
property.value,
|
||||
self.readProperty(value, key, property),
|
||||
self.destructuredProperty(value, key, property),
|
||||
mutable,
|
||||
property,
|
||||
initialize,
|
||||
@@ -1175,24 +1196,21 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (pattern.type === "ObjectPattern") {
|
||||
if (!(value instanceof Obj)) {
|
||||
throw invalidData(
|
||||
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
|
||||
pattern,
|
||||
)
|
||||
if (value === null || value === undefined) {
|
||||
throw typeError(`Cannot destructure ${describeValue(value)} as it is ${value}.`, pattern)
|
||||
}
|
||||
|
||||
const consumed = new Set<PropertyKey>()
|
||||
for (const property of pattern.properties) {
|
||||
if (property.type === "RestElement") {
|
||||
const rest = new Obj(self.ctx.builtins.Object)
|
||||
assign(rest, value, consumed)
|
||||
assign(rest, enumerableSource(self.ctx, "Object destructuring", value, pattern), consumed)
|
||||
yield* self.assignPattern(property.argument, rest, property)
|
||||
continue
|
||||
}
|
||||
const key = yield* self.destructuringPropertyKey(property)
|
||||
consumed.add(typeof key === "symbol" ? key : String(key))
|
||||
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
|
||||
yield* self.assignPattern(property.value, self.destructuredProperty(value, key, property), property)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1263,7 +1281,7 @@ class Frame<R> {
|
||||
}
|
||||
const keyNode = property.key
|
||||
if (property.computed) {
|
||||
return Effect.map(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value))
|
||||
return Effect.flatMap(this.evaluateExpression(keyNode), (value) => this.toPropertyKey(value, keyNode))
|
||||
}
|
||||
if (keyNode.type === "Identifier") return Effect.succeed(keyNode.name)
|
||||
if (keyNode.type === "Literal") return Effect.succeed(String(keyNode.value))
|
||||
@@ -1368,85 +1386,105 @@ class Frame<R> {
|
||||
const lhs = yield* self.evaluateExpression(left)
|
||||
const rhs = yield* self.evaluateExpression(node.right)
|
||||
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
|
||||
if (lhs instanceof Obj || rhs instanceof Obj) return yield* self.applyOperator(operator, lhs, rhs, node)
|
||||
return self.applyBinaryOperator(operator, lhs, rhs, node)
|
||||
})
|
||||
}
|
||||
|
||||
/** ToPrimitive for an operand: data objects run their own methods; opaque values stay for the data gates below. */
|
||||
private toPrimitive(value: Value, hint: Hint, node: AstNode) {
|
||||
return this.native(() => toPrimitive(this.ctx, value, hint), node)
|
||||
}
|
||||
|
||||
// Arithmetic, relational, and bitwise operators convert both operands first, left then right, so a `valueOf`
|
||||
// runs (and throws) in spec order; `+` asks for the default hint and the rest for a number.
|
||||
private applyOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Effect.Effect<Value, unknown, R> {
|
||||
if (!(lhs instanceof Obj || rhs instanceof Obj))
|
||||
return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
|
||||
// IsLooselyEqual converts only an object facing a non-nullish primitive; two objects (including tool
|
||||
// references, which are not Obj) compare by identity.
|
||||
const equality = operator === "==" || operator === "!="
|
||||
// `in` checks the right operand before ToPropertyKey on the left, so a bad right side wins over a bad key.
|
||||
if (operator === "in" && lhs instanceof Obj && !isOpaque(lhs) && rhs instanceof Obj) {
|
||||
return Effect.map(this.toPropertyKey(lhs, node), (key) => has(rhs, key))
|
||||
}
|
||||
const other = lhs instanceof Obj ? rhs : lhs
|
||||
const converts =
|
||||
primitiveOperators.has(operator) ||
|
||||
(equality && other !== null && other !== undefined && typeof other !== "object")
|
||||
if (!converts) return Effect.succeed(this.applyBinaryOperator(operator, lhs, rhs, node))
|
||||
const hint = operator === "+" || equality ? "default" : "number"
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const l = yield* self.toPrimitive(lhs, hint, node)
|
||||
const r = yield* self.toPrimitive(rhs, hint, node)
|
||||
return self.applyBinaryOperator(operator, l, r, node)
|
||||
})
|
||||
}
|
||||
|
||||
private applyBinaryOperator(operator: string, lhs: Value, rhs: Value, node: AstNode): Value {
|
||||
if (operator === "===") return lhs === rhs
|
||||
if (operator === "!==") return lhs !== rhs
|
||||
if (operator === "==") return this.looselyEqual(lhs, rhs, node)
|
||||
if (operator === "!=") return !this.looselyEqual(lhs, rhs, node)
|
||||
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) {
|
||||
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
|
||||
}
|
||||
if (operator === "in" && rhs instanceof Obj && !isOpaque(lhs)) return has(rhs, propertyKey(lhs))
|
||||
if (isOpaque(lhs) || isOpaque(rhs)) {
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
// Addition uses the default hint; every other operator asks for a number.
|
||||
const hint = operator === "+" ? "default" : "number"
|
||||
const coerceOperand = (operand: Value) => (operand instanceof Obj ? operand.toPrimitive(hint) : operand)
|
||||
const l = coerceOperand(lhs)
|
||||
const r = coerceOperand(rhs)
|
||||
// Object operands were already converted by applyOperator; only primitives reach the arithmetic below.
|
||||
switch (operator) {
|
||||
case "+": {
|
||||
const sum = (l as string) + (r as string)
|
||||
const sum = (lhs as string) + (rhs as string)
|
||||
if (typeof sum === "string") checkStringLength(sum.length)
|
||||
return sum
|
||||
}
|
||||
case "-":
|
||||
return (l as number) - (r as number)
|
||||
return (lhs as number) - (rhs as number)
|
||||
case "*":
|
||||
return (l as number) * (r as number)
|
||||
return (lhs as number) * (rhs as number)
|
||||
case "/":
|
||||
return (l as number) / (r as number)
|
||||
return (lhs as number) / (rhs as number)
|
||||
case "%":
|
||||
return (l as number) % (r as number)
|
||||
return (lhs as number) % (rhs as number)
|
||||
case "**":
|
||||
return (l as number) ** (r as number)
|
||||
return (lhs as number) ** (rhs as number)
|
||||
case "<":
|
||||
return (l as string) < (r as string)
|
||||
return (lhs as string) < (rhs as string)
|
||||
case "<=":
|
||||
return (l as string) <= (r as string)
|
||||
return (lhs as string) <= (rhs as string)
|
||||
case ">":
|
||||
return (l as string) > (r as string)
|
||||
return (lhs as string) > (rhs as string)
|
||||
case ">=":
|
||||
return (l as string) >= (r as string)
|
||||
return (lhs as string) >= (rhs as string)
|
||||
case "&":
|
||||
return (l as number) & (r as number)
|
||||
return (lhs as number) & (rhs as number)
|
||||
case "|":
|
||||
return (l as number) | (r as number)
|
||||
return (lhs as number) | (rhs as number)
|
||||
case "^":
|
||||
return (l as number) ^ (r as number)
|
||||
return (lhs as number) ^ (rhs as number)
|
||||
case "<<":
|
||||
return (l as number) << (r as number)
|
||||
return (lhs as number) << (rhs as number)
|
||||
case ">>":
|
||||
return (l as number) >> (r as number)
|
||||
return (lhs as number) >> (rhs as number)
|
||||
case ">>>":
|
||||
return (l as number) >>> (r as number)
|
||||
return (lhs as number) >>> (rhs as number)
|
||||
case "in":
|
||||
if (!(rhs instanceof Obj)) {
|
||||
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
}
|
||||
return has(rhs, coerceOperand(lhs) as PropertyKey)
|
||||
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
|
||||
default:
|
||||
throw typeError(`Unsupported binary operator '${operator}'.`, node)
|
||||
}
|
||||
}
|
||||
|
||||
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and only a
|
||||
// data object facing a non-nullish primitive needs to coerce, so an opaque value is rejected only there.
|
||||
// IsLooselyEqual: objects (including functions and tool references) compare by identity, and a nullish
|
||||
// primitive never equals an object.
|
||||
private looselyEqual(lhs: Value, rhs: Value, node: AstNode): boolean {
|
||||
const lhsObject = lhs !== null && typeof lhs === "object"
|
||||
const rhsObject = rhs !== null && typeof rhs === "object"
|
||||
if (lhsObject === rhsObject) return lhsObject ? lhs === rhs : lhs == rhs
|
||||
const object = lhsObject ? lhs : rhs
|
||||
const primitive = lhsObject ? rhs : lhs
|
||||
if (primitive === null || primitive === undefined) return false
|
||||
if (!(object instanceof Obj) || isOpaque(object)) {
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
return object.toPrimitive("default") == primitive
|
||||
// Data objects were converted by applyOperator, so only an opaque reference facing a primitive gets here.
|
||||
throw invalidData("Binary operators require data values.", node)
|
||||
}
|
||||
|
||||
private evaluateLogicalExpression(node: LogicalExpression): Effect.Effect<Value, unknown, R> {
|
||||
@@ -1468,14 +1506,16 @@ class Frame<R> {
|
||||
if (operator === "typeof" && argument.type === "Identifier" && !this.scopes.resolve(argument.name)) {
|
||||
return Effect.succeed("undefined")
|
||||
}
|
||||
return Effect.map(this.evaluateExpression(argument), (value) => {
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
const value = yield* self.evaluateExpression(argument)
|
||||
if (operator === "typeof") return typeofValue(value)
|
||||
if (operator === "!") return !value
|
||||
if (operator === "void") return undefined
|
||||
if (isOpaque(value)) {
|
||||
const operand = yield* self.toPrimitive(value, "number", node)
|
||||
if (isOpaque(operand)) {
|
||||
throw invalidData("Unary operators require data values.", node)
|
||||
}
|
||||
const operand = value instanceof Obj ? value.toPrimitive("number") : value
|
||||
let result: Value
|
||||
switch (operator) {
|
||||
case "+":
|
||||
@@ -1497,11 +1537,16 @@ class Frame<R> {
|
||||
private evaluateAssignmentExpression(node: AssignmentExpression): Effect.Effect<Value, unknown, R> {
|
||||
const left = node.left
|
||||
const operator = node.operator
|
||||
// The binary operator a compound assignment applies: `+=` is `+`.
|
||||
const binary = operator.slice(0, -1)
|
||||
const self = this
|
||||
return Effect.gen(function* () {
|
||||
if (operator === "??=" || operator === "||=" || operator === "&&=") {
|
||||
return yield* self.evaluateLogicalAssignment(node, left, operator)
|
||||
}
|
||||
if (operator !== "=" && !compoundOperators.has(operator)) {
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
if (operator === "=" && (left.type === "ObjectPattern" || left.type === "ArrayPattern")) {
|
||||
const rightValue = yield* self.evaluateExpression(node.right)
|
||||
yield* self.assignPattern(left, rightValue, node)
|
||||
@@ -1512,17 +1557,24 @@ class Frame<R> {
|
||||
if (operator !== "=") {
|
||||
const current = self.scopes.get(name, left)
|
||||
const rightValue = yield* self.evaluateExpression(node.right)
|
||||
return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left)
|
||||
const next =
|
||||
current instanceof Obj || rightValue instanceof Obj
|
||||
? yield* self.applyOperator(binary, current, rightValue, node)
|
||||
: self.applyBinaryOperator(binary, current, rightValue, node)
|
||||
return self.scopes.set(name, next, left)
|
||||
}
|
||||
const rightValue = yield* self.evaluateNamed(node.right, name)
|
||||
return self.scopes.set(name, rightValue, left)
|
||||
}
|
||||
if (left.type === "MemberExpression") {
|
||||
return yield* self.modifyMember(left, (current) =>
|
||||
Effect.map(self.evaluateExpression(node.right), (rightValue) => {
|
||||
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
|
||||
const next = self.applyCompoundAssignment(operator, current, rightValue, node)
|
||||
return { write: true, next, result: next }
|
||||
Effect.flatMap(self.evaluateExpression(node.right), (rightValue) => {
|
||||
if (operator === "=") return Effect.succeed({ write: true, next: rightValue, result: rightValue })
|
||||
return Effect.map(self.applyOperator(binary, current, rightValue, node), (next) => ({
|
||||
write: true,
|
||||
next,
|
||||
result: next,
|
||||
}))
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1572,8 +1624,7 @@ class Frame<R> {
|
||||
throw typeError(`Unsupported update operator '${operator}'.`, node)
|
||||
}
|
||||
|
||||
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
|
||||
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
|
||||
// CodeMode numeric coercion, not host Number(), so opaque runtime references reject clearly.
|
||||
const operand = (current: Value): number => {
|
||||
if (isOpaque(current)) {
|
||||
throw invalidData(`'${operator}' requires a data value.`, argument)
|
||||
@@ -1582,21 +1633,26 @@ class Frame<R> {
|
||||
}
|
||||
|
||||
if (argument.type === "Identifier") {
|
||||
return Effect.sync(() => {
|
||||
const name = argument.name
|
||||
const current = operand(this.scopes.get(name, argument))
|
||||
const next = current + increment
|
||||
const name = argument.name
|
||||
const current = this.scopes.get(name, argument)
|
||||
const update = (value: Value) => {
|
||||
const before = operand(value)
|
||||
const next = before + increment
|
||||
this.scopes.set(name, next, argument)
|
||||
return prefix ? next : current
|
||||
})
|
||||
return prefix ? next : before
|
||||
}
|
||||
if (!(current instanceof Obj)) return Effect.sync(() => update(current))
|
||||
return Effect.map(this.toPrimitive(current, "number", argument), update)
|
||||
}
|
||||
|
||||
if (argument.type === "MemberExpression") {
|
||||
return this.modifyMember(argument, (current) => {
|
||||
const value = operand(current)
|
||||
const next = value + increment
|
||||
return Effect.succeed({ write: true, next, result: prefix ? next : value })
|
||||
})
|
||||
return this.modifyMember(argument, (current) =>
|
||||
Effect.map(this.toPrimitive(current, "number", argument), (primitive) => {
|
||||
const value = operand(primitive)
|
||||
const next = value + increment
|
||||
return { write: true, next, result: prefix ? next : value }
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
|
||||
@@ -1996,11 +2052,11 @@ class Frame<R> {
|
||||
let key: PropertyKey
|
||||
|
||||
if (property.computed) {
|
||||
key = self.toPropertyKey(yield* self.evaluateExpression(keyNode))
|
||||
key = yield* self.toPropertyKey(yield* self.evaluateExpression(keyNode), keyNode)
|
||||
} else if (keyNode.type === "Identifier") {
|
||||
key = keyNode.name
|
||||
} else if (keyNode.type === "Literal") {
|
||||
key = self.toPropertyKey(literal(keyNode))
|
||||
key = propertyKey(literal(keyNode))
|
||||
} else {
|
||||
throw typeError("Unsupported object property key shape.", keyNode)
|
||||
}
|
||||
@@ -2065,7 +2121,7 @@ class Frame<R> {
|
||||
|
||||
if (index < expressions.length) {
|
||||
const raw = yield* self.evaluateExpression(expressions[index])
|
||||
output += coerceToString(raw)
|
||||
output += coerceToString(yield* self.toPrimitive(raw, "string", expressions[index]))
|
||||
checkStringLength(output.length)
|
||||
}
|
||||
}
|
||||
@@ -2115,13 +2171,6 @@ class Frame<R> {
|
||||
)
|
||||
}
|
||||
|
||||
private applyCompoundAssignment(operator: string, current: Value, incoming: Value, node: AstNode): Value {
|
||||
if (!compoundOperators.has(operator)) {
|
||||
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
|
||||
}
|
||||
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
|
||||
}
|
||||
|
||||
private getMemberReference(
|
||||
node: MemberExpression,
|
||||
): Effect.Effect<MemberReference | ToolReference | { value: Value } | typeof OptionalShortCircuit, unknown, R> {
|
||||
@@ -2135,37 +2184,58 @@ class Frame<R> {
|
||||
if (objectValue === OptionalShortCircuit) return OptionalShortCircuit
|
||||
if ((objectValue === null || objectValue === undefined) && node.optional) return OptionalShortCircuit
|
||||
|
||||
const key = node.computed
|
||||
? self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
|
||||
: propertyNode.type === "Identifier"
|
||||
const keyValue =
|
||||
!node.computed && propertyNode.type === "Identifier"
|
||||
? propertyNode.name
|
||||
: self.toPropertyKey(yield* self.evaluateExpression(propertyNode))
|
||||
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string") {
|
||||
throw typeError("Tool paths must use string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return { value: objectValue.length }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
if (index !== undefined) return { value: objectValue[index] }
|
||||
}
|
||||
const proto = primitivePrototype(self.ctx.builtins, objectValue)
|
||||
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
|
||||
|
||||
: yield* self.evaluateExpression(propertyNode)
|
||||
// GetValue applies ToObject to the base before ToPropertyKey, so a nullish base throws before the key's own
|
||||
// toString runs.
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${coerceToString(keyValue)}').`, objectNode)
|
||||
}
|
||||
throw typeError("Cannot access a property on a non-object value.", objectNode)
|
||||
const key = yield* self.toPropertyKey(keyValue, propertyNode)
|
||||
return self.resolveProperty(objectValue, key, objectNode, propertyNode)
|
||||
})
|
||||
}
|
||||
|
||||
private resolveProperty(
|
||||
objectValue: Value,
|
||||
key: PropertyKey,
|
||||
objectNode: AstNode,
|
||||
propertyNode: AstNode,
|
||||
): MemberReference | ToolReference | { value: Value } {
|
||||
if (objectValue instanceof ToolReference) {
|
||||
if (typeof key !== "string") {
|
||||
throw typeError("Tool paths must use string property names.", propertyNode)
|
||||
}
|
||||
return new ToolReference([...objectValue.path, key])
|
||||
}
|
||||
|
||||
if (objectValue instanceof Obj) return { target: objectValue, key, receiver: objectValue }
|
||||
|
||||
// Strings own length and indexes; every other primitive property reads through the wrapper prototype.
|
||||
if (typeof objectValue === "string") {
|
||||
if (key === "length") return { value: objectValue.length }
|
||||
const index = typeof key === "symbol" ? undefined : parseArrayIndex(key)
|
||||
if (index !== undefined) return { value: objectValue[index] }
|
||||
}
|
||||
const proto = primitivePrototype(this.ctx.builtins, objectValue)
|
||||
if (proto !== undefined) return { target: proto, key, receiver: objectValue }
|
||||
|
||||
if (objectValue === null || objectValue === undefined) {
|
||||
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
|
||||
}
|
||||
throw typeError("Cannot access a property on a non-object value.", objectNode)
|
||||
}
|
||||
|
||||
// One destructured property, read the way a member expression would read it (primitives use their prototype).
|
||||
private destructuredProperty(source: Value, key: PropertyKey, node: AstNode): Value {
|
||||
const reference = this.resolveProperty(source, key, node, node)
|
||||
if (reference instanceof ToolReference) return reference
|
||||
if ("value" in reference) return reference.value
|
||||
return this.readProperty(reference.target, reference.key, node, reference.receiver)
|
||||
}
|
||||
|
||||
private readReference(reference: MemberReference, node: MemberExpression): Value {
|
||||
// Reject unknown promise properties so a missing await cannot hide.
|
||||
if (reference.target instanceof PromiseObj && !has(reference.target, reference.key)) {
|
||||
@@ -2255,9 +2325,10 @@ class Frame<R> {
|
||||
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
|
||||
}
|
||||
|
||||
// ToPropertyKey: anything else becomes its string form, so `counts[row.category]` works when the field is null.
|
||||
private toPropertyKey(value: Value): PropertyKey {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "symbol") return value
|
||||
return coerceToString(value)
|
||||
// ToPropertyKey: a data object converts through its own `toString`/`valueOf` first; anything else becomes its
|
||||
// string form synchronously, so `counts[row.category]` works when the field is null.
|
||||
private toPropertyKey(value: Value, node: AstNode): Effect.Effect<PropertyKey, unknown, R> {
|
||||
if (!(value instanceof Obj)) return Effect.succeed(propertyKey(value))
|
||||
return Effect.map(this.toPrimitive(value, "string", node), propertyKey)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,8 @@ const builtins = [
|
||||
"RegExp",
|
||||
"Map",
|
||||
"Set",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"URL",
|
||||
"URLSearchParams",
|
||||
"Headers",
|
||||
@@ -88,6 +90,8 @@ export const createBuiltins = (): Builtins => {
|
||||
RegExp: plain(),
|
||||
Map: plain(),
|
||||
Set: plain(),
|
||||
WeakMap: plain(),
|
||||
WeakSet: plain(),
|
||||
URL: plain(),
|
||||
URLSearchParams: plain(),
|
||||
Headers: plain(),
|
||||
|
||||
@@ -356,6 +356,23 @@ export class SetObj extends Wrapper {
|
||||
}
|
||||
}
|
||||
|
||||
/** Keys are program objects, so a host WeakMap gives the same lifetime rule as JavaScript without any bookkeeping. */
|
||||
export class WeakMapObj extends Wrapper {
|
||||
override readonly tag = "WeakMap"
|
||||
readonly map = new WeakMap<Obj, Value>()
|
||||
override inspect() {
|
||||
return "WeakMap { <items unknown> }"
|
||||
}
|
||||
}
|
||||
|
||||
export class WeakSetObj extends Wrapper {
|
||||
override readonly tag = "WeakSet"
|
||||
readonly set = new WeakSet<Obj>()
|
||||
override inspect() {
|
||||
return "WeakSet { <items unknown> }"
|
||||
}
|
||||
}
|
||||
|
||||
export class URLSearchParamsObj extends Wrapper {
|
||||
override readonly tag = "URLSearchParams"
|
||||
constructor(
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { applyCollectionCallback, invoke, preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { compareText } from "../tool-runtime.js"
|
||||
|
||||
@@ -49,7 +49,7 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
|
||||
const values: Array<Value> = []
|
||||
for (let index = 0; index < arrayLike.length; index += 1) {
|
||||
const item = get(arrayLike.source, index)
|
||||
values.push(apply === undefined ? item : yield* apply([item, index]))
|
||||
values.push(apply === undefined ? item : yield* apply([item, index], args[2]))
|
||||
}
|
||||
return new Arr(proto, values)
|
||||
}
|
||||
@@ -59,7 +59,9 @@ const arrayFrom = <R>(ctx: Interpreter<R>, args: Array<Value>): Effect.Effect<Va
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return new Arr(proto, values)
|
||||
values.push(
|
||||
apply === undefined ? step.value : yield* preserveConsumerError(cursor.close, apply([step.value, index])),
|
||||
apply === undefined
|
||||
? step.value
|
||||
: yield* preserveConsumerError(cursor.close, apply([step.value, index], args[2])),
|
||||
)
|
||||
index += 1
|
||||
}
|
||||
@@ -146,20 +148,30 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
"join",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const joined = self(thisValue, "join")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(args[0] === undefined ? "," : coerceToString(args[0]))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
// .map would keep holes, which Effect.forEach would then hand to the body as undefined.
|
||||
const parts = Array.from(self(thisValue, "join").items, (item) => item ?? "")
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
[args[0] === undefined ? "," : args[0], ...parts],
|
||||
([separator, ...items]) => {
|
||||
const joined = items.map(coerceToString).join(coerceToString(separator))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
},
|
||||
)
|
||||
},
|
||||
],
|
||||
[
|
||||
"toString",
|
||||
0,
|
||||
(thisValue) =>
|
||||
self(thisValue, "toString")
|
||||
.items.map((item) => coerceToString(item ?? ""))
|
||||
.join(","),
|
||||
withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
Array.from(self(thisValue, "toString").items, (item) => item ?? ""),
|
||||
(items) => items.map(coerceToString).join(","),
|
||||
),
|
||||
],
|
||||
[
|
||||
"includes",
|
||||
@@ -362,7 +374,7 @@ export const arrayGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const values: Array<Value> = []
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (!(index in target.items)) continue
|
||||
const mapped = yield* apply([target.items[index], index, target])
|
||||
const mapped = yield* apply([target.items[index], index, target], args[1])
|
||||
if (mapped instanceof Arr) values.push(...mapped.items)
|
||||
else values.push(mapped)
|
||||
}
|
||||
@@ -401,7 +413,10 @@ export const callbackMethods = <R, T extends Obj>(
|
||||
length,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, name)
|
||||
return body(elements(target), target, applyCollectionCallback(ctx, args[0], `${label}.${name}`), args)
|
||||
const call = applyCollectionCallback(ctx, args[0], `${label}.${name}`)
|
||||
// reduce and reduceRight take an initial value where the others take a thisArg.
|
||||
const thisArg = name.startsWith("reduce") ? undefined : args[1]
|
||||
return body(elements(target), target, (callbackArgs) => call(callbackArgs, thisArg), args)
|
||||
},
|
||||
]
|
||||
return [
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
PromiseObj,
|
||||
SetObj,
|
||||
type Value,
|
||||
WeakMapObj,
|
||||
WeakSetObj,
|
||||
} from "../interpreter/objects.js"
|
||||
import { describeValue, isOpaque } from "../interpreter/references.js"
|
||||
import {
|
||||
@@ -188,7 +190,7 @@ export const mapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Map.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, item] of target.map.entries()) yield* apply([item, key, target])
|
||||
for (const [key, item] of target.map.entries()) yield* apply([item, key, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
@@ -386,7 +388,7 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Set.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const item of target.set.values()) yield* apply([item, item, target])
|
||||
for (const item of target.set.values()) yield* apply([item, item, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
@@ -402,3 +404,134 @@ export const setGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
define(proto, IteratorSymbol, get(proto, "values"), hidden)
|
||||
return set
|
||||
}
|
||||
|
||||
// CanBeHeldWeakly: only program objects; tool references are rebuilt on every access, so they could never be found again.
|
||||
const weakKey = (value: Value, label: string) => {
|
||||
if (value instanceof Obj) return value
|
||||
throw typeError(`Invalid value used ${label}: ${describeValue(value)} cannot be held weakly.`)
|
||||
}
|
||||
|
||||
export const weakMapGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.WeakMap
|
||||
const weakMap = constructor<R>(builtins, proto, {
|
||||
name: "WeakMap",
|
||||
call: requiresNew("WeakMap"),
|
||||
construct: (args, newTarget) => {
|
||||
const target = new WeakMapObj(prototypeFrom(newTarget, proto))
|
||||
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(args[0]!)
|
||||
if (cursor === undefined) {
|
||||
throw typeError(
|
||||
`new WeakMap(...) expects an iterable of [key, value] pairs, received ${describeValue(args[0])}.`,
|
||||
)
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return target
|
||||
yield* preserveConsumerError(
|
||||
cursor.close,
|
||||
Effect.sync(() => {
|
||||
if (!(step.value instanceof Obj)) {
|
||||
throw typeError("new WeakMap(...) expects [key, value] pairs as entry objects.")
|
||||
}
|
||||
target.map.set(weakKey(getOwn(step.value, 0), "as weak map key"), getOwn(step.value, 1))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
const self = (thisValue: Value, name: string) => receiver(WeakMapObj, thisValue, `WeakMap.prototype.${name}`).map
|
||||
// Lookups pass any key through: the host collection answers false for a non-object, as the spec requires.
|
||||
const key = (value: Value) => weakKey(value, "as weak map key")
|
||||
methods(builtins, proto, [
|
||||
[
|
||||
"get",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "get")
|
||||
return args[0] instanceof Obj ? target.get(args[0]) : undefined
|
||||
},
|
||||
],
|
||||
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
|
||||
[
|
||||
"set",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
self(thisValue, "set").set(key(args[0]), args[1])
|
||||
return thisValue
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsert",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsert")
|
||||
const k = key(args[0])
|
||||
if (!target.has(k)) target.set(k, args[1])
|
||||
return target.get(k)
|
||||
},
|
||||
],
|
||||
[
|
||||
"getOrInsertComputed",
|
||||
2,
|
||||
(thisValue, args) => {
|
||||
const target = self(thisValue, "getOrInsertComputed")
|
||||
const k = key(args[0])
|
||||
const apply = applyCollectionCallback(ctx, args[1], "WeakMap.getOrInsertComputed")
|
||||
if (target.has(k)) return target.get(k)
|
||||
return Effect.map(apply([k]), (value) => {
|
||||
target.set(k, value)
|
||||
return value
|
||||
})
|
||||
},
|
||||
],
|
||||
])
|
||||
return weakMap
|
||||
}
|
||||
|
||||
export const weakSetGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const proto = builtins.WeakSet
|
||||
const weakSet = constructor<R>(builtins, proto, {
|
||||
name: "WeakSet",
|
||||
call: requiresNew("WeakSet"),
|
||||
construct: (args, newTarget) => {
|
||||
const target = new WeakSetObj(prototypeFrom(newTarget, proto))
|
||||
if (args[0] === undefined || args[0] === null) return Effect.succeed(target)
|
||||
return Effect.gen(function* () {
|
||||
const cursor = yield* ctx.iterate(args[0]!)
|
||||
if (cursor === undefined) {
|
||||
throw typeError(`new WeakSet(...) expects a synchronous iterable, received ${describeValue(args[0])}.`)
|
||||
}
|
||||
while (true) {
|
||||
const step = yield* cursor.next
|
||||
if (step.done) return target
|
||||
yield* preserveConsumerError(
|
||||
cursor.close,
|
||||
Effect.sync(() => {
|
||||
target.set.add(weakKey(step.value, "in weak set"))
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
const self = (thisValue: Value, name: string) => receiver(WeakSetObj, thisValue, `WeakSet.prototype.${name}`).set
|
||||
methods(builtins, proto, [
|
||||
["has", 1, (thisValue, args) => self(thisValue, "has").has(args[0] as Obj)],
|
||||
["delete", 1, (thisValue, args) => self(thisValue, "delete").delete(args[0] as Obj)],
|
||||
[
|
||||
"add",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
self(thisValue, "add").add(weakKey(args[0], "in weak set"))
|
||||
return thisValue
|
||||
},
|
||||
],
|
||||
])
|
||||
return weakSet
|
||||
}
|
||||
|
||||
@@ -16,8 +16,11 @@ const constructDate = <R>(ctx: Interpreter<R>, args: Array<Value>, proto: Obj) =
|
||||
: new DateObj(proto, new Date(coerceToNumber(value)).getTime()),
|
||||
)
|
||||
}
|
||||
const parts = args.map((arg) => coerceToNumber(arg))
|
||||
return Effect.succeed(new DateObj(proto, new Date(...(parts as [number, number])).getTime()))
|
||||
// The spec converts at most seven components, in order, so extra arguments never run program code.
|
||||
return Effect.map(
|
||||
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
|
||||
(parts) => new DateObj(proto, new Date(...(parts as [number, number])).getTime()),
|
||||
)
|
||||
}
|
||||
|
||||
type Getter = keyof {
|
||||
@@ -79,7 +82,15 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
methods(builtins, date, [
|
||||
["now", 0, () => Date.now()],
|
||||
["parse", 1, (_, args) => Date.parse(coerceToString(args[0]))],
|
||||
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
|
||||
[
|
||||
"UTC",
|
||||
7,
|
||||
(_, args) =>
|
||||
Effect.map(
|
||||
Effect.forEach(args.slice(0, 7), (arg) => toPrimitiveNumber(ctx, arg), { concurrency: 1 }),
|
||||
(parts) => Date.UTC(...(parts as Parameters<typeof Date.UTC>)),
|
||||
),
|
||||
],
|
||||
])
|
||||
|
||||
const self = (thisValue: Value, name: string) => receiver(DateObj, thisValue, `Date.prototype.${name}`)
|
||||
@@ -125,6 +136,8 @@ export const dateGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
concurrency: 1,
|
||||
}),
|
||||
(values) => {
|
||||
// Every setter but setTime and setFullYear leaves an invalid Date untouched and answers NaN.
|
||||
if (Number.isNaN(hosted.getTime()) && name !== "setTime" && !name.endsWith("FullYear")) return NaN
|
||||
target.time = hosted[name](...(values as [number, number, number, number]))
|
||||
return target.time
|
||||
},
|
||||
|
||||
@@ -117,7 +117,7 @@ export const headersGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
|
||||
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import { constants, type Method, methods } from "../interpreter/native.js"
|
||||
import { typeError } from "../interpreter/model.js"
|
||||
import { Obj, coerceToNumber } from "../interpreter/objects.js"
|
||||
import { preserveConsumerError } from "../interpreter/callback.js"
|
||||
import { preserveConsumerError, withPrimitives } from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
|
||||
// Bun exposes ES2026 Math.sumPrecise before TypeScript's standard library types.
|
||||
@@ -12,25 +12,27 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
// Validate only the arguments a method consumes; like JS, extras are ignored
|
||||
// (so built-ins work as callbacks receiving (element, index, array)).
|
||||
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(coerceToNumber(args[0]))]
|
||||
|
||||
const binary = (name: string, op: (a: number, b: number) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => op(coerceToNumber(args[0]), coerceToNumber(args[1])),
|
||||
]
|
||||
|
||||
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => op(...args.map(coerceToNumber)),
|
||||
]
|
||||
|
||||
export const mathGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
const math = new Obj(builtins.Object)
|
||||
// Convert only the arguments a method consumes; like JS, extras are ignored
|
||||
// (so built-ins work as callbacks receiving (element, index, array)).
|
||||
const unary = (name: string, op: (a: number) => number): Method => [
|
||||
name,
|
||||
1,
|
||||
(_, args) => withPrimitives(ctx, "number", [args[0]], ([a]) => op(coerceToNumber(a))),
|
||||
]
|
||||
const binary = (name: string, op: (a: number, b: number) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) =>
|
||||
withPrimitives(ctx, "number", [args[0], args[1]], ([a, b]) => op(coerceToNumber(a), coerceToNumber(b))),
|
||||
]
|
||||
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
|
||||
name,
|
||||
2,
|
||||
(_, args) => withPrimitives(ctx, "number", args, (values) => op(...values.map(coerceToNumber))),
|
||||
]
|
||||
constants(math, {
|
||||
PI: Math.PI,
|
||||
E: Math.E,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { constructor, constants, methods } from "../interpreter/native.js"
|
||||
import { coerceToNumber, coerceToString, type Value } from "../interpreter/objects.js"
|
||||
import { constructor, constants, type Method, methods } from "../interpreter/native.js"
|
||||
import { coerceToNumber, type Value } from "../interpreter/objects.js"
|
||||
import { rangeError, typeError } from "../interpreter/model.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coercion } from "./value.js"
|
||||
import { withPrimitives } from "../interpreter/callback.js"
|
||||
import { coerce, coercion } from "./value.js"
|
||||
|
||||
export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
@@ -26,46 +27,36 @@ export const numberGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
["isFinite", 1, (_, args) => Number.isFinite(args[0])],
|
||||
["isNaN", 1, (_, args) => Number.isNaN(args[0])],
|
||||
["isSafeInteger", 1, (_, args) => Number.isSafeInteger(args[0])],
|
||||
[
|
||||
"parseInt",
|
||||
2,
|
||||
(_, args) => {
|
||||
return parseInt(coerceToString(args[0]), coerceToNumber(args[1]))
|
||||
},
|
||||
],
|
||||
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
|
||||
["parseInt", 2, (_, args) => coerce(ctx, "parseInt", args)],
|
||||
["parseFloat", 1, (_, args) => coerce(ctx, "parseFloat", args)],
|
||||
])
|
||||
|
||||
const self = (thisValue: Value, name: string): number => {
|
||||
if (typeof thisValue === "number") return thisValue
|
||||
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
|
||||
}
|
||||
const optNum = (arg: Value): number | undefined => (arg === undefined ? undefined : coerceToNumber(arg))
|
||||
// The receiver is checked first, then the one argument converts through ToPrimitive with the number hint.
|
||||
const formatting = (name: string, op: (value: number, digits: number | undefined) => string): Method => [
|
||||
name,
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, name)
|
||||
return withPrimitives(ctx, "number", [args[0]], ([digits]) =>
|
||||
op(value, digits === undefined ? undefined : coerceToNumber(digits)),
|
||||
)
|
||||
},
|
||||
]
|
||||
methods(builtins, builtins.Number, [
|
||||
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum(args[0]))],
|
||||
formatting("toFixed", (value, digits) => value.toFixed(digits)),
|
||||
["toLocaleString", 0, (thisValue) => self(thisValue, "toLocaleString").toLocaleString("en-US")],
|
||||
["toExponential", 1, (thisValue, args) => self(thisValue, "toExponential").toExponential(optNum(args[0]))],
|
||||
[
|
||||
"toPrecision",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, "toPrecision")
|
||||
const digits = optNum(args[0])
|
||||
return digits === undefined ? value.toString() : value.toPrecision(digits)
|
||||
},
|
||||
],
|
||||
[
|
||||
"toString",
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, "toString")
|
||||
const radix = optNum(args[0])
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw rangeError("Number.toString radix must be between 2 and 36.")
|
||||
}
|
||||
return value.toString(radix)
|
||||
},
|
||||
],
|
||||
formatting("toExponential", (value, digits) => value.toExponential(digits)),
|
||||
formatting("toPrecision", (value, digits) => (digits === undefined ? value.toString() : value.toPrecision(digits))),
|
||||
formatting("toString", (value, radix) => {
|
||||
if (radix !== undefined && (radix < 2 || radix > 36)) {
|
||||
throw rangeError("Number.toString radix must be between 2 and 36.")
|
||||
}
|
||||
return value.toString(radix)
|
||||
}),
|
||||
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
|
||||
])
|
||||
return number
|
||||
|
||||
@@ -17,7 +17,13 @@ import {
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
|
||||
import { applyCollectionCallback, isSupportedCallback, toPrimitiveString } from "../interpreter/callback.js"
|
||||
import {
|
||||
applyCollectionCallback,
|
||||
type Hint,
|
||||
isSupportedCallback,
|
||||
toPrimitiveString,
|
||||
withPrimitives,
|
||||
} from "../interpreter/callback.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { matchToValue, toHostRegex } from "./regexp.js"
|
||||
import { coercion } from "./value.js"
|
||||
@@ -142,34 +148,72 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
args[index] === undefined ? undefined : num(name, args, index)
|
||||
const optStr = (name: string, args: Array<Value>, index: number): string | undefined =>
|
||||
args[index] === undefined ? undefined : str(name, args, index)
|
||||
const rejectRegex = (name: string, args: Array<Value>): void => {
|
||||
if (args[0] instanceof RegExpObj) {
|
||||
throw typeError(
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
)
|
||||
}
|
||||
}
|
||||
// ToPrimitive in spec order: the receiver, then the arguments the method consumes, one hint per position; the
|
||||
// rest pass through as they are.
|
||||
const simple = (
|
||||
name: string,
|
||||
length: number,
|
||||
op: (value: string, args: Array<Value>) => ReturnType<Impl>,
|
||||
): Method => [name, length, (thisValue, args) => op(self(thisValue, name), args)]
|
||||
const replace = (name: "replace" | "replaceAll") =>
|
||||
simple(name, 2, (value, args) => {
|
||||
if (isSupportedCallback(args[1])) return replaceWithCallback(ctx, value, name, args)
|
||||
if (typeofValue(args[1]) === "function") {
|
||||
hints: ReadonlyArray<Hint> = [],
|
||||
): Method => [
|
||||
name,
|
||||
length,
|
||||
(thisValue, args) => {
|
||||
if (thisValue === null || thisValue === undefined) {
|
||||
throw typeError(`String.prototype.${name} called on null or undefined.`)
|
||||
}
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
["string", ...hints],
|
||||
[thisValue, ...args.slice(0, hints.length)],
|
||||
([value, ...primitives]) => op(coerceToString(value), [...primitives, ...args.slice(hints.length)]),
|
||||
)
|
||||
},
|
||||
]
|
||||
// includes, startsWith, and endsWith reject a RegExp before converting their search string and position.
|
||||
const searching = (name: string, op: (value: string, search: string, position: number | undefined) => boolean) =>
|
||||
simple(name, 1, (value, args) => {
|
||||
if (args[0] instanceof RegExpObj) {
|
||||
throw typeError(
|
||||
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
|
||||
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
|
||||
)
|
||||
}
|
||||
if (args[0] instanceof RegExpObj) {
|
||||
const pattern = args[0].regex
|
||||
const replacement = str(name, args, 1)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
|
||||
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
|
||||
}
|
||||
if (name === "replace") return value.replace(str(name, args, 0), str(name, args, 1))
|
||||
return value.replaceAll(str(name, args, 0), str(name, args, 1))
|
||||
return withPrimitives(ctx, ["string", "number"], args.slice(0, 2), (primitives) =>
|
||||
op(value, str(name, primitives, 0), optNum(name, primitives, 1)),
|
||||
)
|
||||
})
|
||||
// match, matchAll, and search read a RegExp as is and convert anything else to its pattern text.
|
||||
const withPattern = (args: Array<Value>, op: (pattern: Value) => Value) =>
|
||||
args[0] instanceof RegExpObj ? op(args[0]) : withPrimitives(ctx, "string", [args[0]], ([text]) => op(text))
|
||||
const replace = (name: "replace" | "replaceAll") =>
|
||||
simple(name, 2, (value, args) => {
|
||||
const pattern = args[0]
|
||||
const replacer = args[1]
|
||||
// A RegExp pattern is used as is; a plain one converts to its search string, then a non-callable replacement.
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
"string",
|
||||
[pattern instanceof RegExpObj ? undefined : pattern, isSupportedCallback(replacer) ? undefined : replacer],
|
||||
([search, replacement]) => {
|
||||
if (isSupportedCallback(replacer)) {
|
||||
return replaceWithCallback(ctx, value, name, [pattern instanceof RegExpObj ? pattern : search, replacer])
|
||||
}
|
||||
if (typeofValue(replacer) === "function") {
|
||||
throw typeError(
|
||||
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
|
||||
)
|
||||
}
|
||||
const primitives = [search, replacement]
|
||||
if (pattern instanceof RegExpObj) {
|
||||
const regex = pattern.regex
|
||||
const text = str(name, primitives, 1)
|
||||
if (name === "replaceAll") replaceAllNeedsGlobal(regex)
|
||||
return name === "replace" ? value.replace(regex, text) : value.replaceAll(regex, text)
|
||||
}
|
||||
if (name === "replace") return value.replace(str(name, primitives, 0), str(name, primitives, 1))
|
||||
return value.replaceAll(str(name, primitives, 0), str(name, primitives, 1))
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
methods(builtins, builtins.String, [
|
||||
@@ -185,107 +229,148 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
simple("trimEnd", 0, (value) => value.trimEnd()),
|
||||
simple("trimRight", 0, (value) => value.trimEnd()),
|
||||
// Locale/options are deliberately unsupported; comparison uses the host default locale.
|
||||
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
|
||||
simple("normalize", 0, (value, args) => {
|
||||
const form = optStr("normalize", args, 0)
|
||||
try {
|
||||
return value.normalize(form)
|
||||
} catch {
|
||||
throw rangeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
)
|
||||
}
|
||||
}),
|
||||
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0)), ["string"]),
|
||||
simple(
|
||||
"normalize",
|
||||
0,
|
||||
(value, args) => {
|
||||
const form = optStr("normalize", args, 0)
|
||||
try {
|
||||
return value.normalize(form)
|
||||
} catch {
|
||||
throw rangeError(
|
||||
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
|
||||
)
|
||||
}
|
||||
},
|
||||
["string"],
|
||||
),
|
||||
simple("split", 2, (value, args) => {
|
||||
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
|
||||
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||
// unless the limit truncates to zero.
|
||||
const requestedLimit = optNum("split", args, 1)
|
||||
if (args[0] === undefined) {
|
||||
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
|
||||
}
|
||||
const parts =
|
||||
args[0] instanceof RegExpObj
|
||||
? value.split(args[0].regex, requestedLimit)
|
||||
: value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
checkArrayLength(parts.length)
|
||||
return wrap(parts)
|
||||
const separator = args[0]
|
||||
// A RegExp separator is used as is; the limit converts before a plain separator does, as in the spec.
|
||||
return withPrimitives(
|
||||
ctx,
|
||||
["number", "string"],
|
||||
[args[1], separator instanceof RegExpObj ? undefined : separator],
|
||||
([limit, pattern]) => {
|
||||
const wrap = (parts: Array<string>) => new Arr(builtins.Array, parts)
|
||||
// Native: an undefined separator returns the whole string, not a split on "undefined",
|
||||
// unless the limit truncates to zero.
|
||||
const requestedLimit = args[1] === undefined ? undefined : num("split", [pattern, limit], 1)
|
||||
if (separator === undefined) {
|
||||
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
|
||||
}
|
||||
const parts =
|
||||
separator instanceof RegExpObj
|
||||
? value.split(separator.regex, requestedLimit)
|
||||
: value.split(str("split", [pattern], 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0)
|
||||
checkArrayLength(parts.length)
|
||||
return wrap(parts)
|
||||
},
|
||||
)
|
||||
}),
|
||||
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1))),
|
||||
simple("includes", 1, (value, args) => {
|
||||
rejectRegex("includes", args)
|
||||
return value.includes(str("includes", args, 0), optNum("includes", args, 1))
|
||||
}),
|
||||
simple("startsWith", 1, (value, args) => {
|
||||
rejectRegex("startsWith", args)
|
||||
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", args, 1))
|
||||
}),
|
||||
simple("endsWith", 1, (value, args) => {
|
||||
rejectRegex("endsWith", args)
|
||||
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", args, 1))
|
||||
}),
|
||||
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1))),
|
||||
simple("lastIndexOf", 1, (value, args) =>
|
||||
value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
|
||||
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1)), [
|
||||
"number",
|
||||
"number",
|
||||
]),
|
||||
searching("includes", (value, search, position) => value.includes(search, position)),
|
||||
searching("startsWith", (value, search, position) => value.startsWith(search, position)),
|
||||
searching("endsWith", (value, search, position) => value.endsWith(search, position)),
|
||||
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1)), [
|
||||
"string",
|
||||
"number",
|
||||
]),
|
||||
simple(
|
||||
"lastIndexOf",
|
||||
1,
|
||||
(value, args) => value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
|
||||
["string", "number"],
|
||||
),
|
||||
replace("replace"),
|
||||
replace("replaceAll"),
|
||||
simple("match", 1, (value, args) => {
|
||||
const pattern = toHostRegex(args[0], "match")
|
||||
const matched = value.match(pattern)
|
||||
if (matched === null) return null
|
||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
||||
if (pattern.global) return new Arr(builtins.Array, [...matched])
|
||||
return matchToValue(builtins, matched)
|
||||
}),
|
||||
simple("matchAll", 1, (value, args) => {
|
||||
const pattern = toHostRegex(args[0], "matchAll", "g")
|
||||
if (!pattern.global) {
|
||||
throw typeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
|
||||
)
|
||||
}
|
||||
const matches: Array<Value> = []
|
||||
for (const match of value.matchAll(pattern)) {
|
||||
checkArrayLength(matches.length + 1)
|
||||
matches.push(matchToValue(builtins, match))
|
||||
}
|
||||
return new Arr(builtins.Array, matches)
|
||||
}),
|
||||
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
|
||||
simple("repeat", 1, (value, args) => {
|
||||
const count = num("repeat", args, 0)
|
||||
if (!Number.isFinite(count) || count < 0) {
|
||||
throw rangeError("String.repeat expects a finite non-negative count.")
|
||||
}
|
||||
checkStringLength(value.length * count)
|
||||
return value.repeat(count)
|
||||
}),
|
||||
simple("padStart", 1, (value, args) => {
|
||||
const length = num("padStart", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padStart(length, optStr("padStart", args, 1))
|
||||
}),
|
||||
simple("padEnd", 1, (value, args) => {
|
||||
const length = num("padEnd", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padEnd(length, optStr("padEnd", args, 1))
|
||||
}),
|
||||
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0)),
|
||||
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0)),
|
||||
simple("substring", 2, (value, args) =>
|
||||
value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
|
||||
simple("match", 1, (value, args) =>
|
||||
withPattern(args, (arg) => {
|
||||
const regex = toHostRegex(arg, "match")
|
||||
const matched = value.match(regex)
|
||||
if (matched === null) return null
|
||||
// Preserve the own `index` and `groups` properties on non-global matches.
|
||||
if (regex.global) return new Arr(builtins.Array, [...matched])
|
||||
return matchToValue(builtins, matched)
|
||||
}),
|
||||
),
|
||||
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1))),
|
||||
simple("matchAll", 1, (value, args) =>
|
||||
withPattern(args, (arg) => {
|
||||
const regex = toHostRegex(arg, "matchAll", "g")
|
||||
if (!regex.global) {
|
||||
throw typeError(
|
||||
`String.matchAll requires a regular expression with the global (g) flag: write /${regex.source}/${regex.flags}g, or use String.match for a single match.`,
|
||||
)
|
||||
}
|
||||
const matches: Array<Value> = []
|
||||
for (const match of value.matchAll(regex)) {
|
||||
checkArrayLength(matches.length + 1)
|
||||
matches.push(matchToValue(builtins, match))
|
||||
}
|
||||
return new Arr(builtins.Array, matches)
|
||||
}),
|
||||
),
|
||||
simple("search", 1, (value, args) => withPattern(args, (arg) => value.search(toHostRegex(arg, "search")))),
|
||||
simple(
|
||||
"repeat",
|
||||
1,
|
||||
(value, args) => {
|
||||
const count = num("repeat", args, 0)
|
||||
if (!Number.isFinite(count) || count < 0) {
|
||||
throw rangeError("String.repeat expects a finite non-negative count.")
|
||||
}
|
||||
checkStringLength(value.length * count)
|
||||
return value.repeat(count)
|
||||
},
|
||||
["number"],
|
||||
),
|
||||
simple(
|
||||
"padStart",
|
||||
1,
|
||||
(value, args) => {
|
||||
const length = num("padStart", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padStart(length, optStr("padStart", args, 1))
|
||||
},
|
||||
["number", "string"],
|
||||
),
|
||||
simple(
|
||||
"padEnd",
|
||||
1,
|
||||
(value, args) => {
|
||||
const length = num("padEnd", args, 0)
|
||||
checkStringLength(length)
|
||||
return value.padEnd(length, optStr("padEnd", args, 1))
|
||||
},
|
||||
["number", "string"],
|
||||
),
|
||||
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0), ["number"]),
|
||||
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0), ["number"]),
|
||||
simple(
|
||||
"substring",
|
||||
2,
|
||||
(value, args) => value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
|
||||
["number", "number"],
|
||||
),
|
||||
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1)), [
|
||||
"number",
|
||||
"number",
|
||||
]),
|
||||
simple("isWellFormed", 0, (value) => value.isWellFormed()),
|
||||
simple("toWellFormed", 0, (value) => value.toWellFormed()),
|
||||
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0)),
|
||||
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0)),
|
||||
simple("concat", 1, (value, args) => {
|
||||
const joined = value.concat(...args.map((_, index) => str("concat", args, index)))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
}),
|
||||
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0), ["number"]),
|
||||
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0), ["number"]),
|
||||
simple("concat", 1, (value, args) =>
|
||||
withPrimitives(ctx, "string", args, (parts) => {
|
||||
const joined = value.concat(...parts.map((_, index) => str("concat", parts, index)))
|
||||
checkStringLength(joined.length)
|
||||
return joined
|
||||
}),
|
||||
),
|
||||
])
|
||||
define(
|
||||
builtins.String,
|
||||
|
||||
@@ -281,7 +281,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const target = self(thisValue, "forEach")
|
||||
const apply = applyCollectionCallback(ctx, args[0], "URLSearchParams.forEach")
|
||||
return Effect.gen(function* () {
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
|
||||
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target], args[1])
|
||||
return undefined
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { fn } from "../interpreter/native.js"
|
||||
import { coerceToNumber, coerceToString, type Native, type Value } from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { withPrimitives } from "../interpreter/callback.js"
|
||||
|
||||
export const compoundOperators = new Set(["+=", "-=", "*=", "/=", "%=", "**=", "&=", "|=", "^=", "<<=", ">>=", ">>>="])
|
||||
|
||||
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
|
||||
|
||||
const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Value => {
|
||||
export const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>) => {
|
||||
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
|
||||
// other coercers match native through the undefined-argument path below.
|
||||
if (args.length === 0) {
|
||||
@@ -14,15 +15,19 @@ const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<Value>): Val
|
||||
if (name === "String") return ""
|
||||
}
|
||||
const raw = args[0]
|
||||
if (name === "Number") return coerceToNumber(raw)
|
||||
if (name === "Boolean") return Boolean(raw)
|
||||
if (name === "isFinite") return Number.isFinite(coerceToNumber(raw))
|
||||
if (name === "isNaN") return Number.isNaN(coerceToNumber(raw))
|
||||
if (name === "parseInt") {
|
||||
return parseInt(coerceToString(raw), coerceToNumber(args[1]))
|
||||
return withPrimitives(ctx, ["string", "number"], [raw, args[1]], ([text, radix]) =>
|
||||
parseInt(coerceToString(text), coerceToNumber(radix)),
|
||||
)
|
||||
}
|
||||
if (name === "parseFloat") return parseFloat(coerceToString(raw))
|
||||
return coerceToString(raw)
|
||||
return withPrimitives(ctx, name === "String" || name === "parseFloat" ? "string" : "number", [raw], ([value]) => {
|
||||
if (name === "Number") return coerceToNumber(value)
|
||||
if (name === "isFinite") return Number.isFinite(coerceToNumber(value))
|
||||
if (name === "isNaN") return Number.isNaN(coerceToNumber(value))
|
||||
if (name === "parseFloat") return parseFloat(coerceToString(value))
|
||||
return coerceToString(value)
|
||||
})
|
||||
}
|
||||
|
||||
/** A global coercion function such as `Number` or `parseInt`. */
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
Obj,
|
||||
RegExpObj,
|
||||
SetObj,
|
||||
WeakMapObj,
|
||||
WeakSetObj,
|
||||
coerceToString,
|
||||
type Value,
|
||||
} from "../interpreter/objects.js"
|
||||
@@ -84,7 +86,9 @@ export const structuredCloneGlobal = <R>(ctx: Interpreter<R>) =>
|
||||
if (hasOwn(value, "cause")) define(copy, "cause", clone(getOwn(value, "cause")), hidden)
|
||||
return copy
|
||||
}
|
||||
if (isRuntimeReference(value)) throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
|
||||
if (isRuntimeReference(value) || value instanceof WeakMapObj || value instanceof WeakSetObj) {
|
||||
throw typeError(`DataCloneError: ${describeValue(value)} could not be cloned.`)
|
||||
}
|
||||
const copy = remember(
|
||||
value instanceof Arr ? new Arr(builtins.Array, new Array(value.items.length)) : new Obj(builtins.Object),
|
||||
)
|
||||
|
||||
@@ -1571,3 +1571,416 @@ describe("this, arguments, and Function.prototype.call/apply/bind", () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ToPrimitive: operators and conversions honor program valueOf and toString", () => {
|
||||
test("program-installed valueOf and toString on opaque values are ignored at every site", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const f = () => 1
|
||||
f.toString = () => "custom"
|
||||
f.valueOf = () => 5
|
||||
return [String(f), \`\${f}\`, [f].join(), new Error(f).message, isNaN(Number(f)), isNaN(Math.abs(f))]
|
||||
`),
|
||||
).toEqual(["[object Function]", "[object Function]", "[object Function]", "[object Function]", true, true])
|
||||
})
|
||||
|
||||
test("== converts an object facing a non-nullish primitive through its own valueOf", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const one = { valueOf() { return 1 } }
|
||||
return [one == 1, 1 == one, one == true, one == "1", one == null, one == one, one == { valueOf() { return 1 } }, [1] == 1]
|
||||
`),
|
||||
).toEqual([true, true, true, true, false, true, false, true])
|
||||
expect((await error(`(() => 1) == 1`)).message).toContain("Binary operators require data values")
|
||||
})
|
||||
|
||||
test("operators, unary, template literals, and conversion functions use the object's own methods", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const money = { valueOf() { return 7 } }
|
||||
return [money * 2, money + 1, money + "", -money, +money, ~money, money < 8, money ** 2, money | 8,
|
||||
Number(money), Math.max(money, 1), \`\${money}\`, String(money), isNaN(money), isFinite(money),
|
||||
parseInt({ toString() { return "42px" } }), parseInt("ff", { valueOf() { return 16 } }),
|
||||
Number.parseFloat({ toString() { return "1.5" } })]
|
||||
`),
|
||||
).toEqual([
|
||||
14,
|
||||
8,
|
||||
"7",
|
||||
-7,
|
||||
7,
|
||||
-8,
|
||||
true,
|
||||
49,
|
||||
15,
|
||||
7,
|
||||
7,
|
||||
"[object Object]",
|
||||
"[object Object]",
|
||||
false,
|
||||
true,
|
||||
42,
|
||||
255,
|
||||
1.5,
|
||||
])
|
||||
})
|
||||
|
||||
test("the hint picks the method: + and Number prefer valueOf, template literals and String prefer toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const both = { valueOf() { return 1 }, toString() { return "s" } }
|
||||
return [both + "", \`\${both}\`, String(both), both * 2, new Error(both).message, [both].join(), [both, 2] + ""]
|
||||
`),
|
||||
).toEqual(["1", "s", "s", 2, "s", "s", "s,2"])
|
||||
})
|
||||
|
||||
test("operands convert left then right, and a throwing valueOf surfaces as the program error", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const order = []
|
||||
const a = { valueOf() { order.push("a"); return 1 } }, b = { valueOf() { order.push("b"); return 2 } }
|
||||
a + b; a < b; a - b
|
||||
return order
|
||||
`),
|
||||
).toEqual(["a", "b", "a", "b", "a", "b"])
|
||||
expect(
|
||||
await value(`
|
||||
const bad = { valueOf() { throw new RangeError("nope") } }
|
||||
const names = []
|
||||
try { bad + 1 } catch (e) { names.push(e.name) }
|
||||
try { Number(bad) } catch (e) { names.push(e.name) }
|
||||
try { Math.abs(bad) } catch (e) { names.push(e.name) }
|
||||
return names
|
||||
`),
|
||||
).toEqual(["RangeError", "RangeError", "RangeError"])
|
||||
})
|
||||
|
||||
test("arrays keep their built-in join form unless the program replaces toString", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const arr = [1, 2]
|
||||
const before = [arr + "", [] + [], [1, , 3].join("-"), [1, { toString() { return "q" } }].join("-")]
|
||||
arr.toString = () => "x"
|
||||
return [...before, arr + "", \`\${arr}\`, String(arr)]
|
||||
`),
|
||||
).toEqual(["1,2", "", "1--3", "1-q", "x", "x", "x"])
|
||||
})
|
||||
|
||||
test("update and compound assignment convert the current value", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
let x = { valueOf() { return 5 } }
|
||||
const o = { n: { valueOf() { return 4 } } }
|
||||
const after = x++
|
||||
o.n += 1
|
||||
o.n++
|
||||
let s = { valueOf() { return 2 } }
|
||||
s *= 3
|
||||
return [after, x, o.n, s]
|
||||
`),
|
||||
).toEqual([5, 6, 6, 6])
|
||||
})
|
||||
|
||||
test("functions and other opaque values still reject arithmetic, and an object without a primitive form throws", async () => {
|
||||
expect((await error(`const f = () => 1; return f + 1`)).message).toContain("Binary operators require data values")
|
||||
expect((await error(`return -(() => 1)`)).message).toContain("Unary operators require data values")
|
||||
const failure = await error(`return { valueOf() { return {} }, toString() { return [] } } + 1`)
|
||||
expect(failure.message).toContain("Cannot convert object to primitive value")
|
||||
})
|
||||
})
|
||||
|
||||
describe("object destructuring from primitives", () => {
|
||||
test("reads through the primitive's prototype like member access", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const { length, 0: first, toUpperCase } = "abc"
|
||||
const { toFixed } = 1.5
|
||||
const {} = true
|
||||
const { 0: a, ...rest } = "xyz"
|
||||
const { ...none } = 42
|
||||
let n
|
||||
;({ length: n } = "hello")
|
||||
return [length, first, toUpperCase.call("q"), toFixed.call(2.345, 1), a, rest, none, n]
|
||||
`),
|
||||
).toEqual([3, "a", "Q", "2.3", "x", { 1: "y", 2: "z" }, {}, 5])
|
||||
})
|
||||
|
||||
test("only null and undefined sources throw", async () => {
|
||||
expect((await error(`const { a } = null`)).message).toContain("Cannot destructure null as it is null")
|
||||
expect((await error(`const {} = undefined`)).message).toContain("Cannot destructure undefined")
|
||||
expect((await error(`let a; ({ a } = undefined)`)).message).toContain("Cannot destructure undefined")
|
||||
})
|
||||
})
|
||||
|
||||
describe("Date components convert through ToPrimitive", () => {
|
||||
test("construction and Date.UTC ask each of the first seven arguments in order", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const seen = []
|
||||
const part = (n) => ({ valueOf() { seen.push(n); return n } })
|
||||
const time = new Date(part(2024), part(1), part(2), part(3), part(4), part(5), part(6), part(99)).getTime()
|
||||
const utc = Date.UTC(2024, { valueOf() { return 0 } }, 15)
|
||||
return [seen, time === new Date(2024, 1, 2, 3, 4, 5, 6).getTime(), utc === Date.UTC(2024, 0, 15)]
|
||||
`),
|
||||
).toEqual([[2024, 1, 2, 3, 4, 5, 6], true, true])
|
||||
expect((await error(`new Date(2024, { valueOf() { throw new RangeError("boom") } })`)).message).toContain("boom")
|
||||
})
|
||||
|
||||
test("setters on an invalid Date answer NaN without overwriting a time set during coercion", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const d = new Date(NaN)
|
||||
const result = d.setDate({ valueOf() { d.setTime(0); return 1 } })
|
||||
const y = new Date(NaN)
|
||||
return [Number.isNaN(result), d.getTime(), y.setFullYear(2020) === Date.UTC(2020, 0, 1) - y.getTimezoneOffset() * 60000]
|
||||
`),
|
||||
).toEqual([true, 0, true])
|
||||
})
|
||||
})
|
||||
|
||||
describe("iteration callbacks receive thisArg", () => {
|
||||
test("Array, Array.from, Map, Set, URLSearchParams, Headers, and Uint8Array pass it as this", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const c = { n: 0 }
|
||||
const count = function () { this.n++ }
|
||||
;[1, 2].forEach(count, c)
|
||||
;[1].map(count, c)
|
||||
;[1].filter(count, c)
|
||||
;[1].find(count, c)
|
||||
;[1].findIndex(count, c)
|
||||
;[1].findLast(count, c)
|
||||
;[1].findLastIndex(count, c)
|
||||
;[1].some(count, c)
|
||||
;[1].every(count, c)
|
||||
;[1].flatMap(count, c)
|
||||
Array.from([1], count, c)
|
||||
Array.from({ length: 1 }, count, c)
|
||||
new Map([[1, 1]]).forEach(count, c)
|
||||
new Set([1]).forEach(count, c)
|
||||
new URLSearchParams("a=1").forEach(count, c)
|
||||
new Headers({ a: "1" }).forEach(count, c)
|
||||
new Uint8Array([1]).forEach(count, c)
|
||||
return c.n
|
||||
`),
|
||||
).toBe(18)
|
||||
expect(await value(`return [1, 2].map(function (x) { return x + this.v }, { v: 10 })`)).toEqual([11, 12])
|
||||
})
|
||||
|
||||
test("arrows keep their lexical this, reduce takes an initial value instead, and opaque values are only bound", async () => {
|
||||
expect(await value(`return [1].map(() => typeof this, { v: 1 })`)).toEqual(["undefined"])
|
||||
expect(
|
||||
await value(`return [1, 2].reduce(function (a, b) { return a + b + (this === undefined ? 0 : 100) }, 0)`),
|
||||
).toBe(3)
|
||||
expect(
|
||||
await value(`
|
||||
let seen
|
||||
;[1].forEach(function () { seen = this }, tools.nowhere)
|
||||
return typeof seen
|
||||
`),
|
||||
).toBe("function")
|
||||
})
|
||||
})
|
||||
|
||||
describe("computed property keys convert through the object's own toString", () => {
|
||||
test("reads, writes, compound assignment, in, delete, literals, and destructuring share one conversion", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const key = { toString() { return "id" } }
|
||||
const o = {}
|
||||
o[key] = 1
|
||||
o[key] += 1
|
||||
const literal = { [key]: "lit" }
|
||||
const had = key in o
|
||||
delete literal[key]
|
||||
return [o.id, had, (({ [key]: v }) => v)(o), literal, o[[1, 2]] === undefined]
|
||||
`),
|
||||
).toEqual([2, true, 2, {}, true])
|
||||
expect(
|
||||
await value(`
|
||||
const seen = []
|
||||
const base = { x: 1 }
|
||||
base[{ toString() { seen.push(1); return "" } }] ^= 0
|
||||
base[{ toString() { seen.push(2); return "x" } }]++
|
||||
return [seen, base[""], base.x]
|
||||
`),
|
||||
).toEqual([[1, 2], 0, 2])
|
||||
})
|
||||
|
||||
test("valueOf is the fallback, a symbol result stays a symbol, and conversion failures surface", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = { 7: "seven" }
|
||||
const sym = { toString() { return Symbol.iterator } }
|
||||
o[sym] = 1
|
||||
return [o[{ valueOf() { return 7 }, toString: undefined }], typeof o[Symbol.iterator], Object.keys(o)]
|
||||
`),
|
||||
).toEqual(["seven", "number", ["7"]])
|
||||
expect((await error(`({})[{ toString() { throw new RangeError("bad key") } }]`)).message).toContain("bad key")
|
||||
expect((await error(`({})[{ toString() { return {} }, valueOf() { return {} } }]`)).message).toContain(
|
||||
"Cannot convert object to primitive value",
|
||||
)
|
||||
expect((await error(`const key = { toString() { return "a" } }; key in 5`)).message).toContain(
|
||||
"requires a data object on the right-hand side",
|
||||
)
|
||||
})
|
||||
|
||||
test("a nullish base throws before the key converts, as ToObject precedes ToPropertyKey", async () => {
|
||||
const failure = await error(`const base = null; base[{ toString() { throw new RangeError("key evaluated") } }]`)
|
||||
expect(failure.message).toContain("Cannot read properties of null")
|
||||
})
|
||||
|
||||
test("opaque values keep their built-in key form and a tool reference toString is never called", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const o = { "[object Function]": 1, "[object Promise]": 2 }
|
||||
return [o[() => 1], o[Promise.resolve("k")]]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
expect((await error(`({})[{ toString: tools.nowhere }] = 1`)).message).toContain(
|
||||
"Cannot convert object to primitive value",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("String and Number method arguments convert through ToPrimitive", () => {
|
||||
test("string positions use the string hint and numeric positions the number hint", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const s = { toString() { return "b" } }
|
||||
const n = { valueOf() { return 1 } }
|
||||
return [
|
||||
"abc".indexOf(s), "abc".lastIndexOf(s), "abc".includes(s), "abc".startsWith(s, n), "abc".endsWith(s, 2),
|
||||
"abc".charAt(n), "abc".at({ valueOf() { return -1 } }), "abc".slice(n), "abc".substring(n, 2),
|
||||
"abc".charCodeAt(n), "a".padStart({ valueOf() { return 3 } }, s), "x".padEnd(3, s), "ab".repeat({ valueOf() { return 2 } }),
|
||||
"a".concat(s, { valueOf() { return 1 }, toString() { return "T" } }), "b".localeCompare(s),
|
||||
(1.005).toFixed({ valueOf() { return 2 } }), (255).toString({ valueOf() { return 16 } }),
|
||||
(1234.5678).toPrecision({ valueOf() { return 6 } }), (12345).toExponential({ valueOf() { return 2 } }),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
1,
|
||||
1,
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
"b",
|
||||
"c",
|
||||
"bc",
|
||||
"b",
|
||||
98,
|
||||
"bba",
|
||||
"xbb",
|
||||
"abab",
|
||||
"abT",
|
||||
0,
|
||||
"1.00",
|
||||
"ff",
|
||||
"1234.57",
|
||||
"1.23e+4",
|
||||
])
|
||||
})
|
||||
|
||||
test("split, replace, match, and search convert a plain pattern but keep a RegExp as is", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const s = { toString() { return "b" } }
|
||||
return [
|
||||
"abc".split(s), "abc".split(/b/, { valueOf() { return 1 } }), "abc".split(undefined, { valueOf() { return undefined } }),
|
||||
"abc".replace(s, "X"), "abc".replace(/b/, { toString() { return "R" } }), "abc".replaceAll(s, s),
|
||||
"abc".replace(s, (m) => m.toUpperCase()), "abc".match(s)[0], "abcb".matchAll(s).length, "abc".search(s),
|
||||
]
|
||||
`),
|
||||
).toEqual([["a", "c"], ["a"], [], "aXc", "aRc", "abc", "aBc", "b", 2, 1])
|
||||
expect((await error(`"abc".includes(/b/)`)).message).toContain("cannot take a regular expression")
|
||||
})
|
||||
|
||||
test("the receiver converts first, then each consumed argument, in spec order; extra arguments are untouched", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const log = []
|
||||
const observer = (name, string, number) => ({
|
||||
toString() { log.push("toString:" + name); return string },
|
||||
valueOf() { log.push("valueOf:" + name); return number },
|
||||
})
|
||||
const padded = String.prototype.padStart.call(observer("receiver", {}, "abc"), observer("maxLength", 11, {}), observer("fillString", {}, "def"))
|
||||
const extra = "abc".indexOf("b", 1, { valueOf() { throw new Error("extra argument converted") } })
|
||||
return [padded, log, extra, String.prototype.trim.call({ toString() { return " abc " } })]
|
||||
`),
|
||||
).toEqual([
|
||||
"defdefdeabc",
|
||||
[
|
||||
"toString:receiver",
|
||||
"valueOf:receiver",
|
||||
"valueOf:maxLength",
|
||||
"toString:maxLength",
|
||||
"toString:fillString",
|
||||
"valueOf:fillString",
|
||||
],
|
||||
1,
|
||||
"abc",
|
||||
])
|
||||
})
|
||||
|
||||
test("conversion failures surface and opaque arguments still reject", async () => {
|
||||
expect((await error(`"abc".indexOf({ toString() { throw new RangeError("intostr") } })`)).message).toContain(
|
||||
"intostr",
|
||||
)
|
||||
expect((await error(`(1).toString({ valueOf() { throw new SyntaxError("poison") } })`)).message).toContain("poison")
|
||||
expect((await error(`(1).toFixed({ toString() { return {} }, valueOf() { return {} } })`)).message).toContain(
|
||||
"Cannot convert object to primitive value",
|
||||
)
|
||||
expect((await error(`"abc".indexOf(tools.nowhere)`)).message).toContain("expects argument 1 to be a data value")
|
||||
expect((await error(`"abc".indexOf(Promise.resolve("b"))`)).message).toContain(
|
||||
"expects argument 1 to be a data value",
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("WeakMap and WeakSet", () => {
|
||||
test("hold program objects by identity and answer like JS for non-object keys", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const k = {}
|
||||
const f = () => 1
|
||||
const wm = new WeakMap([[k, 1]])
|
||||
const ws = new WeakSet([k])
|
||||
return [
|
||||
wm.set(f, "fn") === wm, wm.get(k), wm.get(f), wm.has({}), wm.get(1), wm.has(1), wm.delete("s"),
|
||||
wm.getOrInsert(k, 9), wm.getOrInsertComputed({}, (key) => typeof key),
|
||||
ws.add(f) === ws, ws.has(k), ws.has(f), ws.has(1), ws.delete(k), ws.has(k),
|
||||
String(wm), wm.size, "clear" in wm, Symbol.iterator in ws, JSON.stringify(wm),
|
||||
]
|
||||
`),
|
||||
).toEqual([
|
||||
true,
|
||||
1,
|
||||
"fn",
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
1,
|
||||
"object",
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
"[object WeakMap]",
|
||||
null,
|
||||
false,
|
||||
false,
|
||||
"{}",
|
||||
])
|
||||
})
|
||||
|
||||
test("reject primitive keys, plain calls, bad receivers, and cloning", async () => {
|
||||
expect((await error(`new WeakMap().set(1, 1)`)).message).toContain("Invalid value used as weak map key")
|
||||
expect((await error(`new WeakSet([1])`)).message).toContain("Invalid value used in weak set")
|
||||
expect((await error(`WeakMap()`)).message).toContain("new")
|
||||
expect((await error(`WeakMap.prototype.get.call(new Map(), {})`)).message).toContain("incompatible receiver")
|
||||
expect((await error(`structuredClone(new WeakSet())`)).message).toContain("DataCloneError")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,7 +12,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
|
||||
|
||||
## Layout
|
||||
|
||||
- `manifest.json` — the pinned upstream revision, which upstream directories are copied, and what is left out.
|
||||
- `manifest.json` — the pinned upstream revision, which upstream directories are copied (every `built-ins` and
|
||||
`language` directory, about 14,900 files after filtering), and what is left out.
|
||||
- `built-ins/`, `language/` — the copied files, mirroring upstream `test/`; gitignored.
|
||||
- `skipped.txt` — vendored files that fail on a known interpreter gap, one `path # reason` per line. They are
|
||||
skipped, and each gap is listed as unchecked in `interpreter-support.md`.
|
||||
@@ -26,7 +27,8 @@ Without them the runner registers no tests, so CI is unaffected. Licensed under
|
||||
manifest marks unsupported, or when its code matches one of the manifest's `boundaries` patterns. The sync checks the
|
||||
checkout is at the pinned revision, so every machine runs the same files. Boundaries are
|
||||
intentional limits of the interpreter, not compatibility work: classes, prototype objects, property descriptors,
|
||||
accessors, boxed primitives, sloppy mode, `eval`, `Symbol()`, and the `$262` host API. If one
|
||||
accessors, boxed primitives, typed arrays and buffers, `WeakRef` and `FinalizationRegistry`, `Reflect` and `Proxy`, sloppy mode, `eval`,
|
||||
`Symbol()`, and the `$262` host API. If one
|
||||
of those decisions changes, delete its entry and re-sync; the tests are upstream, not lost.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -1,33 +1,16 @@
|
||||
{
|
||||
"revision": "250f204f23a9249ff204be2baec29600faae7b75",
|
||||
"directories": [
|
||||
"built-ins/Array/prototype",
|
||||
"built-ins/Function/prototype/apply",
|
||||
"built-ins/Function/prototype/bind",
|
||||
"built-ins/Function/prototype/call",
|
||||
"built-ins/Iterator",
|
||||
"built-ins/Object/freeze",
|
||||
"built-ins/Object/getPrototypeOf",
|
||||
"built-ins/Object/is",
|
||||
"built-ins/Object/isExtensible",
|
||||
"built-ins/Object/isFrozen",
|
||||
"built-ins/Object/isSealed",
|
||||
"built-ins/Object/preventExtensions",
|
||||
"built-ins/String/raw",
|
||||
"language/arguments-object",
|
||||
"language/expressions/does-not-equals",
|
||||
"language/expressions/equals",
|
||||
"language/expressions/tagged-template",
|
||||
"language/expressions/this",
|
||||
"language/statements"
|
||||
],
|
||||
"directories": ["built-ins", "language"],
|
||||
"harness": ["assert.js", "sta.js", "compareArray.js", "doneprintHandle.js"],
|
||||
"flags": ["module", "raw", "noStrict"],
|
||||
"boundaries": {
|
||||
"class": "\\bclass\\s*[A-Za-z_${]",
|
||||
"accessor properties": "\\b(get|set)\\s+[\\w$\\[][^\\n(]*\\(",
|
||||
"property descriptors": "Object\\.(defineProperty|defineProperties|getOwnPropertyDescriptors?|getOwnPropertyNames|setPrototypeOf)\\b",
|
||||
"boxed primitives": "\\bnew\\s+(String|Number|Boolean)\\s*\\(",
|
||||
"boxed primitives": "\\b(new\\s+(String|Number|Boolean)\\b|Object\\s*\\(\\s*(true|false|-?\\d|['\"]))",
|
||||
"typed arrays and buffers": "\\b(ArrayBuffer|SharedArrayBuffer|DataView|Int8Array|Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|Uint32Array|Float16Array|Float32Array|Float64Array|BigInt64Array|BigUint64Array)\\b",
|
||||
"weak collections": "\\b(WeakRef|FinalizationRegistry)\\b",
|
||||
"Reflect and Proxy": "\\b(Reflect|Proxy)\\b",
|
||||
"sloppy mode": "\\bwith\\s*\\(",
|
||||
"eval": "\\b(eval|Function)\\b",
|
||||
"new.target": "\\bnew\\.target\\b",
|
||||
@@ -91,8 +74,6 @@
|
||||
"Uint16Array",
|
||||
"Uint32Array",
|
||||
"uint8array-base64",
|
||||
"WeakMap",
|
||||
"WeakSet",
|
||||
"WeakRef",
|
||||
"FinalizationRegistry",
|
||||
"Intl-enumeration",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -354,3 +354,17 @@ describe("tools.search alias", () => {
|
||||
expect(await value(runtime, `return await tools.search({})`)).toBe("custom")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool references under ==", () => {
|
||||
test("compare by identity against data objects without converting them", async () => {
|
||||
const runtime = CodeMode.make({ tools: { probe: echo("Probe", "ok") } })
|
||||
expect(
|
||||
await value(
|
||||
runtime,
|
||||
`let calls = 0
|
||||
const o = { valueOf() { calls++; return 1 } }
|
||||
return [o == tools.probe, tools == { a: 1 }, tools.probe == null, calls]`,
|
||||
),
|
||||
).toEqual([false, false, false, 0])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "e98619ef-c334-45e3-8c48-9932ed1ef290",
|
||||
"prevIds": ["be60f352-8da1-40e1-8d70-dc41121cfbc5"],
|
||||
"id": "421b5a69-27cf-4f13-ba81-85503aa81323",
|
||||
"prevIds": ["e98619ef-c334-45e3-8c48-9932ed1ef290"],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "account_state",
|
||||
@@ -1110,6 +1110,16 @@
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "kind",
|
||||
"entityType": "columns",
|
||||
"table": "session_v2"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
|
||||
@@ -207,6 +207,7 @@ export function normalize(input: unknown): Result {
|
||||
username: Info.fields.username,
|
||||
snapshots: Info.fields.snapshots,
|
||||
media: Info.fields.media,
|
||||
voice: Info.fields.voice,
|
||||
tool_output: Info.fields.tool_output,
|
||||
websearch: Info.fields.websearch,
|
||||
worktree: Info.fields.worktree,
|
||||
|
||||
+2
@@ -47,6 +47,7 @@ import m44 from "./migration/20260819222447_session_viewed_state.js"
|
||||
import m45 from "./migration/20260823191254_nullable_workspace_binding.js"
|
||||
import m46 from "./migration/20260910120000_clear_v1_session_permission.js"
|
||||
import m47 from "./migration/20260923013825_project_time_active.js"
|
||||
import m48 from "./migration/20260925102936_session_kind.js"
|
||||
|
||||
export const migrations = [
|
||||
m00,
|
||||
@@ -97,4 +98,5 @@ export const migrations = [
|
||||
m45,
|
||||
m46,
|
||||
m47,
|
||||
m48,
|
||||
] satisfies DatabaseMigration.Migration[]
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Effect } from "effect"
|
||||
import type { DatabaseMigration } from "../migration.js"
|
||||
|
||||
const migration: DatabaseMigration.Migration = {
|
||||
id: "20260925102936_session_kind",
|
||||
up(tx) {
|
||||
return Effect.gen(function* () {
|
||||
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`kind\` text;`)
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
export default migration
|
||||
@@ -185,6 +185,7 @@ const schema: Omit<DatabaseMigration.Migration, "id"> = {
|
||||
\`project_id\` text NOT NULL,
|
||||
\`workspace_id\` text,
|
||||
\`parent_id\` text,
|
||||
\`kind\` text,
|
||||
\`fork_session_id\` text,
|
||||
\`fork_boundary\` text,
|
||||
\`slug\` text NOT NULL,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { LLMClient, RequestExecutor } from "@opencode/ai/route"
|
||||
import { SpeechClient } from "@opencode/ai/speech-client"
|
||||
import { TranscriptionClient } from "@opencode/ai/transcription-client"
|
||||
import { Socket } from "effect/unstable/socket"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { httpClient } from "@opencode/util/effect/app-node-platform"
|
||||
@@ -12,6 +14,18 @@ export const requestExecutor = makeGlobalNode({
|
||||
|
||||
export const llmClient = makeGlobalNode({ service: LLMClient.Service, layer: LLMClient.layer, deps: [requestExecutor] })
|
||||
|
||||
export const speechClient = makeGlobalNode({
|
||||
service: SpeechClient.Service,
|
||||
layer: SpeechClient.layer,
|
||||
deps: [requestExecutor],
|
||||
})
|
||||
|
||||
export const transcriptionClient = makeGlobalNode({
|
||||
service: TranscriptionClient.Service,
|
||||
layer: TranscriptionClient.layer,
|
||||
deps: [requestExecutor],
|
||||
})
|
||||
|
||||
export const webSocketConstructor = makeGlobalNode({
|
||||
service: Socket.WebSocketConstructor,
|
||||
layer: WebSocketConstructor.layer,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Formatter } from "./formatter.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemSearch } from "./filesystem/search.js"
|
||||
import { Generate } from "./generate.js"
|
||||
import { Voice } from "./voice.js"
|
||||
import { Form } from "./form.js"
|
||||
import { Image } from "./image.js"
|
||||
import { LocationWatcher } from "./filesystem/location-watcher.js"
|
||||
@@ -97,6 +98,7 @@ const nodes = [
|
||||
InstructionEntry.node,
|
||||
Form.node,
|
||||
Generate.node,
|
||||
Voice.node,
|
||||
ReadToolFileSystem.node,
|
||||
McpTool.node,
|
||||
SessionInstructions.node,
|
||||
|
||||
@@ -23,6 +23,20 @@ Guidelines:
|
||||
|
||||
Complete the user's search request efficiently and report your findings clearly.`
|
||||
|
||||
const PROMPT_COMPANION = `You are the companion of an OpenCode coding session, called the main session. The user talks with you, in text or by voice, about what the main session is doing while it keeps working.
|
||||
|
||||
Your job:
|
||||
- Answer questions about the main session: what it is doing, why, what it changed, and what it waits for.
|
||||
- Steer it when the user asks. Use main_send to pass instructions, corrections, or follow-up work. Write each prompt as a clear, self-contained instruction for the main session's agent, not as a transcript of the user's words.
|
||||
- Use main_interrupt only when the user wants the main session to stop.
|
||||
- Check things yourself with read, grep, and glob when that is faster than asking.
|
||||
- You may run read-only git commands with the shell tool: git status, git diff, git log, git show, and plain git branch. Run each one alone, without pipes, redirects, or --output.
|
||||
- You cannot edit files or run other commands. Ask the main session to do that with main_send.
|
||||
|
||||
Each user turn may start with a <main-session> snapshot. Trust it for the current status. Call main_read or main_status when you need more detail.
|
||||
|
||||
Your replies may be read aloud. Keep them short and conversational: a few sentences, no headings or tables, and code only when the user asks for it. When you steer the main session, say briefly what you sent.`
|
||||
|
||||
const PROMPT_TITLE = `You are a title generator. You output ONLY a thread title. Nothing else.
|
||||
|
||||
<task>
|
||||
@@ -130,6 +144,45 @@ export const Plugin = define({
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("companion"), (item) => {
|
||||
const externalDirectories = item.permissions.filter(
|
||||
(rule) => rule.action === "external_directory" && rule.effect === "allow",
|
||||
)
|
||||
item.name = Agent.Name.make("Companion")
|
||||
item.description = "Talks with the user about a main session and steers it."
|
||||
item.system = PROMPT_COMPANION
|
||||
item.mode = "primary"
|
||||
item.hidden = true
|
||||
// Clients show no permission prompts for companions, so every rule allows or denies.
|
||||
item.permissions.push(
|
||||
{ action: "*", resource: "*", effect: "deny" },
|
||||
{ action: "grep", resource: "*", effect: "allow" },
|
||||
{ action: "glob", resource: "*", effect: "allow" },
|
||||
{ action: "webfetch", resource: "*", effect: "allow" },
|
||||
{ action: "websearch", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*", effect: "allow" },
|
||||
{ action: "read", resource: "*.env", effect: "deny" },
|
||||
{ action: "read", resource: "*.env.*", effect: "deny" },
|
||||
{ action: "read", resource: "*.env.example", effect: "allow" },
|
||||
{ action: "main_*", resource: "*", effect: "allow" },
|
||||
...["status", "diff", "log", "show"].map((command) => ({
|
||||
action: "shell",
|
||||
resource: `git ${command} *`,
|
||||
effect: "allow" as const,
|
||||
})),
|
||||
// Exact forms only: `git branch <name>` creates a branch, even after -v.
|
||||
...["", " --show-current", " -a", " -r", " -v", " -vv"].map((flags) => ({
|
||||
action: "shell",
|
||||
resource: `git branch${flags}`,
|
||||
effect: "allow" as const,
|
||||
})),
|
||||
// Redirects and --output write files.
|
||||
{ action: "shell", resource: "*>*", effect: "deny" },
|
||||
{ action: "shell", resource: "*--output*", effect: "deny" },
|
||||
...externalDirectories,
|
||||
)
|
||||
})
|
||||
|
||||
editor.update(Agent.ID.make("compaction"), (item) => {
|
||||
item.name = Agent.Name.make("Compaction")
|
||||
item.mode = "primary"
|
||||
|
||||
@@ -79,6 +79,7 @@ import { ReadTool } from "../tool/plugin/read.js"
|
||||
import { ShellTool } from "../tool/plugin/shell.js"
|
||||
import { SkillTool } from "../tool/plugin/skill.js"
|
||||
import { SubagentTool } from "../tool/plugin/subagent.js"
|
||||
import { CompanionTools } from "../tool/plugin/companion.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { ToolOutput } from "../tool-output.js"
|
||||
import { WebFetchTool } from "../tool/plugin/webfetch.js"
|
||||
@@ -243,6 +244,7 @@ const pre = [
|
||||
ShellTool.Plugin,
|
||||
SkillTool.Plugin,
|
||||
SubagentTool.Plugin,
|
||||
CompanionTools.Plugin,
|
||||
WebFetchTool.Plugin,
|
||||
WebSearchTool.Plugin,
|
||||
WriteTool.Plugin,
|
||||
|
||||
@@ -17,7 +17,7 @@ import { SessionProjector } from "./session/projector.js"
|
||||
import { SessionMessageTable } from "./session/sql.js"
|
||||
import { SessionSchema } from "./session/schema.js"
|
||||
import { RelativePath } from "./schema.js"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { Agent } from "./agent.js"
|
||||
import type { Permission } from "@opencode/schema/permission"
|
||||
import { App } from "./app.js"
|
||||
import { Slug } from "./util/slug.js"
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
DestinationUnavailableError,
|
||||
} from "./session/move.js"
|
||||
import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { Plugin } from "./plugin/service.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
@@ -81,6 +82,7 @@ export type ListInput = SessionStore.ListInput
|
||||
|
||||
type CreateBaseInput = {
|
||||
id?: SessionSchema.ID
|
||||
kind?: SessionSchema.Kind
|
||||
title?: string
|
||||
agent?: Agent.ID
|
||||
model?: Model.Ref
|
||||
@@ -119,6 +121,8 @@ export interface Interface {
|
||||
readonly data: SessionSchema.Info[]
|
||||
}>
|
||||
readonly create: (input: CreateInput) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
/** The main Session's companion, created on first use. A companion is its own companion. */
|
||||
readonly companion: (sessionID: SessionSchema.ID) => Effect.Effect<SessionSchema.Info, NotFoundError>
|
||||
readonly fork: (
|
||||
input: ForkInput,
|
||||
) => Effect.Effect<SessionSchema.Info, NotFoundError | MessageNotFoundError | ForkEmptyError>
|
||||
@@ -266,6 +270,7 @@ const layer = Layer.effect(
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
parentID: input.parentID,
|
||||
kind: input.kind,
|
||||
location,
|
||||
subpath: RelativePath.make(path.relative(project.directory, location.directory).replaceAll("\\", "/")),
|
||||
title: input.title,
|
||||
@@ -304,6 +309,25 @@ const layer = Layer.effect(
|
||||
// TODO: Restore recorded sessions onto replacement synchronized workspaces in a future API slice.
|
||||
return yield* result.get(sessionID).pipe(Effect.orDie)
|
||||
}),
|
||||
companion: Effect.fn("Session.companion")(function* (sessionID) {
|
||||
const main = yield* result.get(sessionID)
|
||||
if (main.kind === "companion") return main
|
||||
const existing = (yield* store.list({ parentID: sessionID })).find((child) => child.kind === "companion")
|
||||
if (existing) return existing
|
||||
const agent = yield* Plugin.awaitActivation.pipe(
|
||||
Effect.andThen(Agent.Service.use((agents) => agents.get(Agent.ID.make("companion")))),
|
||||
instances.provide(main),
|
||||
)
|
||||
return yield* result.create({
|
||||
parentID: sessionID,
|
||||
kind: "companion",
|
||||
title: "Companion",
|
||||
agent: Agent.ID.make("companion"),
|
||||
model: agent?.model ?? main.model,
|
||||
// Main-session approvals must not widen what the companion may do.
|
||||
permissions: [],
|
||||
})
|
||||
}),
|
||||
fork: Effect.fn("Session.fork")(function* (input) {
|
||||
const parent = yield* result.get(input.sessionID)
|
||||
const boundary = yield* db
|
||||
|
||||
@@ -19,6 +19,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
|
||||
projectID: Project.ID.make(row.project_id),
|
||||
title: row.title ?? undefined,
|
||||
parentID: row.parent_id ? SessionSchema.ID.make(row.parent_id) : undefined,
|
||||
kind: row.kind ?? undefined,
|
||||
fork:
|
||||
row.fork_session_id && row.fork_boundary
|
||||
? {
|
||||
|
||||
@@ -444,6 +444,7 @@ const layer = Layer.effectDiscard(
|
||||
project_id: event.data.projectID,
|
||||
workspace_id: event.data.location.workspaceID ? Workspace.ID.make(event.data.location.workspaceID) : null,
|
||||
parent_id: event.data.parentID,
|
||||
kind: event.data.kind,
|
||||
slug: event.data.slug,
|
||||
directory: event.data.location.directory,
|
||||
path: event.data.subpath,
|
||||
|
||||
@@ -29,6 +29,7 @@ export const SessionTable = sqliteTable(
|
||||
.references(() => ProjectTable.id, { onDelete: "cascade" }),
|
||||
workspace_id: text().$type<Workspace.ID>(),
|
||||
parent_id: text().$type<SessionSchema.ID>(),
|
||||
kind: text().$type<Session.Kind>(),
|
||||
fork_session_id: text().$type<SessionSchema.ID>(),
|
||||
fork_boundary: text({ mode: "json" }).$type<Session.ForkBoundary>(),
|
||||
slug: text().notNull(),
|
||||
|
||||
@@ -92,6 +92,7 @@ const layer = Layer.effect(
|
||||
{
|
||||
sessionID,
|
||||
parentID: input.data.info.parentID,
|
||||
kind: input.data.info.kind,
|
||||
slug: Slug.create(),
|
||||
version: app.version,
|
||||
projectID: project.id,
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
export * as CompanionTools from "./companion.js"
|
||||
|
||||
import { Message, ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Agent } from "@opencode/schema/agent"
|
||||
import { SessionInbox } from "@opencode/schema/session-inbox"
|
||||
import { SessionMessage } from "@opencode/schema/session-message"
|
||||
import { Tool } from "@opencode/schema/tool"
|
||||
import { Effect, Predicate, Schema } from "effect"
|
||||
import { Permission } from "../../permission.js"
|
||||
import { Session } from "../../session.js"
|
||||
import { SessionSchema } from "../../session/schema.js"
|
||||
import { ShellTool } from "./shell.js"
|
||||
|
||||
export const agent = Agent.ID.make("companion")
|
||||
|
||||
const names = ["main_status", "main_read", "main_send", "main_cancel", "main_interrupt"]
|
||||
|
||||
// User config rules apply after the companion's own, but never beyond these actions.
|
||||
const actions = new Set(["read", "grep", "glob", "webfetch", "websearch", "shell", "external_directory", ...names])
|
||||
|
||||
// One read-only git command with no shell operators. The shell parser also drops redirects that
|
||||
// follow `&&` or `||` from permission resources, so the companion's rules alone cannot enforce this.
|
||||
const readOnlyGit = /^git (?:(?:status|diff|log|show)(?: [^;&|<>$`\n]*)?|branch(?: --show-current| -a| -r| -vv?)?)$/
|
||||
|
||||
const ReadInput = Schema.Struct({
|
||||
limit: Schema.optionalKey(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 50 }))).annotate({
|
||||
description: "Number of most recent messages to return. Defaults to 12.",
|
||||
}),
|
||||
})
|
||||
|
||||
const SendInput = Schema.Struct({
|
||||
text: Schema.String.check(Schema.isMinLength(1)).annotate({
|
||||
description: "A clear, self-contained instruction for the main session's agent.",
|
||||
}),
|
||||
delivery: Schema.optionalKey(SessionInbox.Delivery).annotate({
|
||||
description:
|
||||
"steer (default) delivers at the main session's next safe boundary, or starts it when idle. queue waits until the main session finishes its current work.",
|
||||
}),
|
||||
})
|
||||
|
||||
const CancelInput = Schema.Struct({
|
||||
inboxID: SessionMessage.ID.annotate({ description: "ID of a pending inbox item from main_status." }),
|
||||
})
|
||||
|
||||
const InterruptInput = Schema.Struct({
|
||||
resume: Schema.optionalKey(Schema.Boolean).annotate({
|
||||
description: "Resume pending steering prompts after the interrupt. Defaults to false.",
|
||||
}),
|
||||
})
|
||||
|
||||
/** Tools that let a companion observe and steer its main Session. Only companion Sessions may call them. */
|
||||
export const Plugin = {
|
||||
id: "opencode.tool.companion",
|
||||
effect: Effect.fn("CompanionTools.Plugin")(function* (ctx: Context) {
|
||||
const sessions = yield* Session.Service
|
||||
const permission = yield* Permission.Service
|
||||
|
||||
const mainOf = Effect.fn("CompanionTools.mainOf")(function* (sessionID: SessionSchema.ID) {
|
||||
const self = yield* sessions
|
||||
.get(sessionID)
|
||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: `Session not found: ${sessionID}`, error })))
|
||||
if (self.kind !== "companion" || !self.parentID)
|
||||
return yield* new ToolFailure({ message: "Only a companion session can use this tool" })
|
||||
return yield* sessions
|
||||
.get(self.parentID)
|
||||
.pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: `Main session not found: ${self.parentID}`, error })),
|
||||
)
|
||||
})
|
||||
|
||||
const status = Effect.fn("CompanionTools.status")(function* (main: SessionSchema.Info) {
|
||||
const [active, inbox, permissions] = yield* Effect.all(
|
||||
[sessions.active, sessions.inbox(main.id).pipe(Effect.orElseSucceed(() => [])), permission.forSession(main.id)],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const pending = inbox.flatMap((item) =>
|
||||
item.type === "user" || item.type === "synthetic"
|
||||
? [`- ${item.id} [${item.delivery}] ${clip(item.payload.text, 200)}`]
|
||||
: [],
|
||||
)
|
||||
const asked = permissions.map(
|
||||
(request) => `- ${request.action}: ${clip(request.message ?? request.resources.join(", "), 200)}`,
|
||||
)
|
||||
return [
|
||||
`Main session: ${main.title ?? "Untitled"} (${main.id})`,
|
||||
`Status: ${active.has(main.id) ? "running" : "idle"}${main.outcome ? ` (last run ${main.outcome})` : ""}`,
|
||||
`Agent: ${main.agent ?? "default"}${main.model ? ` · Model: ${main.model.providerID}/${main.model.id}` : ""}`,
|
||||
pending.length > 0 ? ["Pending inbox:", ...pending].join("\n") : "Pending inbox: none",
|
||||
asked.length > 0 ? ["Waiting for user permission:", ...asked].join("\n") : undefined,
|
||||
]
|
||||
.filter((line) => line !== undefined)
|
||||
.join("\n")
|
||||
})
|
||||
|
||||
const recent = Effect.fn("CompanionTools.recent")(function* (main: SessionSchema.Info, limit: number) {
|
||||
const messages = yield* sessions
|
||||
.messages({ sessionID: main.id, order: "desc", limit })
|
||||
.pipe(Effect.mapError((error) => new ToolFailure({ message: "Unable to read the main session", error })))
|
||||
return messages.toReversed().flatMap(describe)
|
||||
})
|
||||
|
||||
yield* ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.namespace({
|
||||
name: "main",
|
||||
description: "Observe and steer the main session this companion is attached to.",
|
||||
})
|
||||
draft.add({
|
||||
name: "status",
|
||||
description:
|
||||
"Report whether the main session is running, which prompts are waiting in its inbox, and whether it waits for a permission decision.",
|
||||
input: Schema.Struct({}),
|
||||
options: { namespace: "main", codemode: false },
|
||||
execute: (_input, context) =>
|
||||
mainOf(context.sessionID).pipe(
|
||||
Effect.flatMap(status),
|
||||
Effect.map((content) => ({ content })),
|
||||
),
|
||||
})
|
||||
draft.add({
|
||||
name: "read",
|
||||
description:
|
||||
"Read the main session's most recent messages as compact text, including the step that is still running.",
|
||||
input: ReadInput,
|
||||
options: { namespace: "main", codemode: false },
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const main = yield* mainOf(context.sessionID)
|
||||
const lines = yield* recent(main, input.limit ?? 12)
|
||||
return { content: lines.length > 0 ? lines.join("\n\n") : "The main session has no messages yet." }
|
||||
}),
|
||||
})
|
||||
draft.add({
|
||||
name: "send",
|
||||
description:
|
||||
"Send a prompt to the main session's agent. Use it to steer, correct, or give the main session follow-up work. The user sees the prompt in the main session.",
|
||||
input: SendInput,
|
||||
options: { namespace: "main", codemode: false },
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const main = yield* mainOf(context.sessionID)
|
||||
const delivery = input.delivery ?? "steer"
|
||||
const item = yield* sessions
|
||||
.prompt({
|
||||
sessionID: main.id,
|
||||
text: input.text,
|
||||
delivery,
|
||||
metadata: { source: "companion", companionID: context.sessionID },
|
||||
})
|
||||
.pipe(
|
||||
Effect.mapError((error) => new ToolFailure({ message: "Unable to prompt the main session", error })),
|
||||
)
|
||||
return {
|
||||
content: `Sent to the main session as ${delivery} (inbox item ${item.id}).`,
|
||||
metadata: { inboxID: item.id, delivery },
|
||||
}
|
||||
}),
|
||||
})
|
||||
draft.add({
|
||||
name: "cancel",
|
||||
description: "Cancel a prompt that is still waiting in the main session's inbox.",
|
||||
input: CancelInput,
|
||||
options: { namespace: "main", codemode: false },
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const main = yield* mainOf(context.sessionID)
|
||||
yield* sessions
|
||||
.cancelInbox({ sessionID: main.id, inboxID: input.inboxID })
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
(error) => new ToolFailure({ message: `Unable to cancel inbox item ${input.inboxID}`, error }),
|
||||
),
|
||||
)
|
||||
return { content: `Cancelled inbox item ${input.inboxID}.` }
|
||||
}),
|
||||
})
|
||||
draft.add({
|
||||
name: "interrupt",
|
||||
description:
|
||||
"Stop the main session's current work. Use it only when the user wants the main session to stop.",
|
||||
input: InterruptInput,
|
||||
options: { namespace: "main", codemode: false },
|
||||
execute: (input, context) =>
|
||||
Effect.gen(function* () {
|
||||
const main = yield* mainOf(context.sessionID)
|
||||
const interrupted = yield* sessions.interrupt(main.id, { resume: input.resume === true })
|
||||
return {
|
||||
content: interrupted ? "Interrupted the main session." : "The main session was already idle.",
|
||||
metadata: { interrupted },
|
||||
}
|
||||
}),
|
||||
})
|
||||
})
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
// No client shows companion permission prompts, so asks become denials too.
|
||||
yield* ctx.permission.hook("evaluate", (event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.agent !== agent) return
|
||||
if (!actions.has(event.action)) {
|
||||
event.effect = "deny"
|
||||
event.message = `The companion cannot use ${event.action}`
|
||||
return
|
||||
}
|
||||
if (event.effect !== "ask") return
|
||||
event.effect = "deny"
|
||||
event.message = "The companion cannot ask for permission"
|
||||
}),
|
||||
)
|
||||
|
||||
yield* ctx.tool.hook("execute.before", (event) => {
|
||||
if (event.agent !== agent || event.tool !== ShellTool.name || !Predicate.isObject(event.input)) return Effect.void
|
||||
const command = typeof event.input.command === "string" ? event.input.command.trim() : ""
|
||||
if (readOnlyGit.test(command) && !command.includes("--output")) return Effect.void
|
||||
return Effect.fail(
|
||||
new Tool.Error({ message: "The companion can only run one read-only git command, without shell operators" }),
|
||||
)
|
||||
})
|
||||
|
||||
// Companion tools stay out of every other agent's catalog.
|
||||
const hide = (event: SessionHooks["context"]) =>
|
||||
Effect.sync(() => {
|
||||
if (event.agent === agent) return
|
||||
names.forEach((name) => delete event.tools[name])
|
||||
})
|
||||
yield* ctx.session.hook("compaction", hide)
|
||||
yield* ctx.session.hook("generate", hide)
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
Effect.gen(function* () {
|
||||
yield* hide(event)
|
||||
if (event.agent !== agent || event.messages.at(-1)?.role !== "user") return
|
||||
const self = yield* sessions.get(event.sessionID).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (self?.kind !== "companion" || !self.parentID) return
|
||||
const main = yield* sessions.get(self.parentID).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!main) return
|
||||
const digest = yield* Effect.all([status(main), recent(main, 6)]).pipe(Effect.orElseSucceed(() => undefined))
|
||||
if (!digest) return
|
||||
// Unpersisted and placed before the newest prompt, so earlier turns keep their cached prefix.
|
||||
event.messages.splice(
|
||||
event.messages.length - 1,
|
||||
0,
|
||||
Message.user(
|
||||
[
|
||||
"<main-session>",
|
||||
digest[0],
|
||||
"",
|
||||
"Recent activity:",
|
||||
...digest[1].map((line) => clip(line, 600)),
|
||||
"</main-session>",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
}
|
||||
|
||||
function describe(message: SessionMessage.Info): string[] {
|
||||
if (message.type === "user")
|
||||
return [`[user${message.metadata?.source === "companion" ? " via companion" : ""}] ${clip(message.text, 1500)}`]
|
||||
if (message.type === "synthetic") return [`[note] ${clip(message.text, 600)}`]
|
||||
if (message.type === "shell") return [`[shell ${message.status}] ${clip(message.command, 300)}`]
|
||||
if (message.type === "compaction") return [`[compaction ${message.status}]`]
|
||||
if (message.type === "idle") return [`[main session went idle: ${message.outcome}]`]
|
||||
if (message.type === "agent-switched") return [`[switched to agent ${message.agent}]`]
|
||||
if (message.type !== "assistant") return []
|
||||
const content = message.content.flatMap((part) => {
|
||||
if (part.type === "text") return part.text.trim() ? [`[assistant] ${clip(part.text, 1500)}`] : []
|
||||
if (part.type === "reasoning") return []
|
||||
const input = part.state.status === "streaming" ? part.state.input : JSON.stringify(part.state.input)
|
||||
const result =
|
||||
part.state.status === "completed" || part.state.status === "error"
|
||||
? (part.state.content ?? []).flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n")
|
||||
: ""
|
||||
return [`[tool ${part.name} ${part.state.status}] ${clip(input, 200)}${result ? `\n→ ${clip(result, 300)}` : ""}`]
|
||||
})
|
||||
if (message.error) content.push(`[error] ${clip(message.error.message, 300)}`)
|
||||
return content
|
||||
}
|
||||
|
||||
function clip(text: string, limit: number) {
|
||||
const flat = text.trim()
|
||||
if (flat.length <= limit) return flat
|
||||
return `${flat.slice(0, limit)}…`
|
||||
}
|
||||
@@ -165,6 +165,8 @@ export const Plugin = {
|
||||
return yield* new ToolFailure({
|
||||
message: `Session ${existing.id} is not a child of the current session`,
|
||||
})
|
||||
if (existing?.kind === "companion")
|
||||
return yield* new ToolFailure({ message: `Session ${existing.id} is a companion, not a subagent` })
|
||||
const override = input.model === undefined ? undefined : yield* resolveModel(input.model)
|
||||
// Continuing with a different agent switches the child, mirroring create semantics
|
||||
// where an explicit model wins over the agent's configured model, which wins over the inherited one.
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
export * as Voice from "./voice.js"
|
||||
|
||||
import {
|
||||
Media,
|
||||
Speech,
|
||||
SpeechEvent,
|
||||
Transcription,
|
||||
type AIError,
|
||||
type SpeechModel,
|
||||
type TranscriptionModel,
|
||||
} from "@opencode/ai"
|
||||
import { SpeechClient } from "@opencode/ai/speech-client"
|
||||
import { TranscriptionClient } from "@opencode/ai/transcription-client"
|
||||
import type { ConfigModel } from "@opencode/schema/config/model"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Context, Effect, Layer, Schema, Stream } from "effect"
|
||||
import { Config } from "./config.js"
|
||||
import { speechClient, transcriptionClient } from "./effect/app-node-platform.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { Plugin } from "./plugin.js"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
export class UnavailableError extends Schema.TaggedError<UnavailableError>()("Voice.UnavailableError", {
|
||||
message: Schema.String,
|
||||
service: Schema.optional(Schema.String),
|
||||
}) {}
|
||||
|
||||
/** How `audio` chunks decode. Raw PCM is interleaved little-endian 16-bit samples. */
|
||||
export type Format =
|
||||
| { readonly type: "mp3" }
|
||||
| { readonly type: "pcm"; readonly sampleRate: number; readonly channels: number }
|
||||
|
||||
export type SpeechChunk =
|
||||
| { readonly type: "format"; readonly format: Format }
|
||||
| { readonly type: "audio"; readonly chunk: Uint8Array }
|
||||
|
||||
export interface Interface {
|
||||
readonly transcribe: (input: {
|
||||
readonly audio: Uint8Array
|
||||
readonly mediaType: string
|
||||
}) => Effect.Effect<string, UnavailableError>
|
||||
/** Fails before streaming when voice is not configured. The stream starts with one `format` chunk. */
|
||||
readonly speak: (input: {
|
||||
readonly text: string
|
||||
}) => Effect.Effect<Stream.Stream<SpeechChunk, UnavailableError>, UnavailableError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Voice") {}
|
||||
|
||||
type Facade = {
|
||||
readonly speech?: (modelID: string) => SpeechModel
|
||||
readonly transcription?: (modelID: string) => TranscriptionModel
|
||||
}
|
||||
|
||||
type Settings = { readonly apiKey?: string; readonly baseURL?: string }
|
||||
|
||||
// Speech and transcription models are not in the model catalog, so each provider package maps to its facade directly.
|
||||
const facades: Record<string, () => Promise<{ readonly configure: (settings: Settings) => Facade }>> = {
|
||||
"@opencode/ai/providers/openai": () => import("@opencode/ai/providers/openai"),
|
||||
"@opencode/ai/providers/google": () => import("@opencode/ai/providers/google"),
|
||||
"@opencode/ai/providers/xai": () => import("@opencode/ai/providers/xai"),
|
||||
"@opencode/ai/providers/elevenlabs": () => import("@opencode/ai/providers/elevenlabs"),
|
||||
"@opencode/ai/providers/deepgram": () => import("@opencode/ai/providers/deepgram"),
|
||||
"@opencode/ai/providers/assemblyai": () => import("@opencode/ai/providers/assemblyai"),
|
||||
}
|
||||
|
||||
// Gemini speech is PCM only; every other supported provider streams MP3.
|
||||
const pcm: Record<string, Format> = {
|
||||
"@opencode/ai/providers/google": { type: "pcm", sampleRate: 24000, channels: 1 },
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const config = yield* Config.Service
|
||||
const providers = yield* Provider.Service
|
||||
const integrations = yield* Integration.Service
|
||||
const speech = yield* SpeechClient.Service
|
||||
const transcription = yield* TranscriptionClient.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
|
||||
const facade = Effect.fn("Voice.facade")(function* (selection: ConfigModel.Selection) {
|
||||
// Config providers register during plugin activation, which may still be running on a fresh Location.
|
||||
yield* plugins.awaitActivation
|
||||
const provider = yield* providers.get(selection.providerID)
|
||||
const connection = yield* integrations.connection.active(
|
||||
provider?.integrationID ?? Integration.ID.make(selection.providerID),
|
||||
)
|
||||
const credential = connection ? yield* integrations.connection.resolve(connection) : undefined
|
||||
const specifier = provider?.package || `@opencode/ai/providers/${selection.providerID}`
|
||||
const load = facades[specifier]
|
||||
if (!load)
|
||||
return yield* new UnavailableError({
|
||||
message: `Provider ${selection.providerID} has no speech or transcription support`,
|
||||
service: selection.providerID,
|
||||
})
|
||||
// OAuth logins target chat backends that do not serve audio endpoints; fall back to API-key environment variables.
|
||||
const settings: Settings =
|
||||
credential?.type === "oauth"
|
||||
? {}
|
||||
: {
|
||||
apiKey: credential?.key ?? stringSetting(provider?.settings?.apiKey),
|
||||
baseURL: stringSetting(provider?.settings?.baseURL),
|
||||
}
|
||||
const { configure } = yield* Effect.promise(load)
|
||||
return { specifier, facade: configure(settings) }
|
||||
})
|
||||
|
||||
const selected = Effect.fn("Voice.selected")(function* <Key extends "transcription" | "speech">(key: Key) {
|
||||
const voice = Config.latest(yield* config.entries(), "voice")
|
||||
const selection = voice?.[key]
|
||||
if (!selection)
|
||||
return yield* new UnavailableError({
|
||||
message: `Configure voice.${key}.model to use ${key === "speech" ? "text-to-speech" : "speech-to-text"}`,
|
||||
})
|
||||
return selection
|
||||
})
|
||||
|
||||
const unavailable = (providerID: string) => (error: AIError) =>
|
||||
new UnavailableError({ message: describe(error), service: providerID })
|
||||
|
||||
const transcribe: Interface["transcribe"] = Effect.fn("Voice.transcribe")(
|
||||
function* (input) {
|
||||
const selection = yield* selected("transcription")
|
||||
const loaded = yield* facade(selection.model)
|
||||
if (!loaded.facade.transcription)
|
||||
return yield* new UnavailableError({
|
||||
message: `Provider ${selection.model.providerID} does not support transcription`,
|
||||
service: selection.model.providerID,
|
||||
})
|
||||
const response = yield* Transcription.generate({
|
||||
model: loaded.facade.transcription(selection.model.model),
|
||||
audio: Media.bytes(input.audio, input.mediaType),
|
||||
language: selection.language,
|
||||
}).pipe(
|
||||
Effect.provideService(TranscriptionClient.Service, transcription),
|
||||
Effect.mapError(unavailable(selection.model.providerID)),
|
||||
)
|
||||
return response.text.trim()
|
||||
},
|
||||
Effect.catchTag("Integration.Authorization", () => Effect.fail(credentialsUnavailable)),
|
||||
)
|
||||
|
||||
const speak: Interface["speak"] = Effect.fn("Voice.speak")(
|
||||
function* (input) {
|
||||
const selection = yield* selected("speech")
|
||||
const loaded = yield* facade(selection.model)
|
||||
if (!loaded.facade.speech)
|
||||
return yield* new UnavailableError({
|
||||
message: `Provider ${selection.model.providerID} does not support speech`,
|
||||
service: selection.model.providerID,
|
||||
})
|
||||
const format = pcm[loaded.specifier] ?? { type: "mp3" as const }
|
||||
const audio = Speech.stream({
|
||||
model: loaded.facade.speech(selection.model.model),
|
||||
text: input.text,
|
||||
voice: selection.voice,
|
||||
format: format.type,
|
||||
speed: selection.speed,
|
||||
language: selection.language,
|
||||
instructions: selection.instructions,
|
||||
}).pipe(
|
||||
Stream.provideService(SpeechClient.Service, speech),
|
||||
Stream.filter(SpeechEvent.is.audioDelta),
|
||||
Stream.map((event): SpeechChunk => ({ type: "audio", chunk: event.chunk })),
|
||||
Stream.mapError(unavailable(selection.model.providerID)),
|
||||
)
|
||||
return Stream.succeed<SpeechChunk>({ type: "format", format }).pipe(Stream.concat(audio))
|
||||
},
|
||||
Effect.catchTag("Integration.Authorization", () => Effect.fail(credentialsUnavailable)),
|
||||
)
|
||||
|
||||
return Service.of({ transcribe, speak })
|
||||
}),
|
||||
)
|
||||
|
||||
const credentialsUnavailable = new UnavailableError({ message: "Voice provider credentials are unavailable" })
|
||||
|
||||
// Unrecognized provider error bodies leave only "HTTP 403" in the message; the body usually says why.
|
||||
function describe(error: AIError) {
|
||||
const body = error.reason.body?.trim()
|
||||
if (!body || body.includes(error.message)) return error.message
|
||||
return `${error.message}: ${body.slice(0, 500)}`
|
||||
}
|
||||
|
||||
function stringSetting(value: unknown) {
|
||||
return typeof value === "string" && value !== "" ? value : undefined
|
||||
}
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, Provider.node, Integration.node, Plugin.node, speechClient, transcriptionClient],
|
||||
})
|
||||
@@ -180,11 +180,42 @@ describe("Agent", () => {
|
||||
expect(agents.map((item) => String(item.id)).sort()).toEqual([
|
||||
"build",
|
||||
"compaction",
|
||||
"companion",
|
||||
"explore",
|
||||
"general",
|
||||
"summary",
|
||||
"title",
|
||||
])
|
||||
const companion = (yield* agent.get(Agent.ID.make("companion")))?.permissions ?? []
|
||||
expect(Permission.evaluate("main_send", "*", companion).effect).toBe("allow")
|
||||
expect(Permission.evaluate("read", "src/index.ts", companion).effect).toBe("allow")
|
||||
expect(Permission.evaluate("read", ".env", companion).effect).toBe("deny")
|
||||
expect(Permission.evaluate("edit", "src/index.ts", companion).effect).toBe("deny")
|
||||
expect(Permission.evaluate("shell", "ls", companion).effect).toBe("deny")
|
||||
const shell = (commands: string[]) =>
|
||||
Object.fromEntries(
|
||||
commands.map((command) => [command, Permission.evaluate("shell", command, companion).effect]),
|
||||
)
|
||||
const readOnly = [
|
||||
"git status",
|
||||
"git diff --stat HEAD~1",
|
||||
"git log --oneline -5",
|
||||
"git show HEAD",
|
||||
"git branch -v",
|
||||
]
|
||||
expect(shell(readOnly)).toEqual(Object.fromEntries(readOnly.map((command) => [command, "allow"])))
|
||||
const writes = [
|
||||
"git commit -m x",
|
||||
"git checkout main",
|
||||
"git branch feature",
|
||||
"git branch -v feature",
|
||||
"git branch -D main",
|
||||
"git diff > patch.txt",
|
||||
"git log --output=log.txt",
|
||||
"git -c core.pager=sh log",
|
||||
"git statusx",
|
||||
]
|
||||
expect(shell(writes)).toEqual(Object.fromEntries(writes.map((command) => [command, "deny"])))
|
||||
expect((yield* agent.get(Agent.defaultID))?.system).toBeUndefined()
|
||||
const permissions = (yield* agent.get(Agent.defaultID))?.permissions ?? []
|
||||
const compaction = yield* agent.get(Agent.ID.make("compaction"))
|
||||
|
||||
@@ -70,6 +70,17 @@ describe("ConfigNormalize", () => {
|
||||
expect(result.agents?.reviewer?.system).toBe("Use V2")
|
||||
})
|
||||
|
||||
test("keeps voice model selections", () => {
|
||||
const result = normalized({
|
||||
voice: { transcription: { model: "xai/grok-voice-transcribe-2.0" }, speech: { model: "google/tts" } },
|
||||
})
|
||||
expect(result.encoded.voice).toEqual({
|
||||
transcription: { model: { providerID: "xai", model: "grok-voice-transcribe-2.0" } },
|
||||
speech: { model: { providerID: "google", model: "tts" } },
|
||||
})
|
||||
expect(result.diagnostics).toEqual([])
|
||||
})
|
||||
|
||||
test("canonicalizes transformed native values through decode then encode", () => {
|
||||
const result = normalized({ warming: { interval: "4 minutes", duration: "30 minutes" } })
|
||||
expect(result.encoded.warming).toEqual({ interval: "240000 millis", duration: "1800000 millis" })
|
||||
|
||||
@@ -486,6 +486,11 @@ describe("LocationServiceMap", () => {
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"main_cancel",
|
||||
"main_interrupt",
|
||||
"main_read",
|
||||
"main_send",
|
||||
"main_status",
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
@@ -505,6 +510,11 @@ describe("LocationServiceMap", () => {
|
||||
"edit",
|
||||
"glob",
|
||||
"grep",
|
||||
"main_cancel",
|
||||
"main_interrupt",
|
||||
"main_read",
|
||||
"main_send",
|
||||
"main_status",
|
||||
"patch",
|
||||
"question",
|
||||
"read",
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { makeGlobalNode, makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Job } from "@opencode/core/job"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { PluginHooks } from "@opencode/core/plugin/hooks"
|
||||
import { PluginSupervisor } from "@opencode/core/plugin/supervisor"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { CompanionTools } from "@opencode/core/tool/plugin/companion"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Provider } from "@opencode/schema/provider"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { executeTool, registerToolPlugin, toolIdentity } from "./lib/tool"
|
||||
|
||||
// Prompts are admitted durably; nothing needs to run for these tools to observe the inbox.
|
||||
const executionNode = makeGlobalNode({
|
||||
service: SessionExecution.Service,
|
||||
layer: Layer.succeed(
|
||||
SessionExecution.Service,
|
||||
SessionExecution.Service.of({
|
||||
active: Effect.succeed(new Set()),
|
||||
isActive: () => Effect.succeed(false),
|
||||
resume: () => Effect.void,
|
||||
wake: () => Effect.void,
|
||||
interrupt: () => Effect.succeed(false),
|
||||
awaitIdle: () => Effect.void,
|
||||
}),
|
||||
),
|
||||
deps: [],
|
||||
})
|
||||
|
||||
const companionPluginSupervisor = makeLocationNode({
|
||||
name: "test/companion-plugins",
|
||||
layer: Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* registerToolPlugin(
|
||||
CompanionTools.Plugin,
|
||||
{
|
||||
permission: {
|
||||
hook: (name, callback) => hooks.register("permission", name, callback),
|
||||
list: () => Effect.die("unused permission.list"),
|
||||
get: () => Effect.die("unused permission.get"),
|
||||
reply: () => Effect.die("unused permission.reply"),
|
||||
},
|
||||
},
|
||||
(name, callback) => hooks.register("tool", name, callback),
|
||||
)
|
||||
}),
|
||||
),
|
||||
deps: [Permission.node, Session.node, Tool.node, PluginHooks.node],
|
||||
})
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([Database.node, Bus.node, Job.node, Session.node, LocationServiceMap.node]), [
|
||||
SessionExecution.node.replace(executionNode),
|
||||
Global.node.replace(tempGlobalLayer),
|
||||
offlineModels,
|
||||
PluginSupervisor.node.replace(companionPluginSupervisor),
|
||||
]),
|
||||
)
|
||||
|
||||
const withDirectory = <A, E, R>(body: (location: Location.Ref) => Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap((dir) => body(Location.Ref.make({ directory: AbsolutePath.make(dir.path) }))))
|
||||
|
||||
const registry = (location: Location.Ref) =>
|
||||
Effect.gen(function* () {
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
yield* Plugin.Service.use((plugins) => plugins.awaitActivation).pipe(Effect.provide(locations.get(location)))
|
||||
return yield* Tool.Service.pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
|
||||
const call = (name: string, input: Record<string, unknown>) => ({
|
||||
type: "tool-call" as const,
|
||||
id: `call-${name}`,
|
||||
name,
|
||||
input,
|
||||
})
|
||||
|
||||
describe("CompanionTools", () => {
|
||||
it.live("gives each main session one companion", () =>
|
||||
withDirectory((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const main = yield* sessions.create({ location, title: "Main" })
|
||||
const companion = yield* sessions.companion(main.id)
|
||||
expect(companion).toMatchObject({ parentID: main.id, kind: "companion", agent: "companion", permissions: [] })
|
||||
expect((yield* sessions.companion(main.id)).id).toBe(companion.id)
|
||||
expect((yield* sessions.companion(companion.id)).id).toBe(companion.id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses the companion agent's model before the main session's model", () =>
|
||||
withDirectory((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const model = Model.Ref.make({ providerID: Provider.ID.make("main"), id: Model.ID.make("large") })
|
||||
const fast = Model.Ref.make({ providerID: Provider.ID.make("fast"), id: Model.ID.make("small") })
|
||||
|
||||
const fallback = yield* sessions.companion((yield* sessions.create({ location, model })).id)
|
||||
expect(fallback.model).toMatchObject(model)
|
||||
|
||||
yield* Agent.Service.use((agents) =>
|
||||
agents.transform((editor) =>
|
||||
editor.update(Agent.ID.make("companion"), (agent) => {
|
||||
agent.model = { ...fast }
|
||||
}),
|
||||
),
|
||||
).pipe(Effect.provide(locations.get(location)))
|
||||
const configured = yield* sessions.companion((yield* sessions.create({ location, model })).id)
|
||||
expect(configured.model).toMatchObject(fast)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("denies what config rules would let the companion ask for or edit", () =>
|
||||
withDirectory((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const main = yield* sessions.create({ location, agent: Agent.ID.make("reviewer") })
|
||||
const companion = yield* sessions.companion(main.id)
|
||||
yield* registry(location)
|
||||
const effects = yield* Effect.gen(function* () {
|
||||
const agents = yield* Agent.Service
|
||||
yield* agents.transform((editor) =>
|
||||
[Agent.ID.make("reviewer"), Agent.ID.make("companion")].forEach((id) =>
|
||||
editor.update(id, (agent) => {
|
||||
agent.permissions.push(
|
||||
{ action: "shell", resource: "*", effect: "ask" },
|
||||
{ action: "edit", resource: "*", effect: "allow" },
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
const permission = yield* Permission.Service
|
||||
return yield* Effect.forEach([main, companion], (session) =>
|
||||
Effect.forEach(
|
||||
[
|
||||
{ action: "shell", resources: ["npm test"] },
|
||||
{ action: "edit", resources: ["src/index.ts"] },
|
||||
],
|
||||
(input) =>
|
||||
permission
|
||||
.ask({ sessionID: session.id, agent: session.agent, ...input })
|
||||
.pipe(Effect.map((result) => result.effect)),
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
|
||||
expect(effects).toEqual([
|
||||
["ask", "allow"],
|
||||
["deny", "deny"],
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("steers the main session from its companion", () =>
|
||||
withDirectory((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const main = yield* sessions.create({ location, title: "Main" })
|
||||
const companion = yield* sessions.companion(main.id)
|
||||
const tools = yield* registry(location)
|
||||
|
||||
const sent = yield* executeTool(tools, {
|
||||
sessionID: companion.id,
|
||||
...toolIdentity,
|
||||
call: call("main_send", { text: "Run the tests before committing", delivery: "queue" }),
|
||||
})
|
||||
expect(sent.status).toBe("completed")
|
||||
const inbox = yield* sessions.inbox(main.id)
|
||||
expect(inbox).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "user",
|
||||
delivery: "queue",
|
||||
payload: expect.objectContaining({
|
||||
text: "Run the tests before committing",
|
||||
metadata: { source: "companion", companionID: companion.id },
|
||||
}),
|
||||
}),
|
||||
])
|
||||
|
||||
const status = yield* executeTool(tools, {
|
||||
sessionID: companion.id,
|
||||
...toolIdentity,
|
||||
call: call("main_status", {}),
|
||||
})
|
||||
expect(status.content).toEqual([
|
||||
{ type: "text", text: expect.stringContaining("Run the tests before committing") },
|
||||
])
|
||||
|
||||
const cancelled = yield* executeTool(tools, {
|
||||
sessionID: companion.id,
|
||||
...toolIdentity,
|
||||
call: call("main_cancel", { inboxID: inbox[0]?.id }),
|
||||
})
|
||||
expect(cancelled.status).toBe("completed")
|
||||
expect(yield* sessions.inbox(main.id)).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("limits companion shell commands to one read-only git command", () =>
|
||||
withDirectory((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const companion = yield* sessions.companion((yield* sessions.create({ location })).id)
|
||||
const tools = yield* registry(location)
|
||||
const run = (agent: string, command: string) =>
|
||||
executeTool(tools, {
|
||||
sessionID: companion.id,
|
||||
...toolIdentity,
|
||||
agent: Agent.ID.make(agent),
|
||||
call: call("shell", { command }),
|
||||
}).pipe(Effect.map((result) => result.error?.message))
|
||||
|
||||
// Config rules such as `git *` allow these; the hook refuses them anyway.
|
||||
const refused = [
|
||||
"git status && git log > notes.txt",
|
||||
"git diff --output=patch.txt",
|
||||
"git commit -m x",
|
||||
"git reset --hard",
|
||||
"git branch -D main",
|
||||
"git status; touch notes.txt",
|
||||
"git log $(touch notes.txt)",
|
||||
"git show `touch notes.txt`",
|
||||
"git log | tee notes.txt",
|
||||
"git status\ntouch notes.txt",
|
||||
"gh pr comment 1 --body x",
|
||||
]
|
||||
for (const command of refused)
|
||||
expect(yield* run("companion", command)).toBe(
|
||||
"The companion can only run one read-only git command, without shell operators",
|
||||
)
|
||||
// Allowed commands and other agents reach tool lookup; this registry has no shell tool.
|
||||
for (const command of ["git status", "git log --oneline -5 --format='%h %s'", "git branch -v", "git diff "])
|
||||
expect(yield* run("companion", command)).toContain('No tool named "shell"')
|
||||
expect(yield* run("build", "git log > notes.txt")).toContain('No tool named "shell"')
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("refuses callers that are not companions", () =>
|
||||
withDirectory((location) =>
|
||||
Effect.gen(function* () {
|
||||
const sessions = yield* Session.Service
|
||||
const main = yield* sessions.create({ location, title: "Main" })
|
||||
const tools = yield* registry(location)
|
||||
const result = yield* executeTool(tools, {
|
||||
sessionID: main.id,
|
||||
...toolIdentity,
|
||||
call: call("main_send", { text: "hello" }),
|
||||
})
|
||||
expect(result).toMatchObject({
|
||||
status: "error",
|
||||
error: { message: expect.stringContaining("Only a companion session") },
|
||||
})
|
||||
expect(yield* sessions.inbox(main.id)).toEqual([])
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
@@ -636,6 +636,15 @@ describe("SubagentTool", () => {
|
||||
message: `Session ${unrelated.id} is not a child of the current session`,
|
||||
},
|
||||
})
|
||||
const companion = yield* sessions.companion(parent.id)
|
||||
expect(yield* call(companion.id, "call-companion")).toEqual({
|
||||
status: "error",
|
||||
error: {
|
||||
type: "tool.execution",
|
||||
message: `Session ${companion.id} is a companion, not a subagent`,
|
||||
},
|
||||
})
|
||||
expect(yield* sessions.get(companion.id)).toMatchObject({ agent: "companion" })
|
||||
expect(yield* call(switched.id, "call-switched-child")).toMatchObject({
|
||||
status: "completed",
|
||||
metadata: { sessionID: switched.id, status: "completed" },
|
||||
|
||||
@@ -35,6 +35,7 @@ const session = (
|
||||
project_id: Project.ID.global,
|
||||
workspace_id: null,
|
||||
parent_id: null,
|
||||
kind: null,
|
||||
fork_session_id: null,
|
||||
fork_boundary: null,
|
||||
slug: "test",
|
||||
|
||||
@@ -361,6 +361,12 @@ export function createBrowserPage(
|
||||
visible = value
|
||||
updateVisibility()
|
||||
},
|
||||
// Freezes the shown page so the renderer can paint it under DOM overlays while the view hides.
|
||||
async capture() {
|
||||
if (closed || !visible || !content) return
|
||||
const image = await contents.capturePage()
|
||||
return image.isEmpty() ? undefined : new Uint8Array(image.toJPEG(90))
|
||||
},
|
||||
async execute(command: Browser.Command, signal: AbortSignal): Promise<Browser.Result> {
|
||||
await ready
|
||||
abortError(signal)
|
||||
|
||||
@@ -288,6 +288,9 @@ export function createBrowserPane(storage: StateStore) {
|
||||
page.layout(bounds, value.background, value.radius)
|
||||
page.setVisible(true)
|
||||
},
|
||||
async capture(win: BrowserWindow, bindingID: string, tabID: Browser.TabID) {
|
||||
return (await owned(win, bindingID).pages.get(tabID)?.capture()) ?? null
|
||||
},
|
||||
async command(win: BrowserWindow, bindingID: string, command: BrowserPaneCommand) {
|
||||
const entry = owned(win, bindingID)
|
||||
await execute(entry, { action: command, files: [] }, new AbortController().signal)
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IpcPortHandoff } from "../ipc-transport"
|
||||
import { Shutdown } from "../lifecycle/shutdown"
|
||||
import { isRendererUrl } from "../windows/scheme"
|
||||
import { DesktopStorage } from "../storage"
|
||||
import { sender } from "./context"
|
||||
import { sender, type RpcContext } from "./context"
|
||||
|
||||
export const eventHandlers = EventRpcs.toLayer(
|
||||
Effect.gen(function* () {
|
||||
@@ -25,21 +25,29 @@ export const eventHandlers = EventRpcs.toLayer(
|
||||
})
|
||||
const remove = yield* shutdown.add(stop)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(remove).pipe(Effect.andThen(stop)))
|
||||
const owner = async (context: RpcContext) => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
|
||||
throw new Error("browser.pane.owner.invalid")
|
||||
}
|
||||
browser ??= load()
|
||||
return { win, pane: await browser }
|
||||
}
|
||||
return EventRpcs.of({
|
||||
DesktopEvents: (_request, context) => ipcEventStream(sender(handoff, context).id),
|
||||
BrowserPane: ({ request }, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
const contents = sender(handoff, context)
|
||||
const win = BrowserWindow.fromWebContents(contents)
|
||||
if (!win || win.isDestroyed() || win.webContents !== contents || !isRendererUrl(contents.getURL())) {
|
||||
throw new Error("browser.pane.owner.invalid")
|
||||
}
|
||||
browser ??= load()
|
||||
const pane = await browser
|
||||
if (request.type === "register") return pane.register(win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return pane.layout(win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return pane.command(win, request.bindingID, request.command)
|
||||
return pane.close(win, request.bindingID)
|
||||
const target = await owner(context)
|
||||
if (request.type === "register") return target.pane.register(target.win, request.bindingID, request.target)
|
||||
if (request.type === "layout") return target.pane.layout(target.win, request.bindingID, request.layout)
|
||||
if (request.type === "command") return target.pane.command(target.win, request.bindingID, request.command)
|
||||
return target.pane.close(target.win, request.bindingID)
|
||||
}).pipe(Effect.orDie),
|
||||
BrowserPaneCapture: (request, context) =>
|
||||
Effect.tryPromise(async () => {
|
||||
const target = await owner(context)
|
||||
return target.pane.capture(target.win, request.bindingID, request.tabID)
|
||||
}).pipe(Effect.orDie),
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { DesktopNativeBundle } from "@opencode/app/i18n/desktop-native"
|
||||
import type { UpdaterState } from "@opencode/app/updater"
|
||||
import type { WslServersPlatform } from "@opencode/app/wsl/types"
|
||||
import type { SshPlatform } from "@opencode/app/ssh"
|
||||
import type { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import type { BrowserPaneRequest } from "../shared/ipc-rpc/browser"
|
||||
import type { WindowBootstrap } from "../shared/window-bootstrap"
|
||||
import type {
|
||||
@@ -31,6 +32,7 @@ export type ElectronAPI = {
|
||||
browserPane: {
|
||||
request(request: BrowserPaneRequest): Promise<void>
|
||||
send(request: BrowserPaneRequest): void
|
||||
capture(bindingID: string, tabID: Browser.TabID): Promise<ArrayBuffer | null>
|
||||
onEvent(callback: (value: { readonly bindingID: string; readonly event: BrowserPaneEvent }) => void): () => void
|
||||
}
|
||||
wslServers: WslServersAPI
|
||||
|
||||
@@ -50,6 +50,8 @@ export const api: ElectronAPI = {
|
||||
browserPane: {
|
||||
request: (request) => invoke("BrowserPane", { request }),
|
||||
send: (request) => send("BrowserPane", { request }),
|
||||
capture: (bindingID, tabID) =>
|
||||
invoke("BrowserPaneCapture", { bindingID, tabID }).then((data) => (data ? toArrayBuffer(data) : null)),
|
||||
onEvent: (callback) => listen("BrowserPaneEvent", (value) => callback(value)),
|
||||
},
|
||||
wslServers: {
|
||||
|
||||
@@ -46,6 +46,10 @@ export function createDesktopPlatform(
|
||||
.catch(() => undefined)
|
||||
},
|
||||
command: (command) => ready.then(() => api.browserPane.request({ type: "command", bindingID, command })),
|
||||
capture: (tabID) =>
|
||||
ready
|
||||
.then(() => api.browserPane.capture(bindingID, tabID))
|
||||
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
|
||||
close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Browser } from "@opencode/plugin-browser/rpc"
|
||||
import { Schema } from "effect"
|
||||
import { Rpc } from "effect/unstable/rpc"
|
||||
import { Transferable } from "effect/unstable/workers"
|
||||
|
||||
const text = (maximum: number) => Schema.String.check(Schema.isMinLength(1), Schema.isMaxLength(maximum))
|
||||
const bindingID = text(128)
|
||||
@@ -42,3 +43,7 @@ export const BrowserPaneEventSchema = Schema.Union([
|
||||
}),
|
||||
])
|
||||
export const BrowserPaneRpc = Rpc.make("BrowserPane", { payload: { request: BrowserPaneRequestSchema } })
|
||||
export const BrowserPaneCaptureRpc = Rpc.make("BrowserPaneCapture", {
|
||||
payload: { bindingID, tabID: Browser.TabID },
|
||||
success: Schema.NullOr(Transferable.Uint8Array),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Schema } from "effect"
|
||||
import { Rpc, RpcGroup } from "effect/unstable/rpc"
|
||||
import { BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
|
||||
import { BrowserPaneCaptureRpc, BrowserPaneEventSchema, BrowserPaneRpc } from "./browser"
|
||||
import { UpdaterStateSchema } from "./updater"
|
||||
import { WslServersEventSchema } from "./wsl"
|
||||
import { SshState } from "@opencode/app/ssh"
|
||||
@@ -63,4 +63,4 @@ export const DesktopEvent = Schema.Union([
|
||||
export type DesktopEvent = Schema.Schema.Type<typeof DesktopEvent>
|
||||
|
||||
export const DesktopEvents = Rpc.make("DesktopEvents", { success: DesktopEvent, stream: true })
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc)
|
||||
export const EventRpcs = RpcGroup.make(DesktopEvents, BrowserPaneRpc, BrowserPaneCaptureRpc)
|
||||
|
||||
@@ -71,6 +71,10 @@ async function main() {
|
||||
void ready.then(() => pane.layout(win, bindingID, layout))
|
||||
},
|
||||
command: (command) => ready.then(() => pane.command(win, bindingID, command)),
|
||||
capture: (tabID) =>
|
||||
ready
|
||||
.then(() => pane.capture(win, bindingID, tabID))
|
||||
.then((data) => data && new Blob([data], { type: "image/jpeg" })),
|
||||
close: () => {
|
||||
listeners.delete(bindingID)
|
||||
void ready.then(() => pane.close(win, bindingID)).catch(() => {})
|
||||
|
||||
@@ -333,6 +333,11 @@ async function main() {
|
||||
await call("tabs.focus", { tabID: second.id })
|
||||
pane.layout(win, "suite", { tabID: second.id, visible: true, bounds: { x: 0, y: 0, width: 1000, height: 700 } })
|
||||
const snap = await call("snapshot", { tabID, boxes: true })
|
||||
const still = await until(() => pane.capture(win, "suite", second.id))
|
||||
assert(still)
|
||||
assert.deepEqual(Array.from(still.subarray(0, 2)), [0xff, 0xd8], "The shown page captures as a JPEG still")
|
||||
assert.equal(await pane.capture(win, "suite", tabID), null, "A hidden page has no still to show")
|
||||
console.log("PASS browser pane still capture")
|
||||
const ref = (text: string) => {
|
||||
const match = snap.content
|
||||
.split("\n")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user