Compare commits

...
Author SHA1 Message Date
Kit Langton b171a295df fix(cli): adopt the login shell environment in the background service
A managed service elected by a GUI client (the desktop app, an editor, a
login item) inherits launchd's environment: PATH is /usr/bin:/bin:/usr/sbin:/sbin
and nothing exported from .zshrc or .bashrc exists. Every process the server
spawns extends process.env, so stdio MCP servers using #!/usr/bin/env node,
npx, uvx, formatters, and hooks fail with "Connection closed" while the bash
tool, which prefers the client's terminal environment, keeps working.

Resolve the user's interactive login shell once at service startup when the
inherited environment did not come from a shell, and adopt it before anything
reads process.env.
2026-09-16 11:57:09 -07:00
Kit Langton 79d657b8fe fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:43 -04:00
Kit Langton 606ec4fa38 fix(core): keep recovered shell notices from waking idle sessions (#49378) 2026-09-16 14:25:00 -04:00
Shoubhit Dash 2cdd938152 fix: honor provider transport overrides and preserve errors (#49350) 2026-09-16 22:42:23 +05:30
opencode 4d73b40e59 sync release versions for v2.0.5 2026-09-16 16:35:05 +00:00
opencode-agent[bot]andadamdotdevin 276f2eef3e fix(stats): hide Union Alpha provider (#49355)
Co-authored-by: adamdotdevin <2363879+adamdotdevin@users.noreply.github.com>
2026-09-16 10:58:41 -05:00
Dax Raad 8806d34ed4 feat(tui): make notification and sound alerts independent toggles
Remove the attention.enabled master switch. Notifications and sounds are
now enabled independently and both default to off. Existing enabled keys
are ignored; there is no migration.
2026-09-16 11:39:09 -04:00
Shoubhit Dash cda2bc5100 test(core): match the one minute Console config refresh interval (#49327) 2026-09-16 17:37:14 +05:30
Daniel Polito ba753b383f fix(core): retry acknowledged websocket read failures (#49183) 2026-09-16 16:15:19 +05:30
Simon Klee eb052f70b4 fix: mini reconnect (#49301)
A stalled catalog refresh kept reconnection from completing and prevented
new prompts. Allow the stream to reconnect independently of catalog state.
2026-09-16 08:05:23 +00:00
Victor Navarro 5eaed68c2b fix(core): refresh Console config every minute (#49233) 2026-09-16 09:28:29 +02:00
Aiden Cline 7d7962b2fa feat(core): let subagents pick a model and expose models tool (#49194) 2026-09-16 01:16:55 -05:00
Aiden Cline 0537fd47c7 feat(tui): open execute call details dialog on click (#49259) 2026-09-16 01:10:08 -05:00
Aiden Cline 9add9384be refactor(core): list which extension calls show under execute (#49281) 2026-09-16 00:30:54 -05:00
Luke Parker 005522786f fix(app): enable debug tools toggle on local channel (#49278) 2026-09-16 04:58:54 +00:00
Dax 5379a2dfe0 fix(codemode): reuse tool discovery catalogs
from OpenCode
2026-09-16 00:52:00 -04:00
Aiden Cline 6ee156e0b9 fix(core): keep the configured MCP URL as the OAuth resource (#49241) 2026-09-15 23:50:36 -05:00
opencode 7c3c50bac2 sync release versions for v2.0.4 2026-09-16 04:41:12 +00:00
Aiden Cline 194a006932 feat(core): expose fetch to code mode scripts (#49235) 2026-09-15 23:29:22 -05:00
Dax Raad 3c1f87d2d4 fix(tui): limit shell command previews 2026-09-16 00:21:05 -04:00
Aiden Cline c57733a647 refactor(codemode): before/after hooks for tool and extension calls (#49242) 2026-09-15 23:05:06 -05:00
Dax 9bf5faa978 fix(core): reuse model catalog across credential changes (#49255) 2026-09-15 23:10:29 -04:00
Dax a062f4e803 test(core): expect the vcs provider in info results (#49264) 2026-09-15 23:01:16 -04:00
opencode-agent[bot]andrekram1-node fc49b16584 fix(install): use latest release channel (#49261)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-15 21:30:38 -05:00
usrnk1andLukeParkerDev 9b091ebd37 feat(desktop): cycle new session location (#49171)
Co-authored-by: LukeParkerDev <10430890+Hona@users.noreply.github.com>
2026-09-16 11:17:52 +10:00
Dax 12201ee507 fix(cli): load web assets on demand
from OpenCode
2026-09-15 21:15:40 -04:00
Kit Langton 73bbeb7ce1 fix(tui): deduplicate plugin targets (#49112) 2026-09-16 01:03:58 +00:00
Kit Langton 370b9d484a test(tui): update descendant form expectation (#49246) 2026-09-16 00:51:51 +00:00
Dax Raad 796c423df5 fix(tui): remove web search integration category 2026-09-15 20:40:05 -04:00
Dax Raad 978bc53eba docs: complete v2 HTTP API audit 2026-09-15 20:38:01 -04:00
Dax Raad f7444aba27 fix(release): anchor review to stable tag 2026-09-15 20:36:47 -04:00
Dax Raad c987590c5f refactor(protocol): mark stateless generation experimental 2026-09-15 20:33:46 -04:00
Kit Langton 1a3bd3458c test(cli): align ACP fixture contracts (#49251) 2026-09-16 00:33:18 +00:00
Dax Raad 67a6d52015 chore(cli): reduce compiled runtime memory 2026-09-15 20:29:56 -04:00
Dax Raad b1f6d21ef3 refactor(protocol): refine vcs and shell APIs 2026-09-15 20:19:08 -04:00
Aiden Cline fa3721fad6 fix(codemode): treat tools.search as the built-in search (#49249) 2026-09-15 19:08:51 -05:00
Aiden Cline 02b12aa60e test(core): align plugin host fixture with session update and permission domains (#49243) 2026-09-15 18:43:52 -05:00
Dax Raad 1b6a07dda3 refactor(session): simplify permissions event 2026-09-15 19:24:50 -04:00
Dax Raad 547d60183d refactor(session): consolidate mutable properties 2026-09-15 18:50:43 -04:00
Dax Raad 12e53ee6d4 refactor(protocol): simplify interactive resources 2026-09-15 18:23:47 -04:00
David Hill b629b458f7 fix(app): match session title placeholder to tab label (#48735) 2026-09-16 08:20:08 +10:00
David Hill 24dff6929d feat(app): preview timeline images in attachment overlay (#49111) 2026-09-16 08:19:41 +10:00
Kit Langton 7e2ca5fe26 fix(tui): retry installed plugin dependencies (#49187) 2026-09-15 21:40:58 +00:00
Dax aaed6eaa45 fix(tui): clarify collapsed shell output (#49232) 2026-09-15 17:00:57 -04:00
Aiden Cline 01443d5e00 fix(core): preserve job failure details (#49226) 2026-09-15 15:30:53 -05:00
opencode-agent[bot]andrekram1-node 70fe186df4 test(app): wait for summary readiness (#49217)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-15 15:26:28 -05:00
opencode-agent[bot] fc731d6013 chore: update nix node_modules hashes 2026-09-15 20:10:17 +00:00
Aiden Cline 4cd6093e31 fix(codemode): keep one RegExp lastIndex (#49220) 2026-09-15 15:01:08 -05:00
Aiden Cline b693c222da feat(core): configure MCP OAuth authorization server metadata (#49126) 2026-09-15 14:52:30 -05:00
Aiden Cline e31ad14c1d fix(core): surface authorization cause in error message (#49214) 2026-09-15 14:38:15 -05:00
James Long 6c9115eb75 docs(www): restore regular body font weight (#49219) 2026-09-15 15:18:40 -04:00
Aiden Cline d9dfc5c1d6 refactor(codemode): extensions are host functions only (#49196) 2026-09-15 13:42:51 -05:00
opencode-agent[bot]andrekram1-node c921067eb8 fix(client): regenerate model compatibility type (#49204)
Co-authored-by: rekram1-node <rekram1-node@users.noreply.github.com>
2026-09-15 13:31:18 -05:00
James Long b76e56eca3 docs(www): adjust text and background colors (#49202) 2026-09-15 13:34:54 -04:00
James Long 3c3ef00b32 docs(www): use variable IBM Plex Mono and refine body text (#49200) 2026-09-15 13:28:13 -04:00
Aiden Cline d54fc305db feat(core): append model identity to system prompts via plugin (#49129) 2026-09-15 11:46:07 -05:00
James Long a4aaf18855 docs(www): refine typography and background (#49191) 2026-09-15 12:43:18 -04:00
Aiden Cline d0a902815d feat(plugin): add experimental WebSocket handshake hook (#49131) 2026-09-15 01:41:00 -05:00
Aiden Cline 27aaa9ce0e feat(ai): allowlist prompt_cache_key sending per provider (#49121) 2026-09-15 01:23:49 -05:00
Aiden Cline 0b15abbf9a refactor(codemode): make the host boundary JSON.stringify plus a short table (#49128) 2026-09-15 01:23:20 -05:00
Aiden Cline 0f22b86670 refactor(core): replace websocket flags with a single transport preference (#48423) 2026-09-15 00:55:43 -05:00
293 changed files with 6945 additions and 5978 deletions
+83 -83
View File
@@ -29,15 +29,15 @@ Review endpoints in document order. For each endpoint, select one disposition an
## Progress
- [x] Group 1: Foundation and placement (4)
- [ ] Group 2: Configuration and capability catalogs (16)
- [ ] Group 3: Credentials, integrations, MCP, and web search (22)
- [ ] Group 4: Session lifecycle (12)
- [ ] Group 5: Session execution and inputs (11)
- [ ] Group 6: Session history and recovery (13)
- [ ] Group 7: Inbox, permissions, and forms (19)
- [ ] Group 8: Filesystem, worktrees, and VCS (12)
- [ ] Group 9: PTYs, persistent terminals, and shells (24)
- [ ] Group 10: Events, RPC, and experimental operations (6)
- [x] Group 2: Configuration and capability catalogs (16)
- [x] Group 3: Credentials, integrations, MCP, and web search (22)
- [x] Group 4: Session lifecycle (12)
- [x] Group 5: Session execution and inputs (11)
- [x] Group 6: Session history and recovery (13)
- [x] Group 7: Inbox, permissions, and forms (19)
- [x] Group 8: Filesystem, worktrees, and VCS (12)
- [x] Group 9: PTYs, persistent terminals, and shells (24)
- [x] Group 10: Events, RPC, and experimental operations (6)
## Resolved during audit
@@ -100,7 +100,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 021 | `GET` | `/api/config` | `config.get` | Keep | Compatibility entries removed; response now contains only documents and OpenCode directories. |
| [x] 022 | `GET` | `/api/config/preferences` | `config.preferences` | Remove | Redundant special projection of global config. |
| [x] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | Remove | Redundant field-specific config mutation API. |
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
| [x] 024 | `GET` | `/api/config/shell` | `config.shells` | Keep | Required by the server Terminal shell setting. |
| [x] 024a | `PATCH` | `/api/experimental/config` | `experimental.config.update` | Change | Experimental global config mutation; initially accepts only `shell`. |
## Group 3: Credentials, integrations, MCP, and web search
@@ -111,21 +111,21 @@ Review endpoints in document order. For each endpoint, select one disposition an
|---|---|---|---|---|---|
| [x] 025 | `GET` | `/api/integration` | `integration.list` | Keep | Full integration inventory is consumed by authentication and integration-selection clients. |
| [x] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | Change | Missing integration now returns typed `404` instead of optional data. |
| [x] | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | | |
| [ ] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | | |
| [ ] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | | |
| [ ] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | | |
| [ ] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | | |
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
| [x] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | Experimental-only | Retained outside the stable API commitment. |
| [x] 028 | `POST` | `/api/integration/{integrationID}/connect/key` | `integration.connect.key` | Change | Missing integration returns typed `404`; key form answers retained. |
| [x] 029 | `POST` | `/api/integration/{integrationID}/connect/oauth` | `integration.oauth.connect` | Keep | OAuth connection start contract retained. |
| [x] 030 | `GET` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.status` | Change | Missing integration or OAuth attempt returns typed `404`. |
| [x] 031 | `DELETE` | `/api/integration/{integrationID}/connect/oauth/{attemptID}` | `integration.oauth.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 032 | `POST` | `/api/integration/{integrationID}/connect/oauth/{attemptID}/complete` | `integration.oauth.complete` | Change | Missing integration or OAuth attempt returns typed `404`; code remains mode-dependent. |
| [x] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | Change | Missing integration or command method returns typed `404`. |
| [x] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | Change | Missing integration or command attempt returns typed `404`. |
| [x] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | Keep | Idempotent cancellation remains a no-op for unavailable or terminal attempts. |
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
| [x] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | Keep | Reviewed separately by coworker. |
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
@@ -147,7 +147,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
| [x] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | Keep | Subsequent-execution agent selection retained. |
| [x] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | Keep | Subsequent-execution model and optional variant selection retained. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.rename` | Change | Title-only rename now uses the session resource path. |
| [x] 056 | `PATCH` | `/api/session/{sessionID}` | `session.update` | Change | General session patch updates title and permissions; rules emit `session.permissions`. |
| [x] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | Change | Removed inaccurate local-change transfer claim; delivery behavior retained. |
| [x] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | Keep | Backgroundable foreground tools transition to background observation; idle requests remain no-ops. |
@@ -166,7 +166,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
| [x] 068 | `PUT` | `/api/experimental/session/{sessionID}/environment` | `experimental.session.environment` | Experimental-only | Process-local environment replacement retained outside the stable API. |
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
## Group 6: Session history and recovery
@@ -185,7 +185,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 077 | `GET` | `/api/experimental/session/{sessionID}/instructions/entries` | `experimental.session.instructions.entry.list` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 078 | `PUT` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.put` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] 079 | `DELETE` | `/api/experimental/session/{sessionID}/instructions/entries/{key}` | `experimental.session.instructions.entry.remove` | Experimental-only | API-managed durable context entries retained outside the stable API. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | Experimental-only | Retained outside the stable API commitment. |
| [x] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message.get` | Change | Normalized specific-message operation ID. |
| [x] 082 | `GET` | `/api/session/{sessionID}/message` | `session.message.list` | Change | Normalized session-scoped message-list operation ID. |
@@ -196,24 +196,24 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
| [x] 101 | — | — | — | Remove | Permission rules are updated through `session.update`. |
## Group 8: Filesystem, worktrees, and VCS
@@ -221,18 +221,18 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 102 | `GET` | `/api/fs/read/*` | `fs.read` | | |
| [ ] 103 | `GET` | `/api/fs/list` | `fs.list` | | |
| [ ] 104 | `GET` | `/api/fs/find` | `fs.find` | | |
| [ ] 105 | `GET` | `/api/worktree` | `worktree.list` | | |
| [ ] 106 | `POST` | `/api/worktree` | `worktree.create` | | |
| [ ] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | | |
| [ ] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | | |
| [ ] 109 | `GET` | `/api/vcs` | `vcs.get` | | |
| [ ] 110 | `GET` | `/api/vcs/base` | `vcs.base` | | |
| [ ] 111 | `GET` | `/api/vcs/status` | `vcs.status` | | |
| [ ] 112 | `GET` | `/api/vcs/branches` | `vcs.branches` | | |
| [ ] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | | |
| [x] 102 | `GET` | `/api/fs/read/*` | `fs.read` | Keep | Relative wildcard file reads and raw byte responses retained. |
| [x] 103 | `GET` | `/api/fs/list` | `fs.list` | Keep | Existing path scope and minimal entry metadata retained. |
| [x] 104 | `GET` | `/api/fs/find` | `fs.find` | Keep | Existing ranked filesystem search retained. |
| [x] 105 | `GET` | `/api/worktree` | `worktree.list` | Keep | Reviewed separately by coworker. |
| [x] 106 | `POST` | `/api/worktree` | `worktree.create` | Keep | Reviewed separately by coworker. |
| [x] 107 | `DELETE` | `/api/worktree` | `worktree.remove` | Keep | Reviewed separately by coworker. |
| [x] 108 | `POST` | `/api/worktree/refresh` | `worktree.refresh` | Keep | Reviewed separately by coworker. |
| [x] 109 | `GET` | `/api/vcs` | `vcs.get` | Change | Preserved branch nesting and added selected VCS provider ID. |
| [x] 110 | `GET` | `/api/vcs/base` | `vcs.base` | Keep | Review-base inference and nullable unavailable state retained. |
| [x] 111 | `GET` | `/api/vcs/status` | `vcs.status` | Keep | Existing working-copy status shape retained for now. |
| [x] 112 | `GET` | `/api/vcs/branch` | `vcs.branch.list` | Change | Singular collection path and normalized operation ID. |
| [x] 113 | `GET` | `/api/vcs/diff` | `vcs.diff` | Keep | Existing working, branch, and committed comparison modes retained. |
## Group 9: PTYs, persistent terminals, and shells
@@ -240,30 +240,30 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 114 | `GET` | `/api/pty` | `pty.list` | | |
| [ ] 115 | `POST` | `/api/pty` | `pty.create` | | |
| [ ] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | | |
| [ ] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | | |
| [ ] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | | |
| [ ] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | | |
| [ ] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | | |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [ ] 132 | `GET` | `/api/shell` | `shell.list` | | |
| [ ] 133 | `POST` | `/api/shell` | `shell.create` | | |
| [ ] 134 | `GET` | `/api/shell/{id}` | `shell.get` | | |
| [ ] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | | |
| [ ] 136 | `PATCH` | `/api/shell/{id}/timeout` | `shell.timeout` | | |
| [ ] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | | |
| [x] 114 | `GET` | `/api/pty` | `pty.list` | Keep | PTY endpoints reviewed together and retained. |
| [x] 115 | `POST` | `/api/pty` | `pty.create` | Keep | PTY endpoints reviewed together and retained. |
| [x] 116 | `GET` | `/api/pty/{ptyID}` | `pty.get` | Keep | PTY endpoints reviewed together and retained. |
| [x] 117 | `PUT` | `/api/pty/{ptyID}` | `pty.update` | Keep | PTY endpoints reviewed together and retained. |
| [x] 118 | `DELETE` | `/api/pty/{ptyID}` | `pty.remove` | Keep | PTY endpoints reviewed together and retained. |
| [x] 119 | `POST` | `/api/pty/{ptyID}/connect-token` | `pty.connect.token` | Keep | PTY endpoints reviewed together and retained. |
| [x] 120 | `GET` | `/api/pty/{ptyID}/connect` | `pty.connect` | Keep | PTY endpoints reviewed together and retained. |
| [x] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | Experimental-only | Retained outside the stable API commitment. |
| [x] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | Experimental-only | Retained outside the stable API commitment. |
| [x] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | Experimental-only | Retained outside the stable API commitment. |
| [x] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | Experimental-only | Retained outside the stable API commitment. |
| [x] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | Experimental-only | Retained outside the stable API commitment. |
| [x] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | Experimental-only | Retained outside the stable API commitment. |
| [x] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | Experimental-only | Retained outside the stable API commitment. |
| [x] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | Experimental-only | Retained outside the stable API commitment. |
| [x] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | Experimental-only | Retained outside the stable API commitment. |
| [x] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | Experimental-only | Retained outside the stable API commitment. |
| [x] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | Experimental-only | Retained outside the stable API commitment. |
| [x] 132 | `GET` | `/api/shell` | `shell.list` | Change | Stable shell inventory retained; numeric timestamps documented as epoch milliseconds. |
| [x] 133 | `POST` | `/api/shell` | `shell.create` | Change | Timeout is optional and defaults to zero; caller metadata retained. |
| [x] 134 | `GET` | `/api/shell/{id}` | `shell.get` | Keep | Specific running or retained shell read retained. |
| [x] 135 | `DELETE` | `/api/shell/{id}` | `shell.remove` | Change | Shell deletion is idempotent and returns `204` when already absent. |
| [x] 136 | — | — | — | Remove | Timeout mutation remains an internal Core shell operation. |
| [x] 137 | `GET` | `/api/shell/{id}/output` | `shell.output` | Keep | Existing byte-cursor text output paging retained. |
## Group 10: Events, RPC, and experimental operations
@@ -271,9 +271,9 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 138 | `POST` | `/api/generate` | `generate.text` | | |
| [ ] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | | |
| [ ] 140 | `GET` | `/api/event` | `event.subscribe` | | |
| [ ] 141 | `GET` | `/api/debug/location` | `debug.location.list` | | |
| [ ] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | | |
| [x] | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
| [x] 138 | `POST` | `/api/experimental/generate` | `experimental.generate.text` | Experimental-only | Stateless generation retained alongside session generation. |
| [x] 139 | `POST` | `/api/rpc/{rpcID}/{method}` | `rpc.call` | Keep | Generic typed-error plugin RPC transport retained. |
| [x] 140 | `GET` | `/api/event` | `event.subscribe` | Keep | Unified native and dynamic plugin event stream retained. |
| [x] 141 | `GET` | `/api/debug/location` | `debug.location.list` | Keep | Loaded-location debug inventory retained. |
| [x] 142 | `DELETE` | `/api/debug/location` | `debug.location.evict` | Keep | Idempotent loaded-location eviction retained. |
| [x] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | Experimental-only | Retained outside the stable API commitment. |
+36 -35
View File
@@ -32,7 +32,7 @@
},
"packages/ai": {
"name": "@opencode/ai",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/credential-providers": "3.1057.0",
"@opencode/schema": "workspace:*",
@@ -54,7 +54,7 @@
},
"packages/app": {
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
@@ -113,7 +113,7 @@
},
"packages/cli": {
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.5",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -217,7 +217,7 @@
},
"packages/console/app": {
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -253,7 +253,7 @@
},
"packages/console/core": {
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -280,7 +280,7 @@
},
"packages/console/function": {
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@openauthjs/openauth": "0.0.0-20250322224806",
"@opencode/console-core": "workspace:*",
@@ -297,7 +297,7 @@
},
"packages/console/mail": {
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -321,7 +321,7 @@
},
"packages/console/support": {
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode/console-core": "workspace:*",
@@ -341,7 +341,7 @@
},
"packages/core": {
"name": "@opencode/core",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@ai-sdk/cohere": "3.0.27",
"@ai-sdk/gateway": "3.0.104",
@@ -353,6 +353,7 @@
"@ff-labs/fff-node": "0.10.5",
"@lydell/node-pty": "catalog:",
"@modelcontextprotocol/client": "2.0.0",
"@modelcontextprotocol/core": "2.0.0",
"@opencode-ai/pty": "0.1.13",
"@opencode/ai": "workspace:*",
"@opencode/codemode": "workspace:*",
@@ -408,7 +409,7 @@
},
"packages/desktop": {
"name": "@opencode/desktop",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"electron-context-menu": "4.1.2",
@@ -460,7 +461,7 @@
},
"packages/enterprise": {
"name": "@opencode/enterprise",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/sdk": "1.18.21",
@@ -497,7 +498,7 @@
},
"packages/function": {
"name": "@opencode/function",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -513,7 +514,7 @@
},
"packages/http-recorder": {
"name": "@opencode/http-recorder",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@effect/platform-node-shared": "4.0.0-rc.112",
},
@@ -532,7 +533,7 @@
},
"packages/httpapi-codegen": {
"name": "@opencode/httpapi-codegen",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -545,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -559,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -574,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode/ai": "workspace:*",
@@ -613,7 +614,7 @@
},
"packages/plugin-browser": {
"name": "@opencode/plugin-browser",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -643,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -658,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -682,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -703,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@effect/platform-node": "catalog:",
"@effect/platform-node-shared": "catalog:",
@@ -725,7 +726,7 @@
},
"packages/session-ui": {
"name": "@opencode/session-ui",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -760,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/ai": "workspace:*",
"@opencode/core": "workspace:*",
@@ -780,7 +781,7 @@
},
"packages/stats/app": {
"name": "@opencode/stats-app",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@ibm/plex": "6.4.1",
"@kobalte/core": "catalog:",
@@ -814,7 +815,7 @@
},
"packages/stats/core": {
"name": "@opencode/stats-core",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -833,7 +834,7 @@
},
"packages/stats/server": {
"name": "@opencode/stats-server",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -879,7 +880,7 @@
},
"packages/theme": {
"name": "@opencode/theme",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -893,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -928,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -963,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -996,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1037,7 +1038,7 @@
},
"services/update": {
"name": "@opencode/update",
"version": "2.0.3",
"version": "2.0.5",
"dependencies": {
"jose": "6.0.11",
"semver": "catalog:",
+1 -1
View File
@@ -168,7 +168,7 @@ else
package_scope="@opencode"
if [ -z "$requested_version" ]; then
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
}
}
+1 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json.schemastore.org/package.json",
"name": "opencode",
"description": "AI-powered development tool",
"version": "2.0.3",
"version": "2.0.5",
"private": true,
"type": "module",
"packageManager": "bun@1.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.5",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -168,6 +168,10 @@ export const transport = <Body>(options: Options): Transport<Body, Prepared, str
}
})
: undefined
if (input.webSocket && !channel)
yield* Effect.logWarning(`${options.name} does not offer WebSocket for this endpoint; using HTTP`, {
url: parts.url,
})
return {
http: {
request: ProviderShared.jsonPost({ url: parts.url, body: parts.bodyText, headers: parts.headers }),
+10 -2
View File
@@ -336,12 +336,18 @@ export type OutputItem = StreamItem & { readonly id: string }
// Responses-compatible providers put streaming error details at the top level or
// under `error`, and response failures under `response.error`. Accept all three shapes.
// https://www.openresponses.org/specification
const OpenResponsesErrorPayload = Schema.Struct({
const OpenResponsesErrorObject = Schema.Struct({
type: optionalNull(Schema.String),
code: optionalNull(Schema.String),
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
Schema.decodeTo(OpenResponsesErrorObject, {
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
encode: SchemaGetter.passthrough(),
}),
)
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
@@ -434,7 +440,9 @@ export const decodeChannelEvent = (frame: string) =>
decodeFrame(frame).pipe(
Effect.flatMap((value) =>
decodeEventValue(
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
ProviderShared.isRecord(value) &&
value.type === undefined &&
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
? { ...value, type: "error" }
: value,
),
+4 -1
View File
@@ -715,7 +715,10 @@ const detectZaiToolStream = (provider: string, baseURL: string | undefined, mode
const lowerOptions = (request: LLMRequest, supportsStore: boolean) => {
const options = OpenAIOptions.resolve(request)
const cacheKey = ProviderShared.promptCacheKey(request)
// Default off: strict providers 400 on unknown body fields, so only send
// the key where compatibility explicitly allows it. Header-based affinity
// (x-session-affinity, x-grok-conv-id, ...) is unaffected.
const cacheKey = (request.model.compatibility?.supportsPromptCacheKey ?? false) ? ProviderShared.promptCacheKey(request) : undefined
return {
...(supportsStore && options.store !== undefined ? { store: options.store } : {}),
// For providers that support `store`, ensure stateless `store:false` is sent
+1 -7
View File
@@ -1,4 +1,3 @@
import { Headers } from "effect/unstable/http"
import { Auth } from "../route/auth.js"
import { type AtLeastOne, type ProviderAuthOption } from "../route/auth-options.js"
import type { Route, RouteDefaultsInput, CompactionOperations } from "../route/client.js"
@@ -61,11 +60,6 @@ const responsesRoute = OpenAIResponses.route.with({
url.searchParams.delete("api-version")
return url.toString()
},
headers: (headers) => {
const apiKey = headers["api-key"]
if (!apiKey) return headers
return Headers.remove(Headers.set(headers, "authorization", `Bearer ${apiKey}`), "api-key")
},
}),
})
@@ -135,7 +129,7 @@ export const configure = (input: Config) => {
const chat = (modelID: string | ModelID) =>
configuredRoute(chatRoute, input, modelID)
.with(withOpenAIOptions(modelID, modelDefaults))
.model<OpenAIProviderOptionsInput>({ id: modelID })
.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } })
return {
id,
+1 -1
View File
@@ -44,7 +44,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false },
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning", supportsStore: false, supportsPromptCacheKey: true },
}),
configure,
}
+1 -1
View File
@@ -47,7 +47,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false },
compatibility: { maxTokensField: "max_tokens", reasoningField: "reasoning_content", supportsStore: false, supportsPromptCacheKey: true },
}),
configure,
}
+2 -1
View File
@@ -41,7 +41,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
})
return {
id,
model: (modelID: string | ModelID) => configured.model<OpenAIProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) =>
configured.model<OpenAIProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
configure,
}
}
+1 -1
View File
@@ -132,7 +132,7 @@ export const configure = (input: LanguageModelOptions = {}) => {
const chat = (modelID: string | ModelID) =>
configuredChat.model<OpenResponsesProviderOptionsInput>({
id: modelID,
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false },
compatibility: { maxTokensField: "max_completion_tokens", supportsStore: false, supportsPromptCacheKey: true },
})
const messages = (modelID: string | ModelID) =>
configuredMessages.model<MessagesOptionsInput>({
+1
View File
@@ -112,6 +112,7 @@ export const configure = (input: Config = {}) => {
maxTokensField: "max_tokens",
supportsStore: false,
supportsStrictMode: false,
supportsPromptCacheKey: true,
toolSchema: "moonshot",
reasoningField: "reasoning_content",
},
+4 -1
View File
@@ -91,7 +91,10 @@ export const configure = (input: Config = {}) => {
.with(withOpenAIOptions(id, modelDefaults, { textVerbosity: true }))
.model<OpenAIProviderOptionsInput>({ id })
const chat = (id: string | ModelID) =>
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({ id })
chatRoute.with(withOpenAIOptions(id, modelDefaults)).model<OpenAIProviderOptionsInput>({
id,
compatibility: { supportsPromptCacheKey: true },
})
const image = (modelID: string | ModelID) =>
OpenAIImages.model({
id: modelID,
+2 -1
View File
@@ -183,7 +183,8 @@ export const configure = (input: LanguageModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model<OpenRouterProviderOptionsInput>({ id: modelID }),
model: (modelID: string | ModelID) =>
route.model<OpenRouterProviderOptionsInput>({ id: modelID, compatibility: { supportsPromptCacheKey: true } }),
configure,
}
}
+3
View File
@@ -168,6 +168,9 @@ export class LanguageModelCompatibility extends Schema.Class<LanguageModelCompat
supportsStore: Schema.optional(Schema.Boolean),
supportsUsageInStreaming: Schema.optional(Schema.Boolean),
supportsStrictMode: Schema.optional(Schema.Boolean),
// Accepts `prompt_cache_key` in the Chat Completions body. Chat omits the
// key unless this is set; session-affinity headers still flow regardless.
supportsPromptCacheKey: Schema.optional(Schema.Boolean),
zaiToolStream: Schema.optional(Schema.Boolean),
requireSignature: Schema.optional(Schema.Boolean),
/** Supports Anthropic's thinking-prefix mismatch controls. Overrides model-ID detection. */
@@ -1,8 +1,9 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { Effect, Schema, Stream } from "effect"
import { LLM, LLMClient } from "../../src/index.js"
import { OpenResponses } from "../../src/protocols/open-responses.js"
import { Meta } from "../../src/providers/index.js"
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
import { WebSocketTransport } from "../../src/route.js"
import { configure } from "../../src/providers/openai-compatible-responses.js"
import { it } from "../lib/effect.js"
import { fixedResponse } from "../lib/http.js"
@@ -44,11 +45,59 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
Effect.gen(function* () {
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
const message = "gRPC error: Response with id=resp_missing not found"
for (const error of [{ type: "api_error", message }, message]) {
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
type: "error",
error: typeof error === "string" ? { message } : error,
})
}
}),
)
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
Effect.gen(function* () {
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
type: "error",
error: { message: "Gateway failed" },
})
expect(
yield* decode(
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
),
).toEqual({
type: "response.failed",
response: { id: "resp_failed", error: { message: "Gateway failed" } },
})
}
}),
)
for (const model of [
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
]) {
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
Effect.gen(function* () {
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
const webSocket = WebSocketTransport.makeDirect({
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
})
for (const options of [{ webSocket }, {}]) {
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
Effect.provide(fixedResponse(sseEvents(raw))),
Effect.flip,
)
expect(error.reason._tag).toBe("UnknownProvider")
expect(error.message).toBe("Gateway rejected the request")
expect(error.reason.body).toBe(raw)
}
}),
)
}
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
Effect.gen(function* () {
const raw = `{
+21 -4
View File
@@ -248,7 +248,23 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("maps the request prompt cache key", () =>
it.effect("maps the request prompt cache key when the compatibility flag is set", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model: OpenAIChat.route
.with({ endpoint: { baseURL: "https://api.compatible.test/v1" }, auth: Auth.bearer("test") })
.model({ id: "compatible-model", compatibility: { supportsPromptCacheKey: true } }),
prompt: "Hello",
promptCacheKey: "session_123",
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
}),
)
it.effect("omits the prompt cache key without the compatibility flag", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
@@ -261,7 +277,7 @@ describe("OpenAI Chat route", () => {
}),
)
expect(prepared.body.prompt_cache_key).toBe("session_123")
expect(prepared.body).not.toHaveProperty("prompt_cache_key")
}),
)
@@ -280,7 +296,7 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("maps the xAI Chat prompt cache key to conversation affinity", () =>
it.effect("maps the xAI Chat prompt cache key to conversation affinity header only", () =>
LLMClient.generate(
LLM.request({
model: XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).chat("grok-4.5"),
@@ -294,7 +310,8 @@ describe("OpenAI Chat route", () => {
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(web.headers.get("x-grok-conv-id")).toBe("session_123")
const body = decodeJson(yield* Effect.promise(() => web.text()))
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBe("session_123")
// Chat uses the header; prompt_cache_key is Responses-only.
expect(ProviderShared.isRecord(body) ? body.prompt_cache_key : undefined).toBeUndefined()
return input.respond(sseEvents(deltaChunk({}, "stop")), {
headers: { "content-type": "text/event-stream" },
})
@@ -1,5 +1,5 @@
import { describe, expect } from "bun:test"
import { ConfigProvider, Effect, Layer, Ref, Schema, Stream } from "effect"
import { ConfigProvider, Effect, Layer, Logger, Ref, Schema, Stream } from "effect"
import { Headers, HttpClientRequest } from "effect/unstable/http"
import {
LLM,
@@ -760,7 +760,11 @@ describe("OpenAI Responses route", () => {
)
const next = continuationDriver({
...request,
input: [...firstInput, reasoning, { type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] }],
input: [
...firstInput,
reasoning,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Continue" }] },
],
})
const continued = yield* next.create(saved)
@@ -795,7 +799,10 @@ describe("OpenAI Responses route", () => {
ProviderShared.encodeJson({ type: "response.completed", response: { id: "resp_1" } }),
),
)
const appended = [...request.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }]
const appended = [
...request.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
]
const changes = [
{ ...request, model: "gpt-5.3", input: appended },
{ ...request, instructions: "Changed", input: appended },
@@ -804,7 +811,10 @@ describe("OpenAI Responses route", () => {
{ ...request, metadata: { source: "two" }, input: appended },
{
...request,
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] }, appended[1]],
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Rewritten history" }] },
appended[1],
],
},
]
@@ -900,7 +910,10 @@ describe("OpenAI Responses route", () => {
const second = continuationDriver(
{
...firstRequest,
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
input: [
...firstRequest.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
],
},
classifyingChannelDriver,
)
@@ -951,7 +964,10 @@ describe("OpenAI Responses route", () => {
}
const secondRequest = {
...firstRequest,
input: [...firstRequest.input, { type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] }],
input: [
...firstRequest.input,
{ type: "message", role: "user", content: [{ type: "input_text", text: "Second" }] },
],
}
const saved = checkpoint(
yield* continuationDriver(firstRequest).observe(
@@ -1082,7 +1098,9 @@ describe("OpenAI Responses route", () => {
)
const expected = {
input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] }],
input: [
{ type: "message", role: "user", content: [{ type: "input_text", text: "Say \uFFFDhello \u{1F600}." }] },
],
metadata: { source: "overlay\uFFFD" },
}
expect(JSON.parse(yield* Ref.get(message))).toMatchObject(expected)
@@ -1124,7 +1142,9 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("builds Azure WebSocket requests with v1 URLs and bearer auth", () =>
// Azure's WebSocket upgrade accepts the same credential header as HTTP: `api-key` for keys,
// `Authorization: Bearer` for Entra tokens. Rewriting a key into a bearer is rejected.
it.effect("builds Azure WebSocket requests with v1 URLs and the route's auth header", () =>
Effect.gen(function* () {
const deps = Layer.succeed(
RequestExecutor.Service,
@@ -1133,13 +1153,13 @@ describe("OpenAI Responses route", () => {
const cases = [
{
model: Azure.configure({ resourceName: "opencode-test", apiKey: "azure-key" }).responses("deployment"),
authorization: "Bearer azure-key",
headers: { "api-key": "azure-key", authorization: undefined },
},
{
model: Azure.configure({ resourceName: "opencode-test", auth: Auth.bearer("entra-token") }).responses(
"deployment",
),
authorization: "Bearer entra-token",
headers: { "api-key": undefined, authorization: "Bearer entra-token" },
},
]
@@ -1150,8 +1170,8 @@ describe("OpenAI Responses route", () => {
Effect.gen(function* () {
expect(exchange.connect.url).toBe("wss://opencode-test.openai.azure.com/openai/v1/responses")
expect(exchange.connect.rotateAfterMs).toBe(55 * 60 * 1000)
expect(exchange.connect.headers.authorization).toBe(item.authorization)
expect(exchange.connect.headers["api-key"]).toBeUndefined()
expect(exchange.connect.headers.authorization).toBe(item.headers.authorization)
expect(exchange.connect.headers["api-key"]).toBe(item.headers["api-key"])
expect(exchange.connect.headers["openai-beta"]).toBeUndefined()
expect(JSON.parse((yield* exchange.driver.create(undefined)).message)).toMatchObject({
type: "response.create",
@@ -1199,6 +1219,11 @@ describe("OpenAI Responses route", () => {
},
]
const warnings: string[] = []
const logger = Logger.make((entry) => {
if (entry.logLevel !== "Warn") return
warnings.push(String(Array.isArray(entry.message) ? entry.message[0] : entry.message))
})
yield* Effect.forEach(cases, (item) =>
LLMClient.generate(LLM.request({ model: item.model, prompt: "Say hello." }), {
webSocket: { execute: () => Effect.die("unexpected WebSocket request") },
@@ -1214,6 +1239,9 @@ describe("OpenAI Responses route", () => {
),
),
),
).pipe(Effect.provide(Logger.layer([logger])))
expect(warnings).toEqual(
cases.map(() => "Azure OpenAI Responses does not offer WebSocket for this endpoint; using HTTP"),
)
}),
)
@@ -1842,7 +1870,11 @@ describe("OpenAI Responses route", () => {
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
},
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
{ type: "message", role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
{
type: "message",
role: "user",
content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }],
},
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
@@ -4599,8 +4631,20 @@ describe("OpenAI Responses route", () => {
compileRequest(LLM.request({ model, messages: [response.message], providerOptions: { store } })),
)
expect(prepared.map((request) => request.body.input)).toEqual([
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
[{ type: "message", role: "user", content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }] }],
[
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
},
],
[
{
type: "message",
role: "user",
content: [{ type: "input_image", image_url: "data:image/png;base64,AQID" }],
},
],
])
}),
)
@@ -22,7 +22,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
pageMessages: () => ({ items: [] }),
vcsBranches: ["feature/api", "main", "origin/release"],
})
await page.route("**/api/vcs/branches?*", (route) => {
await page.route("**/api/vcs/branch?*", (route) => {
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
})
@@ -61,7 +61,7 @@ test("session settings use the remote server context", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
@@ -153,7 +153,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
@@ -180,14 +180,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { decision: "once" },
},
{
origin: serverA,
directory: undefined,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { reply: "once" },
body: { decision: "once" },
},
])
})
@@ -243,7 +243,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
@@ -280,7 +280,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
})
@@ -74,7 +74,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
@@ -85,11 +85,11 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = "steer"
row.delivery = input.action
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: "steer",
delivery: input.action,
})
},
}
@@ -48,7 +48,10 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
if (
request.method() === "DELETE" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
)
rejectRequests.push(request.url())
})
@@ -107,7 +110,7 @@ test("shows a pending permission dock", async ({ page }) => {
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ reply: "once" })
expect(request.postDataJSON()).toEqual({ decision: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -23,6 +23,7 @@ for (const custom of [false, true]) {
await page.goto(stressSessionHref(fixture.sourceID))
const trigger = page.getByRole("button", { name: "Session details", exact: true })
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
await expect(trigger).toBeEnabled()
await trigger.hover()
const tooltip = page.getByRole("tooltip")
@@ -587,7 +587,7 @@ async function mockServer(page: Page) {
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
@@ -278,7 +278,11 @@ async function installMotionProbe(page: Page) {
probe.resetAnchorOnMotion = false
}
probe.terminalAnchorGaps.push(anchorGap)
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
if (
panelGap &&
reviewRegion.getBoundingClientRect().height > 1 &&
terminalRegion.getBoundingClientRect().height > 1
)
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
if (!review) return
probe.paintGaps.push({
+5 -4
View File
@@ -112,10 +112,10 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branch", { success: Json }))
.add(HttpApiEndpoint.get("vcsDiff", "/api/vcs/diff", { success: Json }))
.add(HttpApiEndpoint.get("fsList", "/api/fs/list", { query: Query, success: Json }))
.add(
@@ -173,7 +173,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
HttpApiEndpoint.delete("sessionFormCancel", "/api/session/:sessionID/form/:formID", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
@@ -218,8 +218,9 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
HttpApiEndpoint.patch("sessionInboxUpdate", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: Schema.String },
payload: Schema.Struct({ delivery: Schema.Literals(["steer", "queue"]) }),
success: NoContent,
}),
)
+7 -3
View File
@@ -42,7 +42,7 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
}
type MockStreamWindow = Window & {
@@ -454,9 +454,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxSteer: (ctx) =>
sessionInboxUpdate: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
config.onInboxChange?.({
sessionID: ctx.params.sessionID,
inboxID: ctx.params.inboxID,
action: ctx.payload.delivery,
}),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/app",
"version": "2.0.3",
"version": "2.0.5",
"description": "",
"type": "module",
"exports": {
@@ -146,7 +146,7 @@ export function createHomeSessionsController(home: HomeController) {
const next = title.trim()
if (!next || next === sessionLabel(session)) return true
return ctx.sdk.api.session
.rename({ sessionID: session.id, title: next })
.update({ sessionID: session.id, title: next })
.then(() => {
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
// Rename advances time.updated server-side; re-sync the canonical
+12
View File
@@ -9,6 +9,10 @@ export function useNewSessionCommands(input: {
empty: () => boolean
open: () => void
}
workspace: {
enabled: () => boolean
cycle: () => void
}
}) {
const command = useCommand()
const dialog = useDialog()
@@ -40,5 +44,13 @@ export function useNewSessionCommands(input: {
disabled: input.project.empty(),
onSelect: input.project.open,
},
{
id: "session.location.cycle",
title: language.t("command.session.location.cycle"),
category: language.t("command.category.workspace"),
keybind: "mod+alt+l",
disabled: !input.workspace.enabled(),
onSelect: input.workspace.cycle,
},
])
}
+4
View File
@@ -60,6 +60,10 @@ export default function NewSessionPage(props: { draftId: string }) {
empty: project.empty,
open: () => project.setOpen(true),
},
workspace: {
enabled: workspace.bar.visible,
cycle: workspace.selection.cycle,
},
})
createEffect(() => {
if (!composer.ready()) return
@@ -1,5 +1,10 @@
import { describe, expect, test } from "bun:test"
import { resolveNewSessionBranch, resolveNewSessionGit, resolveNewSessionWorktree } from "./controller"
import {
cycleNewSessionWorktree,
resolveNewSessionBranch,
resolveNewSessionGit,
resolveNewSessionWorktree,
} from "./controller"
describe("new session workspace selection", () => {
test("uses main when the workspace bar is unavailable", () => {
@@ -64,4 +69,16 @@ describe("new session workspace selection", () => {
expect(resolveNewSessionGit({ projectVcs: "git" })).toBe(true)
expect(resolveNewSessionGit({})).toBe(false)
})
test("cycles between local and a new worktree", () => {
expect(cycleNewSessionWorktree({ current: "main" })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create" })).toBe("main")
})
test("includes the selected existing worktree in the cycle", () => {
const existing = "/project/feature"
expect(cycleNewSessionWorktree({ current: existing, existing })).toBe("main")
expect(cycleNewSessionWorktree({ current: "main", existing })).toBe("create")
expect(cycleNewSessionWorktree({ current: "create", existing })).toBe(existing)
})
})
@@ -37,6 +37,12 @@ export function resolveNewSessionGit(input: { projectVcs?: string; branch?: stri
return input.projectVcs === "git" || input.branch !== undefined
}
export function cycleNewSessionWorktree(input: { current: string; existing?: string }) {
if (input.current === "main") return "create"
if (input.current === "create") return input.existing ?? "main"
return "main"
}
export function createNewSessionWorkspaceController(input: {
selectedWorktree: () => string | undefined
selectedBranch: () => string | undefined
@@ -49,7 +55,10 @@ export function createNewSessionWorkspaceController(input: {
const data = useData()
const settings = useSettings()
const tabs = useTabs()
const [state, setState] = createStore({ search: "" })
const [state, setState] = createStore({
search: "",
existing: undefined as { projectID: string; directory: string } | undefined,
})
const searchBranches = debounce((search: string) => setState("search", search.trim()), 100)
const currentProject = createMemo(() => {
const projectID = data.location.info({ directory: sdk().directory })?.project.id
@@ -142,7 +151,7 @@ export function createNewSessionWorkspaceController(input: {
() => (visible() ? { directory: projectRoot(), search: state.search } : undefined),
({ directory, search }) =>
serverSDK.api.vcs
.branches({ location: { directory }, search, limit: 50 })
.branch.list({ location: { directory }, search, limit: 50 })
.then((response) => ({ directory, search, data: response.data }))
.catch(() => ({ directory, search, data: [] })),
)
@@ -159,6 +168,8 @@ export function createNewSessionWorkspaceController(input: {
createEffect(() => {
const selection = value()
if (selection === "main" || selection === "create") return
const project = currentProject()
if (project) setState("existing", { projectID: project.id, directory: selection })
void data.location.vcs.sync({ directory: selection }).catch(() => undefined)
})
const branch = createMemo(() =>
@@ -176,6 +187,20 @@ export function createNewSessionWorkspaceController(input: {
const local = workspaceSelectionDestination(worktree, project.worktree) === "main"
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
}
const select = (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
}
// The remembered worktree may have been removed since it was selected. Cycling to a directory the
// inventory no longer contains would resolve back to the fallback and leave the cycle stuck.
const existing = () => {
const project = currentProject()
const previous = state.existing
if (!project || previous?.projectID !== project.id) return
if (!worktreeDirectories().some((item) => sameDirectory(item, previous.directory))) return
return previous.directory
}
return {
selection: {
@@ -193,11 +218,8 @@ export function createNewSessionWorkspaceController(input: {
input.setSelectedBranch(undefined)
},
remember,
set: (worktree: string) => {
input.setSelectedBranch(undefined)
input.setSelectedWorktree(worktree)
remember(worktree)
},
set: select,
cycle: () => select(cycleNewSessionWorktree({ current: value(), existing: existing() })),
create: (branch: string) => {
input.setSelectedBranch(branch)
input.setSelectedWorktree("create")
+1
View File
@@ -93,6 +93,7 @@ export const dict = {
"command.session.previous.unseen": "Previous unread session",
"command.session.next.unseen": "Next unread session",
"command.session.archive": "Archive session",
"command.session.location.cycle": "Cycle session location",
"command.palette": "Command palette",
@@ -98,7 +98,7 @@ describe("createRequestQueue", () => {
test("classifies git and worktree endpoints as slow", () => {
expect(isSlowRequest("/api/vcs")).toBe(true)
expect(isSlowRequest("/api/vcs/branches")).toBe(true)
expect(isSlowRequest("/api/vcs/branch")).toBe(true)
expect(isSlowRequest("/api/worktree")).toBe(true)
expect(isSlowRequest("/api/vcsx")).toBe(false)
expect(isSlowRequest("/api/session")).toBe(false)
+3 -1
View File
@@ -130,7 +130,9 @@ export function createSessionQueue(input: {
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
return server.api.session.inbox
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
.catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
@@ -115,7 +115,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.reply({ sessionID: permission.sessionID, requestID: permission.id, decision: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
+1 -1
View File
@@ -112,7 +112,7 @@ export function createSessionRequestModel() {
setStore("responding", perm.id)
serverSDK.api.permission
.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -237,7 +237,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const replyMutation = useMutation(() => ({
mutationFn: (answer: FormAnswer) =>
serverSDK.api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
serverSDK.api.session.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
onMutate: () => {
props.onSubmit()
},
@@ -249,7 +249,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => serverSDK.api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
mutationFn: () => serverSDK.api.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import type { FormAnswer, FormCreated, SessionFormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
@@ -28,7 +28,7 @@ const provider: FormCreated["data"]["form"] = {
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const replies: SessionFormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
@@ -45,7 +45,7 @@ function fixture() {
}
},
},
reply: async (input: FormReplyInput) => {
reply: async (input: SessionFormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
@@ -1,4 +1,4 @@
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode/client/promise"
import type { FormInfo, FormOption, SessionFormReplyInput, FormStringField } from "@opencode/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
@@ -14,7 +14,7 @@ export function createWebSearchRequest(input: {
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: FormReplyInput) => Promise<unknown>
reply: (input: SessionFormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
@@ -93,7 +93,7 @@ export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: FormReplyInput) => Promise<unknown>
reply: (input: SessionFormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
@@ -80,13 +80,13 @@ export function createTimelineController(input: { session: TimelineSessionSource
)
})
const titleValue = createMemo(() => input.session.data.info()?.title)
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("command.session.new"))
const titleLabel = createMemo(() => sessionTitle(titleValue()) ?? language.t("session.tab.session"))
const parentMessages = createMemo(() => {
const id = input.session.data.parentID()
return id ? data.session.message.list(id) : emptyMessages
})
const parentTitle = createMemo(
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("session.tab.session"),
)
const childTaskDescription = createMemo(() => {
const id = input.session.identity.sessionID()
@@ -100,7 +100,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
parentID: input.session.data.parentID(),
taskDescription: childTaskDescription(),
title: titleLabel(),
fallback: language.t("command.session.new"),
fallback: language.t("session.tab.session"),
})
})
const showHeader = createMemo(() => !!input.session.identity.sessionID())
@@ -149,7 +149,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
if (!next || next === (titleLabel() ?? "")) return true
setPending("rename", true)
const success = await serverSDK.api.session
.rename({ sessionID: id, title: next })
.update({ sessionID: id, title: next })
.then(() => true)
.catch((error) => {
showToast({ title: language.t("common.requestFailed"), description: errorMessage(error) })
@@ -209,7 +209,7 @@ export function createTimelineController(input: { session: TimelineSessionSource
function DeleteDialog(props: { sessionID: string }) {
const name = createMemo(
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("session.tab.session"),
)
const confirm = async () => {
await remove(props.sessionID)
@@ -6,7 +6,13 @@ import { IconButton } from "@opencode/ui/icon-button"
import { TextInput } from "@opencode/ui/text-input"
import { showToast } from "@/shell/notifications/toast"
import fuzzysort from "fuzzysort"
import { DEFAULT_PALETTE_KEYBIND, formatKeybind, parseKeybind, useCommand } from "@/shell/commands/command"
import {
DEFAULT_PALETTE_KEYBIND,
formatKeybind,
keyFromKeyboardEvent,
parseKeybind,
useCommand,
} from "@/shell/commands/command"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
import { SettingsList } from "@/settings/list"
@@ -69,13 +75,6 @@ function isModifier(key: string) {
return key === "Shift" || key === "Control" || key === "Alt" || key === "Meta"
}
function normalizeKey(key: string) {
if (key === ",") return "comma"
if (key === "+") return "plus"
if (key === " ") return "space"
return key.toLowerCase()
}
function recordKeybind(event: KeyboardEvent) {
if (isModifier(event.key)) return
@@ -89,7 +88,7 @@ function recordKeybind(event: KeyboardEvent) {
if (event.altKey) parts.push("alt")
if (event.shiftKey) parts.push("shift")
const key = normalizeKey(event.key)
const key = keyFromKeyboardEvent(event)
if (!key) return
parts.push(key)
@@ -51,6 +51,15 @@ describe("command keybind helpers", () => {
).toBe(true)
})
test("matchKeybind uses the letter key for option-modified characters", () => {
expect(
matchKeybind(
parseKeybind("meta+alt+l"),
new KeyboardEvent("keydown", { key: "¬", code: "KeyL", metaKey: true, altKey: true }),
),
).toBe(true)
})
test("formatKeybind returns human readable output", () => {
const display = formatKeybind("ctrl+alt+arrowup")
+9 -2
View File
@@ -50,13 +50,20 @@ function normalizeKey(key: string) {
return key.toLowerCase()
}
export function keyFromKeyboardEvent(event: KeyboardEvent) {
const key = normalizeKey(event.key)
if (!event.altKey || /^[a-z0-9]$/.test(key)) return key
if (!event.code.startsWith("Key") || event.code.length !== 4) return key
return event.code.slice(3).toLowerCase()
}
function signature(key: string, ctrl: boolean, meta: boolean, shift: boolean, alt: boolean) {
const mask = (ctrl ? 1 : 0) | (meta ? 2 : 0) | (shift ? 4 : 0) | (alt ? 8 : 0)
return `${key}:${mask}`
}
function signatureFromEvent(event: KeyboardEvent) {
return signature(normalizeKey(event.key), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
return signature(keyFromKeyboardEvent(event), event.ctrlKey, event.metaKey, event.shiftKey, event.altKey)
}
function isAllowedEditableKeybind(id: string | undefined) {
@@ -179,7 +186,7 @@ export function parseKeybind(config: string): Keybind[] {
}
export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean {
const eventKey = normalizeKey(event.key)
const eventKey = keyFromKeyboardEvent(event)
for (const kb of keybinds) {
const keyMatch = kb.key === eventKey
+10 -5
View File
@@ -33,6 +33,15 @@ export default function Layout(props: ParentProps) {
},
install: installUpdate,
}
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
const debugTools = import.meta.env.DEV
? {
get visible() {
return state.debugTools
},
toggle: () => setState("debugTools", (value) => !value),
}
: undefined
return (
<TitlebarRightProvider>
@@ -53,11 +62,7 @@ export default function Layout(props: ParentProps) {
<Titlebar
update={update}
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
debugTools={
import.meta.env.DEV
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
: undefined
}
debugTools={debugTools}
/>
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
<Show when={verticalTabs()}>
@@ -116,7 +116,7 @@ function SessionTabEntry(props: {
ctx.data.session.remember({ ...value, title })
try {
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
await ctx.sdk.api.session.update({ sessionID: value.id, title })
} catch (err) {
const current = session()
const currentCtx = props.serverCtx
+1 -1
View File
@@ -792,7 +792,7 @@ function ChannelIndicator(props: {
if (!channel || channel === "prod") return null
const label = () => language.t(`titlebar.channel.${channel}`)
const debug = () => (channel === "dev" ? props.debugTools : undefined)
const debug = () => (channel === "dev" || channel === "local" ? props.debugTools : undefined)
return (
<Tooltip
placement={props.sidebar ? "right" : "bottom"}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"bin": {
+18 -21
View File
@@ -4,34 +4,31 @@ import { brotliCompressSync, constants } from "node:zlib"
import { collectFiles } from "./files"
export async function buildAppArchive(channel: string, options?: { skipBuild?: boolean }) {
if (options?.skipBuild) return compress({})
if (options?.skipBuild) return "{}"
const root = path.resolve(import.meta.dirname, "../../app")
await $`bun run build`
.cwd(root)
.env({ ...process.env, OPENCODE_CHANNEL: channel, VITE_OPENCODE_SERVER_MODE: "origin" })
const assets = Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
const encoding = isText(key) ? "utf8" : "base64"
return [key, { encoding, content: body.toString(encoding) }] as const
}),
return JSON.stringify(
Object.fromEntries(
await Promise.all(
(await collectFiles(path.join(root, "dist")))
.map((key) => key.replaceAll(path.sep, "/"))
.filter((key) => !key.endsWith(".map"))
.toSorted()
.map(async (key) => {
const source = path.join(root, "dist", key)
const body = Buffer.from(await Bun.file(source).arrayBuffer())
// Independent entries let the server materialize only assets the browser requests.
return [key, compress(body)] as const
}),
),
),
)
return compress(assets)
}
function compress(assets: object) {
return brotliCompressSync(JSON.stringify(assets), {
params: { [constants.BROTLI_PARAM_QUALITY]: 11 },
function compress(body: Buffer) {
return brotliCompressSync(body, {
params: { [constants.BROTLI_PARAM_QUALITY]: 6 },
}).toString("base64")
}
function isText(key: string) {
return key === "_headers" || /\.(?:css|html|js|json|svg|txt|webmanifest|xml)$/.test(key)
}
+2 -1
View File
@@ -74,7 +74,7 @@ const appAssetsPlugin: BunPlugin = {
}))
build.onLoad({ filter: /^opencode-app-assets$/, namespace: "opencode" }, () => ({
loader: "js",
contents: `export default ${JSON.stringify(appArchive)}`,
contents: `export default ${appArchive}`,
}))
},
}
@@ -140,6 +140,7 @@ export default { path: file, version: ${JSON.stringify(opencodePty.version)}, sh
...(executablePath ? { executablePath } : {}),
outfile: path.join(outdir, name, "bin", binary),
execArgv: [
"--smol",
`--user-agent=opencode/${Script.channel}/${Script.version}/cli`,
"--use-system-ca",
"--no-warnings",
+1 -2
View File
@@ -33,7 +33,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
const errors: Array<Promise<string>> = []
let failure: unknown
try {
await fs.mkdir(path.join(root, ".opencode"))
await fs.mkdir(path.join(root, ".opencode", "plugins"), { recursive: true })
spawnService()
spawnService()
const registration = await waitForRegistration()
@@ -54,7 +54,6 @@ try {
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
await fs.mkdir(path.dirname(plugin), { recursive: true })
await fs.writeFile(plugin, pluginSource())
await waitForPlugin(info.url, headers)
+1 -1
View File
@@ -165,7 +165,7 @@ export async function streamTurn(input: {
continue
}
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.form
await input.client.session.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue
+1 -1
View File
@@ -55,7 +55,7 @@ export async function replyPermission(input: {
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
reply,
decision: reply,
})
}
+21 -18
View File
@@ -1,48 +1,51 @@
import { Effect, FileSystem, Option } from "effect"
import { readFileSync } from "node:fs"
import path from "node:path"
import { brotliDecompressSync } from "node:zlib"
import { OPENCODE_LOCAL } from "./version"
export type AssetMap = Readonly<Record<string, string | Uint8Array>>
type EncodedAssetMap = Readonly<Record<string, { readonly content: string; readonly encoding: "utf8" | "base64" }>>
type EncodedAssetMap = Readonly<Record<string, string>>
export const load = Effect.fn("cli.app-assets.load")(function* () {
const embedded = yield* Effect.tryPromise(() => import("virtual:opencode-app-assets")).pipe(Effect.option)
if (Option.isSome(embedded) && embedded.value.default.length > 0) return decodeArchive(embedded.value.default)
if (Option.isSome(embedded) && (Object.keys(embedded.value.default).length > 0 || !OPENCODE_LOCAL))
return lazy(embedded.value.default, (key) =>
brotliDecompressSync(Buffer.from(embedded.value.default[key]!, "base64")),
)
if (!OPENCODE_LOCAL) return yield* Effect.fail(new Error("Web UI assets are missing from the CLI build"))
return decode(yield* sourceAssets())
return yield* sourceAssets()
})
function decodeArchive(archive: string) {
const body = brotliDecompressSync(Buffer.from(archive, "base64")).toString()
return decode(JSON.parse(body) as EncodedAssetMap)
}
const sourceAssets = Effect.fnUntraced(function* () {
const fs = yield* FileSystem.FileSystem
const root = path.resolve(import.meta.dirname, "../../app/dist")
const files = yield* fs.readDirectory(root, { recursive: true })
return Object.fromEntries(
const assets = Object.fromEntries(
(yield* Effect.forEach(
files.filter((file) => !file.endsWith(".map")),
Effect.fnUntraced(function* (file) {
const target = path.join(root, file)
if ((yield* fs.stat(target)).type === "Directory") return
const body = Buffer.from(yield* fs.readFile(target))
const encoding = isText(file) ? "utf8" : "base64"
return [file, { encoding, content: body.toString(encoding) }] as const
return [file, target] as const
}),
{ concurrency: "unbounded" },
)).filter((asset) => asset !== undefined),
)
return lazy(assets, (key) => readFileSync(assets[key]!))
})
function decode(assets: EncodedAssetMap): AssetMap {
return Object.fromEntries(
Object.entries(assets).map(([key, asset]) => [
key,
asset.encoding === "utf8" ? asset.content : Buffer.from(asset.content, "base64"),
]),
function lazy(assets: EncodedAssetMap, load: (key: string) => Uint8Array): AssetMap {
// Immutable browser caching makes retaining decompressed copies in the server unnecessary.
return new Proxy(
{},
{
get: (_, key) => {
if (typeof key !== "string" || assets[key] === undefined) return
const body = load(key)
return isText(key) ? Buffer.from(body).toString() : body
},
},
)
}
+4 -4
View File
@@ -145,7 +145,7 @@ export async function runNonInteractivePrompt(input: Input) {
.reply({
sessionID: input.sessionID,
requestID: request.id,
reply: input.auto ? "once" : "reject",
decision: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
@@ -155,7 +155,7 @@ export async function runNonInteractivePrompt(input: Input) {
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.form.cancel(
await input.client.session.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
@@ -695,10 +695,10 @@ export async function runNonInteractivePrompt(input: Input) {
const [permissions, forms, globals] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.session.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form.request
: input.client.form
.list({
location: { directory: input.location.directory },
})
+1 -1
View File
@@ -128,7 +128,7 @@ async function execute(input: RunCommandInput, prepared: Prepared, endpoint: End
const model = target.model ? { providerID: target.model.providerID, modelID: target.model.id } : undefined
const variant = target.model?.variant
if (!target.resume && input.title !== undefined) {
await client.session.rename({
await client.session.update({
sessionID: target.session.id,
title: input.title || prepared.message.slice(0, 50) + (prepared.message.length > 50 ? "..." : ""),
})
+5 -2
View File
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
import { Env } from "./env"
import { ServiceConfig } from "./services/service-config"
import { ServiceRegistration } from "./services/service-registration"
import { ShellEnvironment } from "./shell-environment"
import { Updater } from "./services/updater"
import { WebUi } from "./services/web-ui"
import { databasePath } from "./database-path"
@@ -28,6 +29,9 @@ export type Options = {
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
export const run = Effect.fnUntraced(function* (options: Options) {
// A managed service may have been started by a GUI client with launchd's environment. Adopt the
// login shell's before anything reads process.env, including the OPENCODE_* settings below.
if (options.mode === "service") yield* ShellEnvironment.adopt()
return yield* processEffect(options).pipe(
Effect.provide(
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
@@ -38,9 +42,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
],
}),
),
Effect.provide(NodeServices.layer),
)
})
}, Effect.provide(NodeServices.layer))
const processEffect = Effect.fnUntraced(function* (options: Options) {
const inherited = process.env.OPENCODE_PTY_HANDOFF
+5 -4
View File
@@ -26,11 +26,12 @@ export const handler = Effect.fn("cli.web-ui.handler")(function* (options?: { re
function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: AssetMap) {
const key = url.pathname.replace(/^\//, "")
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && assets[key] === undefined)
const requested = assets[key]
if ((key.startsWith("_assets/") || key.startsWith("icons/")) && requested === undefined)
return Effect.succeed(HttpServerResponse.empty({ status: 404, headers: { "cache-control": "no-store" } }))
const name = assets[key] !== undefined ? key : "index.html"
const file = assets[name]
if (!file) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
const name = requested !== undefined ? key : "index.html"
const file = requested ?? assets["index.html"]
if (file === undefined) return Effect.succeed(HttpServerResponse.empty({ status: 404 }))
if (request.method !== "GET" && request.method !== "HEAD")
return Effect.succeed(HttpServerResponse.empty({ status: 405 }))
const html = name === "index.html"
+80
View File
@@ -0,0 +1,80 @@
export * as ShellEnvironment from "./shell-environment"
import { randomUUID } from "node:crypto"
import { Effect } from "effect"
import { ChildProcess } from "effect/unstable/process"
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
// Set on the probe shell so an rc file that itself starts opencode cannot recurse into another probe.
export const RESOLVING = "OPENCODE_RESOLVING_SHELL_ENVIRONMENT"
// Shell bookkeeping that describes the probe process, not the user's environment.
const TRANSIENT = new Set([RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
export type Variables = Readonly<Record<string, string | undefined>>
/**
* Resolve the environment of the user's interactive login shell when the current environment did
* not come from one.
*
* A background service elected by a GUI client (the desktop app, an editor, a login item) inherits
* launchd's environment: PATH is `/usr/bin:/bin:/usr/sbin:/sbin` and nothing exported from
* `.zshrc` or `.bashrc` exists. Every process the server spawns extends `process.env`, so stdio MCP
* servers using `#!/usr/bin/env node`, `npx`, `uvx`, formatters, and hooks fail with no useful error
* while the bash tool, which prefers the client's terminal environment, keeps working. Editors such
* as VS Code and Zed resolve the login shell at startup for the same reason.
*
* Returns `undefined` when there is nothing to adopt: Windows, an environment that already passed
* through a shell (`SHLVL`), a probe already in progress, or a shell that fails to report.
*/
export const resolve = Effect.fnUntraced(function* (env: Variables = process.env) {
if (process.platform === "win32") return undefined
if (env[RESOLVING] !== undefined || env.SHLVL !== undefined) return undefined
const spawner = yield* ChildProcessSpawner
const marker = randomUUID()
// Interactive and login: most PATH edits live in `.zshrc`/`.bashrc`, which only interactive shells read.
// `env -0` keeps values containing newlines intact.
const output = yield* spawner
.string(
ChildProcess.make(env.SHELL || "/bin/sh", ["-ilc", `printf '%s' '${marker}'; env -0; printf '%s' '${marker}'`], {
env: { ...env, [RESOLVING]: "1" },
stdin: "ignore",
// Interactive shells without a terminal warn on stderr; nothing reads it, so never let it fill.
stderr: "ignore",
}),
)
.pipe(
Effect.timeout("10 seconds"),
Effect.tapError((error) => Effect.logWarning("shell environment unavailable", { error })),
Effect.orElseSucceed(() => undefined),
)
if (output === undefined) return undefined
const start = output.indexOf(marker)
const end = output.lastIndexOf(marker)
if (start === -1 || end === start) {
yield* Effect.logWarning("shell environment unavailable", { shell: env.SHELL, reason: "missing markers" })
return undefined
}
return Object.fromEntries(
output
.slice(start + marker.length, end)
.split("\0")
.flatMap((entry) => {
const separator = entry.indexOf("=")
if (separator <= 0) return []
const key = entry.slice(0, separator)
return TRANSIENT.has(key) ? [] : [[key, entry.slice(separator + 1)] as const]
}),
)
})
/** Apply the resolved login-shell environment to this process before anything reads `process.env`. */
export const adopt = Effect.fnUntraced(function* () {
const variables = yield* resolve()
if (variables === undefined) return
Object.assign(process.env, variables)
yield* Effect.logInfo("shell environment adopted", {
shell: process.env.SHELL,
variables: Object.keys(variables).length,
})
})
+1 -1
View File
@@ -1,4 +1,4 @@
declare module "virtual:opencode-app-assets" {
const archive: string
const archive: Readonly<Record<string, string>>
export default archive
}
+3 -1
View File
@@ -748,7 +748,9 @@ describe("acp event behavior", () => {
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
fixture.requests.some(
(request) => request.method === "DELETE" && request.path === "/api/session/ses_form/form/frm_question",
),
).toBe(true)
} finally {
await fixture.stop()
@@ -574,7 +574,7 @@ function permissionReplies(fixture: Fixture) {
return fixture.requests.flatMap((request): Array<[string, string]> => {
const match = /^\/api\/session\/[^/]+\/permission\/([^/]+)\/reply$/.exec(request.path)
if (!match?.[1] || !request.body || typeof request.body !== "object") return []
const reply = "reply" in request.body ? request.body.reply : undefined
const reply = "decision" in request.body ? request.body.decision : undefined
return typeof reply === "string" ? [[decodeURIComponent(match[1]), reply]] : []
})
}
+3 -3
View File
@@ -128,7 +128,7 @@ export function createSseFixture(options: FixtureOptions = {}) {
const permission = /^\/api\/session\/([^/]+)\/permission\/([^/]+)\/reply$/.exec(url.pathname)
if (permission?.[1] && permission[2]) {
const reply = stringField(body, "reply")
const reply = stringField(body, "decision")
if (!reply) return new Response(null, { status: 400 })
await options.onPermissionReply?.({
sessionID: decodeURIComponent(permission[1]),
@@ -140,8 +140,8 @@ export function createSseFixture(options: FixtureOptions = {}) {
return new Response(null, { status: 204 })
}
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)\/cancel$/.exec(url.pathname)
if (form?.[1] && form[2]) {
const form = /^\/api\/session\/([^/]+)\/form\/([^/]+)$/.exec(url.pathname)
if (request.method === "DELETE" && form?.[1] && form[2]) {
await options.onFormCancel?.({
sessionID: decodeURIComponent(form[1]),
formID: decodeURIComponent(form[2]),
+11 -11
View File
@@ -240,17 +240,17 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.form, "list").mockImplementation(
spyOn(sdk.session.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.form.request, "list").mockImplementation(
spyOn(sdk.form, "list").mockImplementation(
() =>
ok({
location: { ...location, project: { id: "proj_1", directory: location.directory } },
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
@@ -426,10 +426,10 @@ describe("runNonInteractivePrompt", () => {
"x-opencode-directory": "%2Fwork%20tree",
},
}
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
})
@@ -440,10 +440,10 @@ describe("runNonInteractivePrompt", () => {
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.request.list).not.toHaveBeenCalled()
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalled()
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
@@ -0,0 +1,68 @@
import { NodeServices } from "@effect/platform-node"
import { afterAll, beforeAll, expect, test } from "bun:test"
import { Effect } from "effect"
import fs from "node:fs/promises"
import os from "node:os"
import path from "node:path"
import { ShellEnvironment } from "../src/shell-environment"
// A stand-in login shell: it records every invocation, applies "rc file" exports, then runs the
// probe script exactly as `$SHELL -ilc <script>` would.
let root: string
let shell: string
let invocations: string
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-shell-env-"))
shell = path.join(root, "login-shell")
invocations = path.join(root, "invocations")
await fs.writeFile(
shell,
[
"#!/bin/sh",
`printf '%s\\n' "$1" >> '${invocations}'`,
`[ "$1" = -ilc ] || exit 64`,
'export PATH="/rc/bin:$PATH"',
"export FROM_RC=yes",
"export MULTILINE='first",
"second'",
'export SAW_GUARD="${OPENCODE_RESOLVING_SHELL_ENVIRONMENT:-unset}"',
"export SHLVL=1 PWD=/rc OLDPWD=/rc",
'eval "$2"',
].join("\n"),
{ mode: 0o755 },
)
})
afterAll(() => fs.rm(root, { recursive: true, force: true }))
const skip = process.platform === "win32"
const launchd = { SHELL: "", HOME: "", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }
const resolve = (env: Record<string, string | undefined>) =>
Effect.runPromise(
ShellEnvironment.resolve({ ...launchd, ...env, SHELL: env.SHELL ?? shell, HOME: root }).pipe(
Effect.provide(NodeServices.layer),
),
)
test.skipIf(skip)("adopts the login shell's exports over a launchd environment", async () => {
const variables = await resolve({})
expect(variables?.PATH).toBe("/rc/bin:/usr/bin:/bin:/usr/sbin:/sbin")
expect(variables?.FROM_RC).toBe("yes")
expect(variables?.MULTILINE).toBe("first\nsecond")
expect(variables?.SAW_GUARD).toBe("1")
for (const key of [ShellEnvironment.RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
expect(variables).not.toHaveProperty(key)
})
test.skipIf(skip)("does not probe when the environment already came from a shell or a probe", async () => {
const before = await fs.readFile(invocations, "utf8").catch(() => "")
expect(await resolve({ SHLVL: "1" })).toBeUndefined()
expect(await resolve({ [ShellEnvironment.RESOLVING]: "1" })).toBeUndefined()
expect(await fs.readFile(invocations, "utf8").catch(() => "")).toBe(before)
})
test.skipIf(skip)("falls back to the inherited environment when the shell cannot report", async () => {
expect(await resolve({ SHELL: "/bin/false" })).toBeUndefined()
expect(await resolve({ SHELL: path.join(root, "missing-shell") })).toBeUndefined()
})
+2 -2
View File
@@ -31,7 +31,7 @@ function appAssetsPlugin(archive: string): Plugin {
},
load(id) {
if (id !== "\0virtual:opencode-app-assets") return
return `export default ${JSON.stringify(archive)}`
return `export default ${archive}`
},
}
}
@@ -292,5 +292,5 @@ export default mainConfig({
channel: process.env.OPENCODE_CHANNEL ?? "local",
assetHash: "local",
target: nodeTarget(process.platform, process.arch),
appArchive: "",
appArchive: "{}",
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.5",
"type": "module",
"license": "MIT",
"repository": {
+74 -82
View File
@@ -23,8 +23,8 @@ import type { Event } from "@opencode/schema/event"
import type { EventLog } from "@opencode/schema/event-log"
import type { Shell } from "@opencode/schema/shell"
import type { Provider } from "@opencode/schema/provider"
import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Integration } from "@opencode/schema/integration"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
@@ -233,9 +233,13 @@ export type SessionSwitchModelOperation<E = never> = (
input: SessionSwitchModelInput,
) => Effect.Effect<SessionSwitchModelOutput, E>
export type SessionRenameInput = { readonly sessionID: Session.ID; readonly title: string }
export type SessionRenameOutput = void
export type SessionRenameOperation<E = never> = (input: SessionRenameInput) => Effect.Effect<SessionRenameOutput, E>
export type SessionUpdateInput = {
readonly sessionID: Session.ID
readonly title?: string | undefined
readonly permissions?: Permission.Ruleset | undefined
}
export type SessionUpdateOutput = void
export type SessionUpdateOperation<E = never> = (input: SessionUpdateInput) => Effect.Effect<SessionUpdateOutput, E>
export type SessionMoveInput = {
readonly sessionID: Session.ID
@@ -360,17 +364,15 @@ export type SessionInboxCancelOperation<E = never> = (
input: SessionInboxCancelInput,
) => Effect.Effect<SessionInboxCancelOutput, E>
export type SessionInboxSteerInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxSteerOutput = void
export type SessionInboxSteerOperation<E = never> = (
input: SessionInboxSteerInput,
) => Effect.Effect<SessionInboxSteerOutput, E>
export type SessionInboxQueueInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxQueueOutput = void
export type SessionInboxQueueOperation<E = never> = (
input: SessionInboxQueueInput,
) => Effect.Effect<SessionInboxQueueOutput, E>
export type SessionInboxUpdateInput = {
readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID
readonly delivery: SessionInbox.Delivery
}
export type SessionInboxUpdateOutput = void
export type SessionInboxUpdateOperation<E = never> = (
input: SessionInboxUpdateInput,
) => Effect.Effect<SessionInboxUpdateOutput, E>
export type SessionInstructionsEntryListInput = { readonly sessionID: Session.ID }
export type SessionInstructionsEntryListOutput = ReadonlyArray<InstructionEntry.Info>
@@ -513,7 +515,7 @@ export type SessionLogOutput =
readonly id: Event.ID
readonly created: number
readonly metadata?: { readonly [x: string]: unknown } | undefined
readonly type: "session.permissions.updated"
readonly type: "session.permissions"
readonly durable: { readonly aggregateID: string; readonly seq: Event.Seq; readonly version: Event.Version }
readonly location?:
| {
@@ -1325,6 +1327,44 @@ export type SessionMessageGetOperation<E = never> = (
input: SessionMessageGetInput,
) => Effect.Effect<SessionMessageGetOutput, E>
export type SessionFormListInput = { readonly sessionID: string }
export type SessionFormListOutput = ReadonlyArray<Form.Info>
export type SessionFormListOperation<E = never> = (
input: SessionFormListInput,
) => Effect.Effect<SessionFormListOutput, E>
export type SessionFormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type SessionFormCreateOutput = Form.Info
export type SessionFormCreateOperation<E = never> = (
input: SessionFormCreateInput,
) => Effect.Effect<SessionFormCreateOutput, E>
export type SessionFormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormGetOutput = Form.Detail
export type SessionFormGetOperation<E = never> = (input: SessionFormGetInput) => Effect.Effect<SessionFormGetOutput, E>
export type SessionFormReplyInput = {
readonly sessionID: string
readonly formID: Form.ID
readonly answer: Form.Answer
}
export type SessionFormReplyOutput = void
export type SessionFormReplyOperation<E = never> = (
input: SessionFormReplyInput,
) => Effect.Effect<SessionFormReplyOutput, E>
export type SessionFormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormCancelOutput = void
export type SessionFormCancelOperation<E = never> = (
input: SessionFormCancelInput,
) => Effect.Effect<SessionFormCancelOutput, E>
export type SessionEnvironmentInput = {
readonly sessionID: Session.ID
readonly variables: { readonly [x: string]: string }
@@ -1350,7 +1390,7 @@ export interface SessionApi<E = never> {
readonly fork: SessionForkOperation<E>
readonly switchAgent: SessionSwitchAgentOperation<E>
readonly switchModel: SessionSwitchModelOperation<E>
readonly rename: SessionRenameOperation<E>
readonly update: SessionUpdateOperation<E>
readonly move: SessionMoveOperation<E>
readonly prompt: SessionPromptOperation<E>
readonly command: SessionCommandOperation<E>
@@ -1369,8 +1409,7 @@ export interface SessionApi<E = never> {
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
readonly update: SessionInboxUpdateOperation<E>
}
readonly instructions: {
readonly entry: {
@@ -1384,6 +1423,13 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: { readonly get: SessionMessageGetOperation<E> }
readonly form: {
readonly list: SessionFormListOperation<E>
readonly create: SessionFormCreateOperation<E>
readonly get: SessionFormGetOperation<E>
readonly reply: SessionFormReplyOperation<E>
readonly cancel: SessionFormCancelOperation<E>
}
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
@@ -1684,50 +1730,12 @@ export interface ProjectApi<E = never> {
readonly update: ProjectUpdateOperation<E>
}
export type FormRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormRequestListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (
input?: FormRequestListInput,
) => Effect.Effect<FormRequestListOutput, E>
export type FormListInput = { readonly sessionID: string }
export type FormListOutput = ReadonlyArray<Form.Info>
export type FormListOperation<E = never> = (input: FormListInput) => Effect.Effect<FormListOutput, E>
export type FormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type FormCreateOutput = Form.Info
export type FormCreateOperation<E = never> = (input: FormCreateInput) => Effect.Effect<FormCreateOutput, E>
export type FormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormGetOutput = Form.Info
export type FormGetOperation<E = never> = (input: FormGetInput) => Effect.Effect<FormGetOutput, E>
export type FormStateInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormStateOutput = Form.State
export type FormStateOperation<E = never> = (input: FormStateInput) => Effect.Effect<FormStateOutput, E>
export type FormReplyInput = { readonly sessionID: string; readonly formID: Form.ID; readonly answer: Form.Answer }
export type FormReplyOutput = void
export type FormReplyOperation<E = never> = (input: FormReplyInput) => Effect.Effect<FormReplyOutput, E>
export type FormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormCancelOutput = void
export type FormCancelOperation<E = never> = (input: FormCancelInput) => Effect.Effect<FormCancelOutput, E>
export type FormListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormListOperation<E = never> = (input?: FormListInput) => Effect.Effect<FormListOutput, E>
export interface FormApi<E = never> {
readonly request: { readonly list: FormRequestListOperation<E> }
readonly list: FormListOperation<E>
readonly create: FormCreateOperation<E>
readonly get: FormGetOperation<E>
readonly state: FormStateOperation<E>
readonly reply: FormReplyOperation<E>
readonly cancel: FormCancelOperation<E>
}
export type PermissionRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -1777,7 +1785,7 @@ export type PermissionGetOperation<E = never> = (input: PermissionGetInput) => E
export type PermissionReplyInput = {
readonly sessionID: Session.ID
readonly requestID: Permission.ID
readonly reply: Permission.Reply
readonly decision: Permission.Reply
readonly message?: string | undefined
}
export type PermissionReplyOutput = void
@@ -1785,12 +1793,6 @@ export type PermissionReplyOperation<E = never> = (
input: PermissionReplyInput,
) => Effect.Effect<PermissionReplyOutput, E>
export type PermissionRulesInput = { readonly sessionID: Session.ID; readonly permissions: Permission.Ruleset }
export type PermissionRulesOutput = void
export type PermissionRulesOperation<E = never> = (
input: PermissionRulesInput,
) => Effect.Effect<PermissionRulesOutput, E>
export interface PermissionApi<E = never> {
readonly request: { readonly list: PermissionRequestListOperation<E> }
readonly saved: { readonly list: PermissionSavedListOperation<E>; readonly remove: PermissionSavedRemoveOperation<E> }
@@ -1798,7 +1800,6 @@ export interface PermissionApi<E = never> {
readonly list: PermissionListOperation<E>
readonly get: PermissionGetOperation<E>
readonly reply: PermissionReplyOperation<E>
readonly rules: PermissionRulesOperation<E>
}
export type FileListInput = {
@@ -2101,7 +2102,7 @@ export type ShellCreateInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly command: string
readonly cwd?: string | undefined
readonly timeout: number
readonly timeout?: number | undefined
readonly metadata?: { readonly [x: string]: unknown } | undefined
}
export type ShellCreateOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
@@ -2114,14 +2115,6 @@ export type ShellGetInput = {
export type ShellGetOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
export type ShellGetOperation<E = never> = (input: ShellGetInput) => Effect.Effect<ShellGetOutput, E>
export type ShellTimeoutInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly timeout: number
}
export type ShellTimeoutOutput = { readonly location: Location.PublicRef; readonly data: Shell.Info }
export type ShellTimeoutOperation<E = never> = (input: ShellTimeoutInput) => Effect.Effect<ShellTimeoutOutput, E>
export type ShellOutputInput = {
readonly id: Shell.ID
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -2150,7 +2143,6 @@ export interface ShellApi<E = never> {
readonly list: ShellListOperation<E>
readonly create: ShellCreateOperation<E>
readonly get: ShellGetOperation<E>
readonly timeout: ShellTimeoutOperation<E>
readonly output: ShellOutputOperation<E>
readonly remove: ShellRemoveOperation<E>
}
@@ -2213,13 +2205,13 @@ export type VcsStatusInput = { readonly location?: { readonly directory?: string
export type VcsStatusOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Vcs.FileStatus> }
export type VcsStatusOperation<E = never> = (input?: VcsStatusInput) => Effect.Effect<VcsStatusOutput, E>
export type VcsBranchesInput = {
export type VcsBranchListInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
readonly search?: string | undefined
readonly limit?: number | undefined
}
export type VcsBranchesOutput = { readonly location: Location.PublicRef; readonly data: Vcs.BranchList }
export type VcsBranchesOperation<E = never> = (input?: VcsBranchesInput) => Effect.Effect<VcsBranchesOutput, E>
export type VcsBranchListOutput = { readonly location: Location.PublicRef; readonly data: Vcs.BranchList }
export type VcsBranchListOperation<E = never> = (input?: VcsBranchListInput) => Effect.Effect<VcsBranchListOutput, E>
export type VcsDiffInput = {
readonly location?: { readonly directory?: string | undefined } | undefined
@@ -2234,7 +2226,7 @@ export interface VcsApi<E = never> {
readonly get: VcsGetOperation<E>
readonly base: VcsBaseOperation<E>
readonly status: VcsStatusOperation<E>
readonly branches: VcsBranchesOperation<E>
readonly branch: { readonly list: VcsBranchListOperation<E> }
readonly diff: VcsDiffOperation<E>
}
+87 -129
View File
@@ -39,8 +39,8 @@ import type {
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionRenameInput,
SessionRenameOutput,
SessionUpdateInput,
SessionUpdateOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -71,10 +71,8 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -91,6 +89,16 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -150,20 +158,8 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -178,8 +174,6 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileListInput,
FileListOutput,
FileFindInput,
@@ -227,8 +221,6 @@ import type {
ShellCreateOutput,
ShellGetInput,
ShellGetOutput,
ShellTimeoutInput,
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellRemoveInput,
@@ -249,8 +241,8 @@ import type {
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsBranchListInput,
VcsBranchListOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -450,11 +442,12 @@ const EndpointSessionSwitchModel = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionRename = (raw: RawClient["server.session"]) => (input: SessionRenameInput) =>
preserveEffect<SessionRenameOutput>()(
raw["session.rename"]({ params: { sessionID: input["sessionID"] }, payload: { title: input["title"] } }).pipe(
Effect.mapError(mapClientError),
),
const EndpointSessionUpdate = (raw: RawClient["server.session"]) => (input: SessionUpdateInput) =>
preserveEffect<SessionUpdateOutput>()(
raw["session.update"]({
params: { sessionID: input["sessionID"] },
payload: { title: input["title"], permissions: input["permissions"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionMove = (raw: RawClient["server.session"]) => (input: SessionMoveInput) =>
@@ -605,18 +598,12 @@ const EndpointSessionInboxCancel = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionInboxSteer = (raw: RawClient["server.session"]) => (input: SessionInboxSteerInput) =>
preserveEffect<SessionInboxSteerOutput>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionInboxQueue = (raw: RawClient["server.session"]) => (input: SessionInboxQueueInput) =>
preserveEffect<SessionInboxQueueOutput>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
const EndpointSessionInboxUpdate = (raw: RawClient["server.session"]) => (input: SessionInboxUpdateInput) =>
preserveEffect<SessionInboxUpdateOutput>()(
raw["session.inbox.update"]({
params: { sessionID: input["sessionID"], inboxID: input["inboxID"] },
payload: { delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionInstructionsEntryList =
@@ -686,6 +673,48 @@ const EndpointSessionMessageGet = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionFormList = (raw: RawClient["server.session"]) => (input: SessionFormListInput) =>
preserveEffect<SessionFormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormCreate = (raw: RawClient["server.session"]) => (input: SessionFormCreateInput) =>
preserveEffect<SessionFormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormGet = (raw: RawClient["server.session"]) => (input: SessionFormGetInput) =>
preserveEffect<SessionFormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: SessionFormReplyInput) =>
preserveEffect<SessionFormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) =>
preserveEffect<SessionFormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
preserveEffect<SessionEnvironmentOutput>()(
raw["session.environment"]({
@@ -713,7 +742,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
fork: EndpointSessionFork(raw),
switchAgent: EndpointSessionSwitchAgent(raw),
switchModel: EndpointSessionSwitchModel(raw),
rename: EndpointSessionRename(raw),
update: EndpointSessionUpdate(raw),
move: EndpointSessionMove(raw),
prompt: EndpointSessionPrompt(raw),
command: EndpointSessionCommand(raw),
@@ -732,8 +761,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
steer: EndpointSessionInboxSteer(raw),
queue: EndpointSessionInboxQueue(raw),
update: EndpointSessionInboxUpdate(raw),
},
instructions: {
entry: {
@@ -747,6 +775,13 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
interrupt: EndpointSessionInterrupt(raw),
background: EndpointSessionBackground(raw),
message: { get: EndpointSessionMessageGet(raw) },
form: {
list: EndpointSessionFormList(raw),
create: EndpointSessionFormCreate(raw),
get: EndpointSessionFormGet(raw),
reply: EndpointSessionFormReply(raw),
cancel: EndpointSessionFormCancel(raw),
},
environment: EndpointSessionEnvironment(raw),
view: EndpointSessionView(raw),
})
@@ -1006,70 +1041,12 @@ const adaptGroupProject = (raw: RawClient["server.project"]) => ({
update: EndpointProjectUpdate(raw),
})
const EndpointFormRequestList = (raw: RawClient["server.form"]) => (input?: FormRequestListInput) =>
preserveEffect<FormRequestListOutput>()(
raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormList = (raw: RawClient["server.form"]) => (input: FormListInput) =>
const EndpointFormList = (raw: RawClient["server.form"]) => (input?: FormListInput) =>
preserveEffect<FormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["form.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormCreate = (raw: RawClient["server.form"]) => (input: FormCreateInput) =>
preserveEffect<FormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormGet = (raw: RawClient["server.form"]) => (input: FormGetInput) =>
preserveEffect<FormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormState = (raw: RawClient["server.form"]) => (input: FormStateInput) =>
preserveEffect<FormStateOutput>()(
raw["session.form.state"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormReply = (raw: RawClient["server.form"]) => (input: FormReplyInput) =>
preserveEffect<FormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormCancel = (raw: RawClient["server.form"]) => (input: FormCancelInput) =>
preserveEffect<FormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupForm = (raw: RawClient["server.form"]) => ({
request: { list: EndpointFormRequestList(raw) },
list: EndpointFormList(raw),
create: EndpointFormCreate(raw),
get: EndpointFormGet(raw),
state: EndpointFormState(raw),
reply: EndpointFormReply(raw),
cancel: EndpointFormCancel(raw),
})
const adaptGroupForm = (raw: RawClient["server.form"]) => ({ list: EndpointFormList(raw) })
const EndpointPermissionRequestList = (raw: RawClient["server.permission"]) => (input?: PermissionRequestListInput) =>
preserveEffect<PermissionRequestListOutput>()(
@@ -1128,15 +1105,7 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
preserveEffect<PermissionReplyOutput>()(
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointPermissionRules = (raw: RawClient["server.permission"]) => (input: PermissionRulesInput) =>
preserveEffect<PermissionRulesOutput>()(
raw["session.permission.rules"]({
params: { sessionID: input["sessionID"] },
payload: { permissions: input["permissions"] },
payload: { decision: input["decision"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1147,7 +1116,6 @@ const adaptGroupPermission = (raw: RawClient["server.permission"]) => ({
list: EndpointPermissionList(raw),
get: EndpointPermissionGet(raw),
reply: EndpointPermissionReply(raw),
rules: EndpointPermissionRules(raw),
})
const EndpointFileList = (raw: RawClient["server.fs"]) => (input?: FileListInput) =>
@@ -1393,15 +1361,6 @@ const EndpointShellGet = (raw: RawClient["server.shell"]) => (input: ShellGetInp
),
)
const EndpointShellTimeout = (raw: RawClient["server.shell"]) => (input: ShellTimeoutInput) =>
preserveEffect<ShellTimeoutOutput>()(
raw["shell.timeout"]({
params: { id: input["id"] },
query: { location: input["location"] },
payload: { timeout: input["timeout"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointShellOutput = (raw: RawClient["server.shell"]) => (input: ShellOutputInput) =>
preserveEffect<ShellOutputOutput>()(
raw["shell.output"]({
@@ -1421,7 +1380,6 @@ const adaptGroupShell = (raw: RawClient["server.shell"]) => ({
list: EndpointShellList(raw),
create: EndpointShellCreate(raw),
get: EndpointShellGet(raw),
timeout: EndpointShellTimeout(raw),
output: EndpointShellOutput(raw),
remove: EndpointShellRemove(raw),
})
@@ -1485,9 +1443,9 @@ const EndpointVcsStatus = (raw: RawClient["server.vcs"]) => (input?: VcsStatusIn
raw["vcs.status"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointVcsBranches = (raw: RawClient["server.vcs"]) => (input?: VcsBranchesInput) =>
preserveEffect<VcsBranchesOutput>()(
raw["vcs.branches"]({
const EndpointVcsBranchList = (raw: RawClient["server.vcs"]) => (input?: VcsBranchListInput) =>
preserveEffect<VcsBranchListOutput>()(
raw["vcs.branch.list"]({
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
}).pipe(Effect.mapError(mapClientError)),
)
@@ -1503,7 +1461,7 @@ const adaptGroupVcs = (raw: RawClient["server.vcs"]) => ({
get: EndpointVcsGet(raw),
base: EndpointVcsBase(raw),
status: EndpointVcsStatus(raw),
branches: EndpointVcsBranches(raw),
branch: { list: EndpointVcsBranchList(raw) },
diff: EndpointVcsDiff(raw),
})
+107 -159
View File
@@ -33,8 +33,8 @@ import type {
SessionSwitchAgentOutput,
SessionSwitchModelInput,
SessionSwitchModelOutput,
SessionRenameInput,
SessionRenameOutput,
SessionUpdateInput,
SessionUpdateOutput,
SessionMoveInput,
SessionMoveOutput,
SessionPromptInput,
@@ -65,10 +65,8 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -85,6 +83,16 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -144,20 +152,8 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -172,8 +168,6 @@ import type {
PermissionGetOutput,
PermissionReplyInput,
PermissionReplyOutput,
PermissionRulesInput,
PermissionRulesOutput,
FileReadInput,
FileReadOutput,
FileListInput,
@@ -223,8 +217,6 @@ import type {
ShellCreateOutput,
ShellGetInput,
ShellGetOutput,
ShellTimeoutInput,
ShellTimeoutOutput,
ShellOutputInput,
ShellOutputOutput,
ShellRemoveInput,
@@ -245,8 +237,8 @@ import type {
VcsBaseOutput,
VcsStatusInput,
VcsStatusOutput,
VcsBranchesInput,
VcsBranchesOutput,
VcsBranchListInput,
VcsBranchListOutput,
VcsDiffInput,
VcsDiffOutput,
DebugLocationListOutput,
@@ -644,12 +636,12 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
rename: (input: SessionRenameInput, requestOptions?: RequestOptions) =>
request<SessionRenameOutput>(
update: (input: SessionUpdateInput, requestOptions?: RequestOptions) =>
request<SessionUpdateOutput>(
{
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}`,
body: { title: input["title"] },
body: { title: input["title"], permissions: input["permissions"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -851,27 +843,17 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>(
update: (input: SessionInboxUpdateInput, requestOptions?: RequestOptions) =>
request<SessionInboxUpdateOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
body: { delivery: input["delivery"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
@@ -977,11 +959,70 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
},
form: {
list: (input: SessionFormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: SessionFormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionFormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: SessionFormReplyInput, requestOptions?: RequestOptions) =>
request<SessionFormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: SessionFormCancelInput, requestOptions?: RequestOptions) =>
request<SessionFormCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
method: "PUT",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/environment`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/environment`,
body: { variables: input["variables"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1047,7 +1088,7 @@ export function make(options: ClientOptions) {
request<{ readonly data: GenerateTextOutput }>(
{
method: "POST",
path: `/api/generate`,
path: `/api/experimental/generate`,
body: { prompt: input["prompt"], model: input["model"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
@@ -1367,87 +1408,17 @@ export function make(options: ClientOptions) {
),
},
form: {
request: {
list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
request<FormRequestListOutput>(
{
method: "GET",
path: `/api/form/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>(
list: (input?: FormListInput, requestOptions?: RequestOptions) =>
request<FormListOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
path: `/api/form`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: FormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: FormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
state: (input: FormStateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormStateOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: FormReplyInput, requestOptions?: RequestOptions) =>
request<FormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: FormCancelInput, requestOptions?: RequestOptions) =>
request<FormCancelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
permission: {
@@ -1537,19 +1508,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input["reply"], message: input["message"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
rules: (input: PermissionRulesInput, requestOptions?: RequestOptions) =>
request<PermissionRulesOutput>(
{
method: "PUT",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/rules`,
body: { permissions: input["permissions"] },
body: { decision: input["decision"], message: input["message"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
@@ -1899,19 +1858,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
timeout: (input: ShellTimeoutInput, requestOptions?: RequestOptions) =>
request<ShellTimeoutOutput>(
{
method: "PATCH",
path: `/api/shell/${encodeURIComponent(input.id)}/timeout`,
query: { location: input["location"] },
body: { timeout: input["timeout"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
),
output: (input: ShellOutputInput, requestOptions?: RequestOptions) =>
request<ShellOutputOutput>(
{
@@ -1931,7 +1877,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: true,
},
requestOptions,
@@ -2044,18 +1990,20 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
branches: (input?: VcsBranchesInput, requestOptions?: RequestOptions) =>
request<VcsBranchesOutput>(
{
method: "GET",
path: `/api/vcs/branches`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
branch: {
list: (input?: VcsBranchListInput, requestOptions?: RequestOptions) =>
request<VcsBranchListOutput>(
{
method: "GET",
path: `/api/vcs/branch`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
diff: (input: VcsDiffInput, requestOptions?: RequestOptions) =>
request<VcsDiffOutput>(
{
File diff suppressed because it is too large Load Diff
+10 -10
View File
@@ -7,9 +7,9 @@ import type {
AgentInfo,
CommandInfo,
ConfigEntry,
FormCancelInput,
SessionFormCancelInput,
FormInfo,
FormReplyInput,
SessionFormReplyInput,
IntegrationInfo,
LocationRef,
LocationGetOutput,
@@ -296,7 +296,7 @@ export function createData(config: CreateDataInput) {
return true
}
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
function settleForm(input: SessionFormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
return request
.catch((error: unknown) => {
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
@@ -697,7 +697,7 @@ export function createData(config: CreateDataInput) {
})
return
}
case "session.permissions.updated":
case "session.permissions":
if (store.session.info[event.data.sessionID])
setStore("session", "info", event.data.sessionID, "permissions", event.data.permissions)
return
@@ -1742,7 +1742,7 @@ export function createData(config: CreateDataInput) {
const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`
return sync.run(key, async () => {
if (sessionID === "global") {
const response = await api().form.request.list({
const response = await api().form.list({
location: locationQuery(ref ?? defaultLocation()),
})
const location = {
@@ -1757,7 +1757,7 @@ export function createData(config: CreateDataInput) {
])
return
}
setStore("session", "form", sessionID, await api().form.list({ sessionID }))
setStore("session", "form", sessionID, await api().session.form.list({ sessionID }))
})
},
invalidate(sessionID: string, ref?: LocationRef) {
@@ -1765,11 +1765,11 @@ export function createData(config: CreateDataInput) {
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
)
},
reply(input: FormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
reply(input: SessionFormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.reply(input, formRequestOptions(input.sessionID, ref)))
},
cancel(input: FormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
cancel(input: SessionFormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.cancel(input, formRequestOptions(input.sessionID, ref)))
},
},
},
+5 -5
View File
@@ -157,7 +157,7 @@ test("generate.text uses the locationless public contract", async () => {
})
expect(await client.generate.text({ prompt: "ping" })).toEqual({ text: "pong" })
expect(request?.url).toBe("http://localhost:3000/api/generate")
expect(request?.url).toBe("http://localhost:3000/api/experimental/generate")
expect(await request?.json()).toEqual({ prompt: "ping" })
})
@@ -546,13 +546,13 @@ test("session.inbox mutations use the public HTTP contract", async () => {
})
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_steer", delivery: "steer" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_queue", delivery: "queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue" },
])
})
+5 -2
View File
@@ -88,8 +88,11 @@ runtime.catalog() // structured tool descriptions
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
```
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
failure as a thrown error.
### `Values`
+33 -33
View File
@@ -19,14 +19,16 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
TypeScript is transpiled first; the emitted JavaScript must still use the supported subset.
- [x] Top-level `await` and `return` through the program's implicit async-function scope.
- [x] Explicit `return`, final top-level expression as a REPL-style result, and `null` when no value is produced.
- [x] Program results use JSON-like boundaries, with `undefined` and non-finite numbers normalized to `null`. Tool
arguments follow JSON serialization semantics before their schema applies (see the tools section). Own
`__proto__` keys are dropped wherever a host object crosses to the host, so merging tool inputs or results
cannot replace a prototype; `JSON.stringify` still emits the key, like JS, since a string cannot pollute.
- [x] Values `JSON.stringify` would flatten to `{}` cross the host boundary in a useful form instead: a Set as an
array, a RegExp as `"/source/flags"`, a URLSearchParams as its query string. A Map still crosses as `{}`.
Functions, generators, promises, extension handles, and a Uint8Array are rejected with a hint. In-program
`JSON.stringify` keeps JS behavior for all of these.
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
what `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish,
`undefined` array elements and non-finite numbers become `null`, a cyclic value throws the same `TypeError`,
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
Uint8Array is rejected with a hint to encode as text, and own `__proto__` keys are dropped so merging tool
inputs or results cannot replace a prototype. In-program `JSON.stringify` keeps JS behavior except for the
Error form and a promise, which is a `TypeError` with an await hint rather than a silent `{}`.
- [x] Live Date, RegExp, Map, Set, URL, URLSearchParams, and Uint8Array values inside CodeMode.
- [x] Tool calls through the host-provided `tools` tree only.
- [x] The global `search(...)` built-in: synchronous tool discovery that counts as an admitted tool call and is
@@ -190,7 +192,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Prefix and postfix `++` and `--`.
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
creates a hole without changing its length. Deleting a non-configurable property (`length`, `lastIndex`) or
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
@@ -231,8 +233,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
callables that settle the promise exactly once (they may escape the executor and settle later); an executor
throw rejects unless the promise already settled, resolving with a promise or callable thenable adopts it, and
resolving with the promise itself rejects with a `TypeError`. Resolver callables work anywhere callbacks are
accepted, including `.then`/`.catch` handlers and collection callbacks, but remain opaque references that cannot
cross the data boundary.
accepted, including `.then`/`.catch` handlers and collection callbacks, and vanish at the data boundary like
any function.
- [x] Recursive assimilation of objects with an own callable `then` field across `Promise.resolve`, combinators,
constructors, reactions, `finally`, `await`, and async returns. Thenable methods run deferred, receive
first-call-wins resolve/reject functions, and ignore throws after settlement. Inherited/accessor `then` fields
@@ -240,11 +242,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Dotted tool names are canonicalized into namespace paths; a path can be both callable and a namespace, and the
last tool supplied for a canonical path wins.
- [x] Tool path segments may be named `constructor`, `prototype`, or `__proto__` because paths use inert Map keys.
- [x] Outbound tool arguments follow JSON serialization semantics, like `JSON.stringify`: object properties with
`undefined` values are dropped, `undefined` array elements and non-finite numbers become `null`, and sparse
arrays densify. Tools never receive `undefined` inside their input object, though a bare `tools.t(undefined)`
argument still reaches schema decoding as `undefined`. Program results keep the stricter
normalization where every `undefined` becomes `null`.
- [x] Outbound tool arguments are what `JSON.stringify` would serialize (see the boundary rule above). Tools never
receive `undefined` inside their input object, though a bare `tools.t(undefined)` argument still reaches schema
decoding as `undefined`.
- [ ] Tokenize and case-fold non-ASCII tool paths, descriptions, and queries for tool search.
## Objects and properties
@@ -394,7 +394,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
`unicodeSets`, and `dotAll`.
- [x] Captures, named groups, match `.index` and `.input`, and stateful global matching.
- [x] Integration with supported String methods, including function replacers.
- [x] Writable `lastIndex`.
- [x] Writable `lastIndex`, shared by `exec`, `test`, and the String methods. It is a prototype accessor that stores
a number, so `re.lastIndex = "12"` reads back `12`, `delete` is a no-op, and `hasOwnProperty("lastIndex")` is
`false`.
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
- [x] `RegExp.escape`.
@@ -426,8 +428,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Uint8Array
The only binary type. Bytes stay inside the program or cross to extensions; the tool boundary rejects them with a
hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
The only binary type. Bytes stay inside the program or cross to extensions as copies; the tool boundary rejects them
with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `new Uint8Array(length | array | iterable | Uint8Array)`, `Uint8Array.from`, `Uint8Array.of`, `fromBase64`,
and `fromHex`. Lengths are capped like arrays.
@@ -452,29 +454,27 @@ hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
## Extensions
Host classes and functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
Host functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
Nothing is exposed unless a host provides it; extension calls are not tool calls.
- [x] Each global is a class or a function, exposed as-is: constructors with `new`, prototype methods, accessors,
and statics (including through an exposed subclass, so `new this()` works), plus inheritance
up to the nearest exposed ancestor. A global that shadows a built-in or another extension throws at `make`.
- [x] Instances of exposed classes stay on the host; the program holds a handle whose only members are the class's.
The same host instance is always the same handle within a run, so identity and `instanceof` hold. Handles
cannot cross the data boundary: returning, stringifying, throwing, or passing one to a tool fails.
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
a built-in or another extension throws at `make`.
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
un-awaited promises, and symbols cannot be passed in; an instance of an unexposed class, a symbol, or a BigInt
cannot come out.
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
(`fetch.json`). Like any program function it vanishes at the data boundary.
- [x] Each call to an extension global runs inside the host's `extension.before`/`extension.after` hooks as
`{ extension, name, args }`, with the host's own error on failure; calls to functions inside results do not.
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
plain data). A getter must be synchronous.
- [x] A prototype member runs only with a handle of its own class as `this`; a detached call, a plain object, or a
handle of another class throws `TypeError: Illegal invocation`. Program edits to an exposed prototype affect
that run only. Data properties on a class or prototype are not exposed, since a program write would change the
host class itself; expose one through an accessor.
plain data).
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
step if `new X()` in a program is ever needed.
## Errors and diagnostics
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.5",
"description": "Effect-native confined code execution over schema-described tools",
"type": "module",
"license": "MIT",
+17 -5
View File
@@ -7,7 +7,14 @@ import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime
import type { Tools } from "./tools.js"
/** A tool call admitted during an execution. */
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
export type {
CallResult,
ExtensionInvocation,
Hooks,
ToolCall,
ToolDescription,
ToolInvocation,
} from "./tool-runtime.js"
/** Signature-construction helpers for host-owned catalog instructions. */
export { searchSignature, toolExpression } from "./tool-runtime.js"
@@ -34,10 +41,12 @@ export type ResolvedExecutionLimits = {
}
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
export type Options<Provided extends Record<string, unknown> = {}> = {
/** Explicit tools exposed to the program as `tools`. */
tools?: Provided & Tools<Services<Provided>>
/** Host classes and functions exposed as globals; see `Extension.make`. */
/** Hooks around every tool and extension call the program makes; see `Hooks`. */
hooks?: ToolRuntime.Hooks<Services<Provided>>
/** Host functions exposed as globals; see `Extension.make`. */
extensions?: ReadonlyArray<Extension>
/** Resource limits enforced on each execution. */
limits?: ExecutionLimits
@@ -147,7 +156,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
}
}
return {
catalog: prepared.catalog,
execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
get catalog() {
return prepared.catalog
},
execute: (code) =>
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
}
}
+97 -221
View File
@@ -1,239 +1,115 @@
export * as Data from "./data.js"
import type { DiagnosticKind } from "./codemode.js"
import type { Builtins } from "./interpreter/intrinsics.js"
import { Effect, type Schema } from "effect"
import type { Interpreter } from "./interpreter/interpreter.js"
import { MAX_VALUE_DEPTH } from "./interpreter/limits.js"
import { invalidData, rangeError, typeError } from "./interpreter/model.js"
import {
Callable,
define,
entries,
get,
isWrapper,
parseArrayIndex,
Arr,
Bytes,
DateObj,
Callable,
define,
ErrorObj,
GeneratorObj,
Handle,
MapObj,
get,
keys,
Obj,
PromiseObj,
RegExpObj,
record,
SetObj,
URLObj,
URLSearchParamsObj,
} from "./interpreter/objects.js"
import { typeofValue } from "./interpreter/references.js"
export const MAX_VALUE_DEPTH = 32
export type Json = Schema.Json
export class ToolRuntimeError extends Error {
constructor(
readonly kind: Extract<
DiagnosticKind,
"UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "InvalidDataValue" | "ToolCallLimitExceeded"
>,
message: string,
readonly suggestions: ReadonlyArray<string> = [],
) {
super(message)
this.name = "ToolRuntimeError"
}
type Replacer<R> = (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
/**
* What `JSON.stringify` would serialize for a program value, as host JSON: `toJSON` is honored, functions and
* `undefined` vanish, non-finite numbers become null, and everything else is copied. Two departures from JS
* so a mistake is not a silent `{}`: an Error serializes as `{ name, message, ...own }`, and a promise throws.
*/
export const toJson = <R>(ctx: Interpreter<R>, value: unknown, replacer?: Replacer<R>) =>
walk(ctx, value, replacer, false)
/**
* The host boundary: `toJson`, plus what a program most likely meant when a value cannot be JSON. A promise is
* awaited, a Set crosses as an array, a URLSearchParams as its query string, a Uint8Array asks to be encoded as
* text first, and a `__proto__` key is dropped so host code can never receive one.
*/
export const toBoundary = <R>(ctx: Interpreter<R>, value: unknown) => walk(ctx, value, undefined, true)
const walk = <R>(
ctx: Interpreter<R>,
value: unknown,
replacer: Replacer<R> | undefined,
boundary: boolean,
): Effect.Effect<Json | undefined, unknown, R> => {
const stack = new Set<object>()
const visit = (holder: Obj, key: string, depth: number): Effect.Effect<Json | undefined, unknown, R> =>
Effect.gen(function* () {
if (depth > MAX_VALUE_DEPTH) throw rangeError(`Value exceeds the maximum depth of ${MAX_VALUE_DEPTH}.`)
const raw = get(holder, key)
if (raw instanceof PromiseObj && !boundary) {
throw invalidData(
"JSON.stringify received an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.",
)
}
const settled = raw instanceof PromiseObj ? yield* ctx.await(raw) : raw
const toJSON = settled instanceof Obj ? get(settled, "toJSON") : undefined
const own = toJSON instanceof Callable ? yield* ctx.call(toJSON, settled, [key]) : settled
const value = replacer === undefined ? own : yield* replacer([key, own])
if (value === undefined || typeofValue(value) === "function") return undefined
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (!(value instanceof Obj)) return {}
if (boundary && value instanceof Bytes) {
throw invalidData(
"A Uint8Array cannot cross to the host; pass text instead, e.g. `new TextDecoder().decode(bytes)` or `bytes.toBase64()`.",
)
}
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
const target = boundary && value instanceof SetObj ? new Arr(ctx.builtins.Array, [...value.set]) : value
if (stack.has(target)) throw typeError("Converting circular structure to JSON.")
stack.add(target)
if (target instanceof Arr) {
const items: Array<Json> = []
for (let index = 0; index < target.items.length; index += 1) {
items.push((yield* visit(target, String(index), depth + 1)) ?? null)
}
stack.delete(target)
return items
}
const copied: Record<string, Json> = {}
// Own data property regardless of the key, so "__proto__" never reaches the Object.prototype setter.
const put = (name: string, item: Json | undefined) => {
if (item !== undefined)
Object.defineProperty(copied, name, { value: item, enumerable: true, writable: true, configurable: true })
}
// Errors serialize as { name, message, ...own }: both may be inherited, and neither is enumerable in JS.
if (target instanceof ErrorObj) {
put("name", yield* visit(target, "name", depth + 1))
put("message", yield* visit(target, "message", depth + 1))
}
for (const name of keys(target)) {
if (boundary && name === "__proto__") continue
put(name, yield* visit(target, name, depth + 1))
}
stack.delete(target)
return copied
})
return visit(record(ctx.builtins.Object, { "": value }), "", 0)
}
/**
* Brings a host-produced value into the program: program values pass through, host Date, RegExp,
* Map, Set, URL, and URLSearchParams become their built-in wrappers, and host objects and arrays
* are copied.
*/
export const toProgram = (builtins: Builtins, value: unknown, label: string): unknown =>
copy(value, label, "program", 0, new Set(), builtins)
/**
* Brings host data into the program: Date and URL become strings, other host collections become
* empty objects, and objects become program copies. Used for tool results and parsed JSON.
*/
export const fromData = (builtins: Builtins, value: unknown, label: string): unknown =>
copy(value, label, "data", 0, new Set(), builtins)
/**
* Takes a program value out as plain JSON: runtime values serialize like `JSON.stringify` would,
* non-finite numbers become null, and array holes become null. `undefined` object properties are
* dropped ("json") or become null ("result", for program results where the consumer must never see
* undefined); a bare `undefined` follows the same rule.
*
* At the host boundary (tool arguments and program results) `__proto__` keys are dropped and values
* `JSON.stringify` would flatten to `{}` cross in a useful form instead: a Set as an array, a RegExp
* and URLSearchParams as their strings. `JSON.stringify` itself passes `boundary: false` to keep JS
* behavior.
*/
export const toData = (
value: unknown,
label: string,
undefinedAs: "json" | "result" = "json",
boundary = true,
): unknown => copy(value, label, undefinedAs, 0, new Set(), undefined, boundary)
// "program" and "data" build program objects; "json" and "result" build ordinary objects for the ctx.
type Mode = "program" | "data" | "json" | "result"
const copy = (
value: unknown,
label: string,
mode: Mode,
depth: number,
seen: Set<object>,
builtins?: Builtins,
boundary = true,
): unknown => {
const next = (item: unknown) => copy(item, label, mode, depth + 1, seen, builtins, boundary)
if (depth > MAX_VALUE_DEPTH) {
throw new ToolRuntimeError("InvalidDataValue", `${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
}
if (value === undefined) return mode === "result" ? null : undefined
if (typeof value === "number") return (mode === "json" || mode === "result") && !Number.isFinite(value) ? null : value
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (typeof value !== "object") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
if (value instanceof PromiseObj) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains an un-awaited Promise; await tool calls (e.g. \`const result = await tools.ns.tool(...)\`) before using their results.`,
/** Host JSON as program values: objects and arrays are copied, primitives pass through. */
export const fromJson = <R>(ctx: Interpreter<R>, value: unknown): unknown => {
if (value === null || typeof value !== "object") return value
if (Array.isArray(value))
return new Arr(
ctx.builtins.Array,
value.map((item) => fromJson(ctx, item)),
)
}
if ((value instanceof Callable || value instanceof GeneratorObj) && mode !== "program") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain data only.`)
}
if (value instanceof Handle && mode !== "program") {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains a ${value.instance.constructor.name}, which only extension functions accept.`,
)
}
// Host-produced input never holds program objects; one arriving here would come back as a host object.
if (value instanceof Obj && mode === "data") {
throw new ToolRuntimeError("InvalidDataValue", `${label} must be host data, not a program value.`)
}
if (builtins !== undefined && mode === "program") {
if (value instanceof Obj) return value
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value.entries()) wrapped.map.set(next(key), next(item))
return wrapped
}
if (value instanceof Set) {
const wrapped = new SetObj(builtins.Set)
for (const item of value.values()) wrapped.set.add(next(item))
return wrapped
}
if (value instanceof URL) return new URLObj(builtins.URL, builtins.URLSearchParams, new URL(value.href))
if (value instanceof URLSearchParams)
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
if (value instanceof DateObj) return Number.isFinite(value.time) ? new Date(value.time).toISOString() : null
if (value instanceof Date) return Number.isFinite(value.getTime()) ? value.toISOString() : null
if (value instanceof URLObj) return value.url.href
if (value instanceof URL) return value.href
if (value instanceof Bytes) {
if (boundary) {
throw new ToolRuntimeError(
"InvalidDataValue",
`${label} contains a Uint8Array; pass text instead, e.g. \`new TextDecoder().decode(bytes)\` or \`bytes.toBase64()\`.`,
)
}
return Object.fromEntries(value.bytes.entries())
}
if (boundary && builtins === undefined) {
if (value instanceof RegExpObj) return String(value.regex)
if (value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof SetObj) {
if (seen.has(value)) throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
seen.add(value)
const copied = Array.from(value.set, (item) => next(item) ?? null)
seen.delete(value)
return copied
}
}
// Remaining wrappers and their host counterparts serialize as empty objects, like JSON.stringify.
if (
isWrapper(value) ||
value instanceof RegExp ||
value instanceof Map ||
value instanceof Set ||
value instanceof URLSearchParams
) {
return builtins !== undefined ? new Obj(builtins.Object) : {}
}
if (seen.has(value)) {
throw new ToolRuntimeError("InvalidDataValue", `${label} contains a circular value.`)
}
seen.add(value)
if (value instanceof Arr) {
const copied = Array.from(value.items, (item) => next(item) ?? null)
seen.delete(value)
return copied
}
if (value instanceof Obj) {
const copied: Record<string, unknown> = {}
// Errors serialize as { name, message, ...own }: both may be inherited, and neither is enumerable in JS.
if (value instanceof ErrorObj) {
defineHost(copied, "name", next(get(value, "name")))
defineHost(copied, "message", next(get(value, "message")))
}
for (const [key, item] of entries(value)) {
if (boundary && key === "__proto__") continue
const copiedItem = next(item)
if (copiedItem === undefined && mode === "json") continue
defineHost(copied, key, copiedItem)
}
seen.delete(value)
return copied
}
if (Array.isArray(value)) {
if (builtins !== undefined) {
const copied = new Arr(builtins.Array, value.map(next))
for (const [key, item] of Object.entries(value)) {
if (parseArrayIndex(key) === undefined) define(copied, key, next(item))
}
seen.delete(value)
return copied
}
const copied = Array.from(value, (item) => next(item) ?? null)
seen.delete(value)
return copied
}
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) {
throw new ToolRuntimeError("InvalidDataValue", `${label} must contain plain objects only.`)
}
if (builtins !== undefined) {
const copied = new Obj(builtins.Object)
for (const [key, item] of Object.entries(value)) define(copied, key, next(item))
seen.delete(value)
return copied
}
const copied: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
if (boundary && key === "__proto__") continue
const copiedItem = next(item)
if (copiedItem === undefined && mode === "json") continue
defineHost(copied, key, copiedItem)
}
seen.delete(value)
const copied = new Obj(ctx.builtins.Object)
for (const [key, item] of Object.entries(value)) define(copied, key, fromJson(ctx, item))
return copied
}
// Own data property regardless of the target's prototype, so a "__proto__" key on a host object
// never reaches the Object.prototype setter.
const defineHost = (target: object, key: string, value: unknown): void => {
Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true })
}
+4 -5
View File
@@ -1,20 +1,19 @@
export * as Extension from "./extension.js"
/**
* Host classes and functions a program uses directly, like JavaScript. Values crossing in either direction are
* converted, never shared: plain data is copied, instances of the classes stay on the host behind program-side
* handles. Extension calls are not tool calls.
* Host functions a program calls directly as globals. Values crossing in either direction are converted, never
* shared: arguments come in as copies, results go out as copies, and a function inside a result is callable the
* same way. Extension calls are not tool calls.
*/
export type Extension = {
readonly name: string
/** Each value is a class or a function; everything on a class is exposed, including statics and accessors. */
readonly globals: Readonly<Record<string, Function>>
}
export const make = (options: Extension): Extension => {
for (const [name, value] of Object.entries(options.globals)) {
if (typeof value !== "function") {
throw new TypeError(`Extension "${options.name}" global "${name}" must be a class or a function.`)
throw new TypeError(`Extension "${options.name}" global "${name}" must be a function.`)
}
}
return { name: options.name, globals: { ...options.globals } }
+3 -6
View File
@@ -1,13 +1,14 @@
import { Effect } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { toData, ToolRuntimeError } from "../data.js"
import { ToolRuntimeError } from "../tool-runtime.js"
import { type AstNode, formatLocation, PendingThrow, Throw, sourceLocation, typeError } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
import { type Callable, define, get, hidden, type Native, Arr, ErrorObj, Obj } from "./objects.js"
import type { Interpreter } from "./interpreter.js"
import { formatValue } from "../stdlib/console.js"
import { coerceToString } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
@@ -44,11 +45,7 @@ export const normalizeError = (error: unknown): Diagnostic => {
} else if (typeof value === "string") {
message = value
} else {
try {
message = JSON.stringify(toData(value, "Thrown value")) ?? String(value)
} catch {
message = String(value)
}
message = formatValue(value)
}
return { kind: "ExecutionFailure", message: `Uncaught: ${message}` }
}
+5 -5
View File
@@ -4,7 +4,7 @@ import { Cause, Effect, Scope } from "effect"
// pass-through on workerd (the compiler is ~11 MiB and can't init there).
import { transpile } from "#transpile"
import type { DataValue, Diagnostic, ResolvedExecutionLimits, Result } from "../codemode.js"
import { toData } from "../data.js"
import { toBoundary } from "../data.js"
import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createBuiltins } from "./intrinsics.js"
@@ -16,7 +16,7 @@ export const executeProgram = <R>(
code: string,
prepared: ToolRuntime.Prepared<R>,
limits: ResolvedExecutionLimits,
hooks: ToolRuntime.ToolCallHooks<R>,
hooks: ToolRuntime.Hooks<R>,
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
): Effect.Effect<Result, never, R> => {
if (code.trim().length === 0) {
@@ -30,7 +30,7 @@ export const executeProgram = <R>(
// Allocate execution state inside suspension so reused Effects never share it.
return Effect.suspend(() => {
const builtins = createBuiltins()
const tools = ToolRuntime.make(prepared, builtins, limits.maxToolCalls, hooks)
const tools = ToolRuntime.make(prepared, limits.maxToolCalls, hooks)
const logs: Array<string> = []
const logged = () => (logs.length > 0 ? { logs: [...logs] } : {})
// Set only after copy-out so timeouts cannot report invalid values as completed.
@@ -42,8 +42,8 @@ export const executeProgram = <R>(
Effect.gen(function* () {
const program = parseProgram(code)
const pending = new Pending<R>(scope, builtins.Promise)
const value = yield* new Interpreter<R>({ tools, pending, builtins, logs, globals }).run(program)
const result = toData(value, "Execution result", "result") as DataValue
const ctx = new Interpreter<R>({ tools, pending, builtins, logs, globals })
const result = (yield* toBoundary(ctx, yield* ctx.run(program))) ?? null
returned = { value: result, pending }
const warnings = yield* pending.interrupt()
return {
+48 -179
View File
@@ -1,25 +1,22 @@
import { Effect } from "effect"
import { MAX_VALUE_DEPTH } from "../data.js"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
import { MAX_VALUE_DEPTH } from "./limits.js"
import { Throw, typeError } from "./model.js"
import { constructor, fn } from "./native.js"
import { fn } from "./native.js"
import {
Callable,
define,
defineAccessor,
entries,
get,
hidden,
type Native,
Arr,
Bytes,
DateObj,
ErrorObj,
GeneratorObj,
Handle,
MapObj,
Obj,
PromiseObj,
@@ -30,31 +27,16 @@ import {
} from "./objects.js"
import { describeValue } from "./references.js"
type Class = Function & { readonly prototype: object }
const isClass = (value: unknown): value is Class =>
typeof value === "function" && typeof value.prototype === "object" && value.prototype !== null
// Own keys the native function already carries.
const ownFunctionKeys = new Set(["length", "name", "prototype"])
const ownPrototypeKeys = new Set(["constructor"])
/**
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data is
* copied, built-in wrappers are copied, instances of exposed classes travel as handles, and a host Promise becomes
* a program promise. Prototypes, constructors, and handle identity are all per run.
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
* built-in wrappers are copied, a host function becomes a program function whose calls cross the same way, and a
* host Promise becomes a program promise.
*/
export const extensionGlobals = <R>(
ctx: Interpreter<R>,
extensions: ReadonlyArray<Extension>,
): ReadonlyArray<readonly [string, unknown]> => {
const builtins = ctx.builtins
const classes = new Set(extensions.flatMap((extension) => Object.values(extension.globals)).filter(isClass))
// Host prototype object → this run's program prototype, so an instance wraps as its most-derived exposed class.
const protoOf = new Map<object, Obj>()
const exposed = new Map<Class, { ctor: Native<R>; proto: Obj }>()
const classOf = new Map<unknown, Class>()
const handles = new WeakMap<object, Handle>()
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
@@ -62,7 +44,6 @@ export const extensionGlobals = <R>(
if (isPrimitive(value)) return value
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
}
if (value instanceof Handle) return value.instance
if (value instanceof Bytes) return new Uint8Array(value.bytes)
if (value instanceof DateObj) return new Date(value.time)
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
@@ -83,7 +64,7 @@ export const extensionGlobals = <R>(
const name = coerceToString(get(value, "name"))
const message = get(value, "message")
const text = message === undefined ? "" : coerceToString(message)
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors[name] ?? Error)(text)
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
}
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
seen.add(value)
@@ -102,15 +83,8 @@ export const extensionGlobals = <R>(
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
if (isPrimitive(value)) return value
if (typeof value === "function") return wrap(value, label)
if (value !== null && typeof value === "object") {
const existing = handles.get(value)
if (existing !== undefined) return existing
const proto = handlePrototype(value)
if (proto !== undefined) {
const handle = new Handle(proto, value)
handles.set(value, handle)
return handle
}
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
@@ -122,28 +96,31 @@ export const extensionGlobals = <R>(
if (value instanceof URLSearchParams) {
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
}
const next = (item: unknown) => fromHost(item, label, depth + 1, seen)
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
if (value instanceof Map) {
const wrapped = new MapObj(builtins.Map)
for (const [key, item] of value) wrapped.map.set(next(key), next(item))
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
return wrapped
}
if (value instanceof Set) {
const wrapped = new SetObj(builtins.Set)
for (const item of value) wrapped.set.add(next(item))
for (const item of value) wrapped.set.add(next(item, label))
return wrapped
}
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
seen.add(value)
if (Array.isArray(value)) {
const copied = new Arr(builtins.Array, value.map(next))
const copied = new Arr(
builtins.Array,
value.map((item, index) => next(item, `${label}[${index}]`)),
)
seen.delete(value)
return copied
}
const prototype = Object.getPrototypeOf(value)
if (prototype === Object.prototype || prototype === null) {
const copied = new Obj(builtins.Object)
for (const [key, item] of Object.entries(value)) define(copied, key, next(item))
for (const [key, item] of Object.entries(value)) define(copied, key, next(item, `${label}.${key}`))
seen.delete(value)
return copied
}
@@ -151,153 +128,46 @@ export const extensionGlobals = <R>(
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
}
const handlePrototype = (instance: object): Obj | undefined => {
for (let level = Object.getPrototypeOf(instance); level !== null; level = Object.getPrototypeOf(level)) {
const proto = protoOf.get(level)
if (proto !== undefined) return proto
}
return undefined
}
// Runs host code with already-converted inputs. Whatever it returns, resolves, throws, or rejects with crosses
// the same way, so the program catches what the author threw.
const invoke = (run: () => unknown, label: string): Effect.Effect<unknown, unknown, R> => {
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
let result: unknown
try {
result = run()
} catch (reason) {
return Effect.fail(thrown(reason))
}
if (!(result instanceof Promise)) return Effect.succeed(fromHost(result, label))
return ctx.pending.create(
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
)
}
const args = (values: Array<unknown>, label: string): Array<unknown> =>
values.map((value, index) => toHost(value, `Argument ${index + 1} to ${label}`))
// Own members of each level from `from` up to (excluding) `root`, child first, as JS resolves them.
const members = (
target: Obj,
from: object,
root: object,
skip: ReadonlySet<string>,
label: string,
receiver: (thisValue: unknown, member: string) => unknown,
): void => {
for (let level: object | null = from; level !== null && level !== root; level = Object.getPrototypeOf(level)) {
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(level))) {
if (skip.has(key) || target.props.has(key)) continue
const name = `${label}.${key}`
if (typeof descriptor.value === "function") {
const method: Function = descriptor.value
const impl = (thisValue: unknown, values: Array<unknown>) => {
const self = receiver(thisValue, name)
const converted = args(values, name)
return invoke(() => method.apply(self, converted), name)
}
define(target, key, fn<R>(builtins, key, method.length, impl), hidden)
continue
}
// Data properties stay host-side: a program write to one would change the host class itself.
if ("value" in descriptor) continue
const get = descriptor.get
const set = descriptor.set
defineAccessor(
target,
key,
get === undefined
? undefined
: (thisValue) => {
const value = get.call(receiver(thisValue, name))
if (value instanceof Promise)
throw typeError(`${name} returned a Promise; a getter must be synchronous.`)
return fromHost(value, name)
},
set === undefined
? undefined
: (thisValue, value) => {
set.call(receiver(thisValue, name), toHost(value, `${name} value`))
},
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
fn<R>(builtins, value.name, value.length, (_, values) => {
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
const hooks = ctx.tools.hooks
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
(describe === undefined
? run
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
).pipe(
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
Effect.map((settled) => fromHost(settled, label)),
)
let result: unknown
try {
result = value.apply(undefined, args)
} catch (reason) {
return settle(Effect.fail(reason))
}
}
}
const expose = (cls: Class): { ctor: Native<R>; proto: Obj } => {
const existing = exposed.get(cls)
if (existing !== undefined) return existing
const ancestor = exposedAncestor(cls)
const base = ancestor === undefined ? undefined : expose(ancestor)
const proto = new Obj(base === undefined ? builtins.Object : base.proto)
protoOf.set(cls.prototype, proto)
const name = cls.name
const ctor = constructor<R>(builtins, proto, {
name,
length: cls.length,
call: (_, values) => {
const converted = args(values, name)
return invoke(() => cls.apply(undefined, converted), name)
},
construct: (values) => {
const label = `new ${name}`
const construct = cls as new (...values: Array<unknown>) => object
const converted = args(values, label)
return invoke(() => new construct(...converted), label)
},
if (!(result instanceof Promise)) return settle(Effect.succeed(result))
return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
})
if (base !== undefined) ctor.proto = base.ctor
const entry = { ctor, proto }
exposed.set(cls, entry)
classOf.set(ctor, cls)
// A static called through an exposed subclass sees that subclass as `this`, like JS.
members(ctor, cls, ancestor ?? Function.prototype, ownFunctionKeys, name, (thisValue) => {
const called = classOf.get(thisValue)
return called !== undefined && (called === cls || called.prototype instanceof cls) ? called : cls
})
members(
proto,
cls.prototype,
ancestor?.prototype ?? Object.prototype,
ownPrototypeKeys,
`${name}.prototype`,
(thisValue, member) => {
if (thisValue instanceof Handle && thisValue.instance instanceof cls) return thisValue.instance
throw typeError(`Illegal invocation: ${member} called on ${describeValue(thisValue)}.`)
},
)
return entry
}
const exposedAncestor = (cls: Class): Class | undefined => {
for (let level = Object.getPrototypeOf(cls); isClass(level); level = Object.getPrototypeOf(level)) {
if (classes.has(level)) return level
}
return undefined
}
return extensions.flatMap((extension) =>
Object.entries(extension.globals).map(([name, value]) => {
if (isClass(value)) return [name, expose(value).ctor] as const
const impl = (_: unknown, values: Array<unknown>) => {
const converted = args(values, name)
return invoke(() => value.apply(undefined, converted), name)
}
return [name, fn<R>(builtins, name, value.length, impl)] as const
}),
Object.entries(extension.globals).map(
([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))] as const,
),
)
}
const hostErrors: Record<string, ErrorConstructor | undefined> = {
TypeError,
RangeError,
SyntaxError,
ReferenceError,
EvalError,
URIError,
}
const hostErrors = new Map<string, ErrorConstructor>([
["TypeError", TypeError],
["RangeError", RangeError],
["SyntaxError", SyntaxError],
["ReferenceError", ReferenceError],
["EvalError", EvalError],
["URIError", URIError],
])
// The primitives the interpreter operates on; symbols and BigInts are not among them.
const isPrimitive = (value: unknown): boolean =>
@@ -308,7 +178,6 @@ const isPrimitive = (value: unknown): boolean =>
typeof value === "boolean"
const describeHost = (value: unknown): string => {
if (typeof value === "function") return "a function"
if (typeof value !== "object" || value === null) return `a ${typeof value}`
const name = (value as { constructor?: { name?: string } }).constructor?.name
return name === undefined || name === "" ? "an object" : `a ${name}`
+2 -1
View File
@@ -54,7 +54,8 @@ type Factory = <R>(ctx: Interpreter<R>) => unknown
// A table rather than a list so the names are known before any runtime exists.
const table: Record<string, Factory> = {
tools: () => new ToolReference([]),
search: (ctx) => native(ctx.builtins, { name: "search", call: (_, args) => ctx.tools.search(args), callback: false }),
search: (ctx) =>
native(ctx.builtins, { name: "search", call: (_, args) => ctx.tool(ctx.tools.search, args), callback: false }),
undefined: () => undefined,
NaN: () => NaN,
Infinity: () => Infinity,
@@ -42,7 +42,7 @@ import type {
YieldExpression,
} from "acorn"
import { Cause, Deferred, Effect, Exit } from "effect"
import { toProgram } from "../data.js"
import { fromJson, type Json, toBoundary } from "../data.js"
import { ToolReference, type ToolRuntime } from "../tool-runtime.js"
import {
type AstNode,
@@ -291,6 +291,18 @@ export class Interpreter<R> {
iterate(value: unknown) {
return this.root.iterate(value)
}
/** Runs one host tool: arguments cross as JSON and the result comes back as program values. */
tool(
run: (args: Array<Json | undefined>) => Effect.Effect<Json | undefined, unknown, R>,
args: Array<unknown>,
): Effect.Effect<unknown, unknown, R> {
const ctx = this
return Effect.gen(function* () {
const json = yield* Effect.forEach(args, (arg) => toBoundary(ctx, arg))
return fromJson(ctx, yield* run(json))
})
}
}
const MAX_CALL_DEPTH = 10_000
@@ -344,7 +356,7 @@ class Frame<R> {
path: ReadonlyArray<string>,
args: Array<unknown>,
): Effect.Effect<PromiseObj, never, R> {
return this.ctx.pending.create(Effect.suspend(() => this.ctx.tools.execute(path, args)))
return this.ctx.pending.create(this.ctx.tool((json) => this.ctx.tools.execute(path, json), args))
}
// Fiber exits make settlement idempotent; yielding prevents inline continuation.
@@ -1216,7 +1228,8 @@ class Frame<R> {
case "Literal": {
const regex = node.regex
if (regex) return Effect.sync(() => constructRegExp(this.ctx.builtins, [regex.pattern, regex.flags]))
return Effect.sync(() => toProgram(this.ctx.builtins, node.value, "Literal"))
if (typeof node.value === "bigint") throw typeError("BigInt literals are not supported.", node)
return Effect.succeed(node.value)
}
case "Identifier":
return Effect.sync(() => this.scopes.get(node.name, node))
@@ -1305,11 +1318,7 @@ class Frame<R> {
const lhs = yield* self.evaluateExpression(left)
const rhs = yield* self.evaluateExpression(node.right)
if (operator === "instanceof") return instanceofValue(lhs, rhs, node)
return toProgram(
self.ctx.builtins,
self.applyBinaryOperator(operator, lhs, rhs, node),
"Binary expression result",
)
return self.applyBinaryOperator(operator, lhs, rhs, node)
})
}
@@ -1429,7 +1438,7 @@ class Frame<R> {
default:
throw typeError(`Unsupported unary operator '${operator}'.`, node)
}
return toProgram(this.ctx.builtins, result, "Unary expression result")
return result
})
}
@@ -1451,12 +1460,7 @@ class Frame<R> {
if (operator !== "=") {
const current = self.scopes.get(name, left)
const rightValue = yield* self.evaluateExpression(node.right)
const next = toProgram(
self.ctx.builtins,
self.applyCompoundAssignment(operator, current, rightValue, node),
"Assignment result",
)
return self.scopes.set(name, next, left)
return self.scopes.set(name, self.applyCompoundAssignment(operator, current, rightValue, node), left)
}
const rightValue = yield* self.evaluateNamed(node.right, name)
return self.scopes.set(name, rightValue, left)
@@ -1465,11 +1469,7 @@ class Frame<R> {
return yield* self.modifyMember(left, (current) =>
Effect.map(self.evaluateExpression(node.right), (rightValue) => {
if (operator === "=") return { write: true, next: rightValue, result: rightValue }
const next = toProgram(
self.ctx.builtins,
self.applyCompoundAssignment(operator, current, rightValue, node),
"Assignment result",
)
const next = self.applyCompoundAssignment(operator, current, rightValue, node)
return { write: true, next, result: next }
}),
)
@@ -2010,7 +2010,7 @@ class Frame<R> {
if (index < expressions.length) {
const raw = yield* self.evaluateExpression(expressions[index])
output += coerceToString(toProgram(self.ctx.builtins, raw, "Template interpolation"))
output += coerceToString(raw)
checkStringLength(output.length)
}
}
@@ -9,6 +9,8 @@ export const MAX_STRING_LENGTH = 1 << 24
export const MAX_ARRAY_LENGTH = 10_000_000
/** Most promises that may be pending at once. */
export const MAX_PENDING_PROMISES = 10_000
/** Deepest nesting a value may have when it crosses to or from the host. */
export const MAX_VALUE_DEPTH = 32
export const checkStringLength = (length: number): void => {
if (length > MAX_STRING_LENGTH) throw rangeError("Invalid string length")
@@ -136,7 +136,6 @@ export class RegExpObj extends Obj {
constructor(proto: Obj, pattern: string, flags: string) {
super(proto)
this.regex = new RegExp(pattern, flags)
define(this, "lastIndex", 0, { writable: true, enumerable: false, configurable: false })
}
}
@@ -179,16 +178,6 @@ export class Bytes extends Obj {
}
}
/** An instance of an extension class: the host object lives in a field no property path reaches. */
export class Handle extends Obj {
constructor(
proto: Obj,
readonly instance: object,
) {
super(proto)
}
}
/** Built-in objects that wrap a host value; data-like, but never plain data. */
export const isWrapper = (
value: unknown,
@@ -9,7 +9,6 @@ import {
Bytes,
DateObj,
GeneratorObj,
Handle,
MapObj,
Obj,
PromiseObj,
@@ -23,7 +22,6 @@ import {
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof Handle ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
@@ -89,7 +87,6 @@ export const describeValue = (value: unknown): string => {
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (value instanceof Handle) return `a ${value.instance.constructor.name}`
if (isRuntimeReference(value)) return "a function"
if (typeof value === "object") return "a data object"
return `a ${typeof value}`
+11 -20
View File
@@ -1,5 +1,3 @@
import { toData, toProgram } from "../data.js"
import type { Builtins } from "../interpreter/intrinsics.js"
import { type Method, methods } from "../interpreter/native.js"
import {
entries,
@@ -15,7 +13,7 @@ import {
URLObj,
URLSearchParamsObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, containsRuntimeReference, isRuntimeReference } from "../interpreter/references.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
@@ -33,7 +31,7 @@ export const consoleGlobal = <R>(ctx: Interpreter<R>) => {
name,
0,
(_, args) => {
ctx.logs.push(formatConsoleMessage(builtins, name, args))
ctx.logs.push(formatConsoleMessage(name, args))
return undefined
},
],
@@ -44,14 +42,15 @@ export const consoleGlobal = <R>(ctx: Interpreter<R>) => {
const MAX_CONSOLE_DEPTH = 32
const formatConsoleMessage = (builtins: Builtins, name: string, args: Array<unknown>): string => {
if (name === "dir") return args.length === 0 ? "undefined" : formatConsoleArgument(args[0])
if (name === "table") return formatConsoleTable(builtins, args[0], args[1])
const formatConsoleMessage = (name: string, args: Array<unknown>): string => {
if (name === "dir") return args.length === 0 ? "undefined" : formatValue(args[0])
if (name === "table") return formatConsoleTable(args[0], args[1])
const prefix = name === "warn" ? "[warn] " : name === "error" ? "[error] " : name === "debug" ? "[debug] " : ""
return `${prefix}${args.map((arg) => formatConsoleArgument(arg)).join(" ")}`
return `${prefix}${args.map((arg) => formatValue(arg)).join(" ")}`
}
const formatConsoleArgument = (value: unknown): string => {
/** One value as `console.log` shows it. */
export const formatValue = (value: unknown): string => {
if (value === undefined) return "undefined"
if (typeof value === "string") return value
return formatConsoleValue(value, new Set(), 0)
@@ -103,12 +102,11 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
const formatItems = (items: Array<unknown>, seen: Set<object>, depth: number): string =>
items.map((item) => formatConsoleValue(item, seen, depth)).join(",")
const formatConsoleTable = (builtins: Builtins, value: unknown, columnsArgument: unknown): string => {
const formatConsoleTable = (value: unknown, columnsArgument: unknown): string => {
if (value === undefined) return "undefined"
if (containsOpaqueReference(value)) return "[opaque reference]"
const data = toProgram(builtins, value, "console.table argument")
const columns = consoleTableColumns(columnsArgument)
const rows = consoleTableRows(data, columns)
const columns = columnsArgument instanceof Arr ? columnsArgument.items.map(String) : undefined
const rows = consoleTableRows(value, columns)
const keys = columns ?? Array.from(new Set(rows.flatMap((row) => Object.keys(row.values))))
const header = ["(index)", ...keys].join("\t")
return [
@@ -117,13 +115,6 @@ const formatConsoleTable = (builtins: Builtins, value: unknown, columnsArgument:
].join("\n")
}
const consoleTableColumns = (value: unknown): ReadonlyArray<string> | undefined => {
if (value === undefined) return undefined
if (containsRuntimeReference(value)) return undefined
const columns = toData(value, "console.table columns", "result")
return Array.isArray(columns) ? columns.map((column) => String(column)) : undefined
}
const consoleTableRows = (
data: unknown,
columns: ReadonlyArray<string> | undefined,
+15 -57
View File
@@ -5,8 +5,8 @@ import type { Interpreter } from "../interpreter/interpreter.js"
import { checkStringLength } from "../interpreter/limits.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { fromData, toData, toProgram } from "../data.js"
import { Callable, get, keys, Arr, Obj, record, remove, set } from "../interpreter/objects.js"
import { fromJson, toJson } from "../data.js"
import { get, keys, Arr, Obj, record, remove, set } from "../interpreter/objects.js"
export const jsonGlobal = <R>(ctx: Interpreter<R>) => {
const json = new Obj(ctx.builtins.Object)
@@ -23,7 +23,7 @@ const parse = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<unkn
const parsed = (() => {
try {
return fromData(ctx.builtins, JSON.parse(text), "JSON.parse result")
return fromJson(ctx, JSON.parse(text))
} catch (error) {
throw syntaxError(`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
}
@@ -50,59 +50,17 @@ const stringify = <R>(ctx: Interpreter<R>, args: Array<unknown>): Effect.Effect<
const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
const replacer = args[1]
if (typeofValue(replacer) !== "function") {
const properties =
replacer instanceof Arr
? replacer.items
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(String)
: null
// Not a host boundary: __proto__ stays and Set/RegExp/URLSearchParams serialize as {}, like JS.
const text = JSON.stringify(toData(args[0], "JSON.stringify value", "json", false), properties, indent)
const properties =
replacer instanceof Arr
? replacer.items
.filter((item): item is string | number => typeof item === "string" || typeof item === "number")
.map(String)
: null
const callback =
typeofValue(replacer) === "function" ? applyCollectionCallback(ctx, replacer, "JSON.stringify") : undefined
return Effect.map(toJson(ctx, args[0], callback), (value) => {
const text = JSON.stringify(value, properties, indent)
if (text !== undefined) checkStringLength(text.length)
return Effect.succeed(text)
}
// Validate up front; the replacer walk below reads the original value.
toProgram(ctx.builtins, args[0], "JSON.stringify value")
const apply = applyCollectionCallback(ctx, replacer, "JSON.stringify")
const stack = new Set<object>()
const visit = (holder: Obj, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = yield* apply([key, yield* toJSONValue(ctx, get(holder, key), key)])
if (value === undefined || typeofValue(value) === "function") return undefined
toProgram(ctx.builtins, value, "JSON.stringify replacer result")
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (!(value instanceof Obj)) return {}
if (stack.has(value)) throw typeError("Converting circular structure to JSON.")
stack.add(value)
if (value instanceof Arr) {
const result: Array<unknown> = []
for (let index = 0; index < value.items.length; index += 1) {
result.push((yield* visit(value, String(index))) ?? null)
}
stack.delete(value)
return result
}
const result: Record<string, unknown> = Object.create(null)
for (const name of keys(value)) {
const item = yield* visit(value, name)
if (item !== undefined) result[name] = item
}
stack.delete(value)
return result
})
return Effect.map(visit(record(ctx.builtins.Object, { "": args[0] }), ""), (value) =>
JSON.stringify(value, null, indent),
)
}
// SerializeJSONProperty step 2: a callable `toJSON` decides the value, as Date and URL define.
const toJSONValue = <R>(ctx: Interpreter<R>, value: unknown, key: string) => {
if (!(value instanceof Obj)) return Effect.succeed(value)
const toJSON = get(value, "toJSON")
return toJSON instanceof Callable ? ctx.call(toJSON, value, [key]) : Effect.succeed(value)
return text
})
}
+2 -4
View File
@@ -1,5 +1,4 @@
import { Effect } from "effect"
import { toProgram } from "../data.js"
import { constructor, methods, receiver } from "../interpreter/native.js"
import {
type AstNode,
@@ -138,12 +137,11 @@ export const objectGlobal = <R>(ctx: Interpreter<R>) => {
"keys",
1,
(_, args) =>
toProgram(
builtins,
new Arr(
builtins.Array,
args[0] instanceof ToolReference
? [...ctx.tools.keys(args[0].path)]
: keys(enumerableSource(ctx, "Object.keys(...)", args[0])),
"Object.keys result",
),
],
[
+11 -13
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import type { Builtins } from "../interpreter/intrinsics.js"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { define, defineAccessor, getOwn, Arr, Obj, RegExpObj, record, set } from "../interpreter/objects.js"
import { define, defineAccessor, Arr, Obj, RegExpObj, record } from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToNumber, coerceToString } from "./value.js"
@@ -75,12 +75,6 @@ export const constructRegExp = (builtins: Builtins, args: Array<unknown>, proto:
}
}
const toLength = (value: unknown): number => {
const number = coerceToNumber(value)
if (Number.isNaN(number) || number <= 0) return 0
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
}
// RegExp constructs identically with or without new, like JS.
export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
@@ -105,18 +99,22 @@ export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
const self = (thisValue: unknown, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`)
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
// The host regex holds the only lastIndex, so exec/test and the String methods share one counter.
defineAccessor(
proto,
"lastIndex",
(thisValue) => self(thisValue, "lastIndex").regex.lastIndex,
(thisValue, value) => {
self(thisValue, "lastIndex").regex.lastIndex = coerceToNumber(value)
},
)
for (const name of flagProperties) defineAccessor(proto, name, (thisValue) => self(thisValue, name).regex[name])
// exec/test run the host regex from the program-visible lastIndex and write it back only when g or y is set.
const run = (name: "exec" | "test"): Method => [
name,
1,
(thisValue, args) => {
const value = self(thisValue, name)
const input = coerceToString(args[0])
const stateful = value.regex.global || value.regex.sticky
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
const matched = value.regex.exec(input)
if (stateful) set(value, "lastIndex", value.regex.lastIndex)
const matched = value.regex.exec(coerceToString(args[0]))
if (name === "test") return matched !== null
return matched === null ? null : matchToValue(builtins, matched)
},
+2 -5
View File
@@ -1,5 +1,4 @@
import { Effect } from "effect"
import { toProgram } from "../data.js"
import { constructor, type Method, methods } from "../interpreter/native.js"
import { checkArrayLength, checkStringLength } from "../interpreter/limits.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
@@ -66,9 +65,7 @@ const replaceWithCallback = <R>(
const replacement = yield* apply(match.args)
output.push(
value.slice(end, match.offset),
replacement instanceof PromiseObj
? "[object Promise]"
: coerceToString(toProgram(builtins, replacement, `String.${name} replacer result`)),
replacement instanceof PromiseObj ? "[object Promise]" : coerceToString(replacement),
)
end = match.offset + match.match.length
}
@@ -209,7 +206,7 @@ export const stringGlobal = <R>(ctx: Interpreter<R>) => {
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 toProgram(builtins, matched, "String.match result")
if (pattern.global) return new Arr(builtins.Array, [...matched])
return matchToValue(builtins, matched)
}),
simple("matchAll", 1, (value, args) => {
+17 -30
View File
@@ -1,6 +1,4 @@
import { Effect } from "effect"
import { toProgram, ToolRuntimeError } from "../data.js"
import type { Builtins } from "../interpreter/intrinsics.js"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
import { defineAccessor, entries, isWrapper, Arr, Obj, URLObj, URLSearchParamsObj } from "../interpreter/objects.js"
@@ -23,9 +21,6 @@ const urlProperties = [
"hash",
] as const
export const uriArgument = (builtins: Builtins, value: unknown, label: string): string =>
coerceToString(toProgram(builtins, value, label))
type UriFunction = "encodeURI" | "encodeURIComponent" | "decodeURI" | "decodeURIComponent"
const uriFunctions: Record<UriFunction, (value: string) => string> = {
@@ -37,7 +32,7 @@ const uriFunctions: Record<UriFunction, (value: string) => string> = {
export const uriGlobal = <R>(ctx: Interpreter<R>, name: UriFunction) =>
fn<R>(ctx.builtins, name, 1, (_, args) => {
const value = uriArgument(ctx.builtins, args[0], `${name} input`)
const value = coerceToString(args[0])
try {
return uriFunctions[name](value)
} catch (error) {
@@ -45,8 +40,7 @@ export const uriGlobal = <R>(ctx: Interpreter<R>, name: UriFunction) =>
}
})
const urlArgument = (builtins: Builtins, value: unknown, label: string): string =>
value instanceof URLObj ? value.url.href : uriArgument(builtins, value, label)
const urlArgument = (value: unknown): string => (value instanceof URLObj ? value.url.href : coerceToString(value))
export const urlGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
@@ -55,8 +49,8 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
if (args.length === 0) {
throw typeError("new URL(...) requires a URL string and an optional base URL.")
}
const input = urlArgument(builtins, args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(builtins, args[1], "new URL base")
const input = urlArgument(args[0])
const base = args[1] === undefined ? undefined : urlArgument(args[1])
try {
return new URLObj(into, builtins.URLSearchParams, new URL(input, base))
} catch {
@@ -74,8 +68,8 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
1,
(_, args) => {
if (args.length === 0) throw typeError(`URL.${name} requires a URL argument.`)
const input = urlArgument(builtins, args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(builtins, args[1], `URL.${name} base`)
const input = urlArgument(args[0])
const base = args[1] === undefined ? undefined : urlArgument(args[1])
try {
const parsed = new URL(input, base)
return name === "canParse" ? true : new URLObj(proto, builtins.URLSearchParams, parsed)
@@ -97,13 +91,9 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
: (thisValue, value) => {
const target = self(thisValue, name)
try {
;(target.url as unknown as Record<string, string>)[name] = uriArgument(
builtins,
value,
`URL.${name} value`,
)
;(target.url as unknown as Record<string, string>)[name] = coerceToString(value)
} catch (error) {
if (error instanceof PendingThrow || error instanceof ToolRuntimeError) throw error
if (error instanceof PendingThrow) throw error
throw typeError(`URL.${name} received an invalid value.`)
}
},
@@ -130,7 +120,7 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
items.push(
yield* preserveConsumerError(
cursor,
Effect.sync(() => uriArgument(ctx.builtins, step.value, "URLSearchParams pair value")),
Effect.sync(() => coerceToString(step.value)),
),
)
}
@@ -189,8 +179,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
const self = (thisValue: unknown, name: string) =>
receiver(URLSearchParamsObj, thisValue, `URLSearchParams.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
const arg = (name: string, args: Array<unknown>, index: number): string =>
uriArgument(builtins, args[index], `URLSearchParams.${name} argument ${index + 1}`)
const arg = (args: Array<unknown>, index: number): string => coerceToString(args[index])
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
if (args.length < count) {
throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
@@ -203,7 +192,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
2,
(thisValue, args) => {
requireArgs("append", args, 2)
self(thisValue, "append").params.append(arg("append", args, 0), arg("append", args, 1))
self(thisValue, "append").params.append(arg(args, 0), arg(args, 1))
return undefined
},
],
@@ -213,8 +202,8 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
(thisValue, args) => {
requireArgs("delete", args, 1)
const params = self(thisValue, "delete").params
if (args[1] !== undefined) params.delete(arg("delete", args, 0), arg("delete", args, 1))
else params.delete(arg("delete", args, 0))
if (args[1] !== undefined) params.delete(arg(args, 0), arg(args, 1))
else params.delete(arg(args, 0))
return undefined
},
],
@@ -223,7 +212,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
1,
(thisValue, args) => {
requireArgs("get", args, 1)
return self(thisValue, "get").params.get(arg("get", args, 0))
return self(thisValue, "get").params.get(arg(args, 0))
},
],
[
@@ -231,7 +220,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
1,
(thisValue, args) => {
requireArgs("getAll", args, 1)
return wrap(self(thisValue, "getAll").params.getAll(arg("getAll", args, 0)))
return wrap(self(thisValue, "getAll").params.getAll(arg(args, 0)))
},
],
[
@@ -240,9 +229,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
(thisValue, args) => {
requireArgs("has", args, 1)
const params = self(thisValue, "has").params
return args[1] !== undefined
? params.has(arg("has", args, 0), arg("has", args, 1))
: params.has(arg("has", args, 0))
return args[1] !== undefined ? params.has(arg(args, 0), arg(args, 1)) : params.has(arg(args, 0))
},
],
[
@@ -250,7 +237,7 @@ export const urlSearchParamsGlobal = <R>(ctx: Interpreter<R>) => {
2,
(thisValue, args) => {
requireArgs("set", args, 2)
self(thisValue, "set").params.set(arg("set", args, 0), arg("set", args, 1))
self(thisValue, "set").params.set(arg(args, 0), arg(args, 1))
return undefined
},
],
+8 -10
View File
@@ -1,4 +1,3 @@
import { toProgram } from "../data.js"
import { fn } from "../interpreter/native.js"
import { typeError } from "../interpreter/model.js"
import {
@@ -74,22 +73,21 @@ const coerce = <R>(ctx: Interpreter<R>, name: Coercion, args: Array<unknown>): u
if (name === "parseInt") return parseInt(coerceToString(raw))
return parseFloat(coerceToString(raw))
}
const value = toProgram(ctx.builtins, raw, `${name} input`)
if (name === "Number") return coerceToNumber(value)
if (name === "Boolean") return Boolean(value)
if (name === "isFinite") return Number.isFinite(coerceToNumber(value))
if (name === "isNaN") return Number.isNaN(coerceToNumber(value))
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") {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw typeError("parseInt expects a numeric radix.")
}
return parseInt(coerceToString(value), radix)
return parseInt(coerceToString(raw), radix)
}
if (name === "parseFloat") return parseFloat(coerceToString(value))
return coerceToString(value)
if (name === "parseFloat") return parseFloat(coerceToString(raw))
return coerceToString(raw)
}
/** A global coercion function such as `Number` or `parseInt`. */
export const coercion = <R>(ctx: Interpreter<R>, name: Coercion, length = 1): Native<R> =>
fn(ctx.builtins, name, length, (_, args) => toProgram(ctx.builtins, coerce(ctx, name, args), `${name} result`))
fn(ctx.builtins, name, length, (_, args) => coerce(ctx, name, args))
+110 -74
View File
@@ -1,6 +1,6 @@
import { Cause, Effect, Exit, Formatter, Schema } from "effect"
import { fromData, toData, ToolRuntimeError } from "./data.js"
import type { Builtins } from "./interpreter/intrinsics.js"
import type { Json } from "./data.js"
import type { DiagnosticKind } from "./codemode.js"
import { toolError } from "./tool-error.js"
import {
decodeInput as decodeToolInput,
@@ -36,26 +36,48 @@ export type ToolCall = {
readonly name: string
}
export type ToolCallStarted = {
readonly index: number
/** A tool call the program is making, with its decoded input. */
export type ToolInvocation = { readonly name: string; readonly input: unknown }
/** A call the program is making to an extension global, with its arguments. */
export type ExtensionInvocation = {
readonly extension: string
readonly name: string
readonly input: unknown
readonly args: ReadonlyArray<unknown>
}
export type ToolCallEnded = {
readonly index: number
readonly name: string
readonly input: unknown
readonly durationMs: number
readonly outcome: "success" | "failure" | "interrupted"
readonly message?: string
/** How a call ended; `after` hooks observe it and cannot change it. */
export type CallResult =
| { readonly status: "success"; readonly value: unknown }
| { readonly status: "failure"; readonly error: unknown }
| { readonly status: "interrupted" }
/** Hooks around every call the program makes into the host. A failing `before` denies the call. */
export type Hooks<R = never> = {
readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined
readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined
readonly "extension.after"?:
| ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>)
| undefined
}
export type ToolCallHooks<R = never> = {
/** Observes decoded tool input immediately before tool execution. */
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
/** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
export const hooked = <Call, A, R>(
call: Call,
before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined,
after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined,
run: Effect.Effect<A, unknown, R>,
): Effect.Effect<A, unknown, R> => {
const observed =
after === undefined
? run
: Effect.onExit(run, (exit) => {
if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value })
if (Cause.hasInterruptsOnly(exit.cause)) return after(call, { status: "interrupted" })
return after(call, { status: "failure", error: Cause.squash(exit.cause) })
})
return before === undefined ? observed : Effect.andThen(before(call), observed)
}
export type ToolDescription = {
@@ -144,15 +166,24 @@ const flattenTools = <R>(
]
}
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
path: visible.path,
description: visible.tool.description,
signature: isEmptyInput(visible.tool)
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
})
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
let signature: string | undefined
return {
path: visible.path,
description: visible.tool.description,
get signature() {
// Search ranks paths and descriptions first; only returned matches need their schemas rendered.
// Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
return (signature ??= [
toolExpression(visible.path),
isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
`: Promise<${outputTypeScript(visible.tool, true)}>`,
].join(""))
},
}
}
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
/** Tools indexed once per runtime, with discovery materialized on demand. */
export type Prepared<R = never> = {
readonly root: ToolNode<R>
readonly catalog: ReadonlyArray<ToolDescription>
@@ -264,12 +295,20 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
const root = toolTrie(tools)
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
let searchIndex: ReadonlyArray<SearchEntry> | undefined
let catalog: ReadonlyArray<ToolDescription> | undefined
return {
root,
catalog: visible.map(describeTool),
searchIndex: visible.map(toSearchEntry),
get catalog() {
return (catalog ??= this.searchIndex.map((entry) => entry.description))
},
get searchIndex() {
// Executing known tools only needs the trie. Render discovery when it is actually read,
// ordering after canonical-path collisions settle so instruction bytes stay deterministic.
return (searchIndex ??= flattenTools(root)
.sort((left, right) => compareText(left.path, right.path))
.map(toSearchEntry))
},
}
}
@@ -299,39 +338,40 @@ const resolve = <R>(root: ToolNode<R>, path: ReadonlyArray<string>): Tool<R> =>
return node.tool
}
export class ToolRuntimeError extends Error {
constructor(
readonly kind: Extract<
DiagnosticKind,
"UnknownTool" | "InvalidToolInput" | "InvalidToolOutput" | "ToolCallLimitExceeded"
>,
message: string,
readonly suggestions: ReadonlyArray<string> = [],
) {
super(message)
this.name = "ToolRuntimeError"
}
}
/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
export type ToolRuntime<R = never> = {
readonly calls: Array<ToolCall>
readonly execute: (path: ReadonlyArray<string>, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly search: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
readonly hooks: Hooks<R>
readonly execute: (
path: ReadonlyArray<string>,
args: Array<Json | undefined>,
) => Effect.Effect<Json | undefined, unknown, R>
readonly search: (args: Array<Json | undefined>) => Effect.Effect<Json | undefined, unknown, R>
readonly keys: (path: ReadonlyArray<string>) => ReadonlyArray<string>
}
/** Per-execution call state over tools prepared once for the runtime. */
export const make = <R>(
prepared: Prepared<R>,
builtins: Builtins,
maxToolCalls: number | undefined,
hooks?: ToolCallHooks<R>,
hooks: Hooks<R> = {},
): ToolRuntime<R> => {
const calls: Array<ToolCall> = []
const root = prepared.root
const searchTool = makeSearchTool(prepared.searchIndex)
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
const onEnd = hooks?.onToolCallEnd
if (onEnd === undefined) return effect
const startedAt = Date.now()
return effect.pipe(
Effect.onExit((exit) => {
const durationMs = Date.now() - startedAt
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
const error = Cause.squash(exit.cause)
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
return onEnd({ ...call, durationMs, outcome: "failure", message })
}),
)
}
const recordCall = (call: ToolCall): void => {
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
@@ -340,9 +380,9 @@ export const make = <R>(
calls.push(call)
}
const executeTool = (name: string, tool: Tool<R>, externalArgs: Array<unknown>) =>
const executeTool = (name: string, tool: Tool<R>, args: Array<Json | undefined>) =>
Effect.gen(function* () {
const normalized = externalArgs.length === 0 ? [{}] : externalArgs
const normalized = args.length === 0 ? [{}] : args
if (normalized.length !== 1)
throw new ToolRuntimeError("InvalidToolInput", `Tool '${name}' expects at most one input object.`)
const input = yield* Effect.try({
@@ -354,14 +394,12 @@ export const make = <R>(
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
),
})
const index = yield* Effect.sync(() => {
recordCall({ name })
return calls.length - 1
})
const call = { index, name, input }
return yield* observeEnd(
yield* Effect.sync(() => recordCall({ name }))
return yield* hooked(
{ name, input },
hooks["tool.before"],
hooks["tool.after"],
Effect.gen(function* () {
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
Effect.catchCause((cause) => {
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
@@ -374,32 +412,30 @@ export const make = <R>(
)
}),
)
// The same round trip a tool result would take through text: exact JSON.stringify semantics.
return yield* Effect.try({
try: () => fromData(builtins, decodeToolOutput(tool, raw), `Result from tool '${name}'`),
try: (): Json | undefined => {
const text = JSON.stringify(decodeToolOutput(tool, raw))
return text === undefined ? undefined : JSON.parse(text)
},
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
})
}),
call,
)
})
return {
calls,
hooks,
keys: (path) => namespaceKeys(root, path),
search: (args) =>
Effect.suspend(() =>
executeTool(
"search",
searchTool,
args.map((arg) => toData(arg, "Arguments for tool 'search'")),
),
),
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
execute: (path, args) =>
Effect.gen(function* () {
const name = canonicalSegments(path).join(".")
const externalArgs = args.map((arg) => toData(arg, `Arguments for tool '${name}'`))
const tool = resolve(root, path)
return yield* executeTool(name, tool, externalArgs)
Effect.suspend(() => {
const segments = canonicalSegments(path)
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
return executeTool(segments.join("."), resolve(root, path), args)
}),
}
}
+157 -111
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import { Cause, Effect, Schema } from "effect"
import { CodeMode, Tool, toolError } from "../src/index.js"
import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
const run = (tool: Tool.Tool<never>) =>
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
@@ -160,77 +160,154 @@ describe("CodeMode host failure boundary", () => {
})
})
describe("CodeMode tool-call observation", () => {
test("reports the tools actually invoked with decoded input", async () => {
const calls: Array<unknown> = []
describe("CodeMode call hooks", () => {
const ended = (result: CodeMode.CallResult) => {
if (result.status !== "failure") return result.status
return `failure:${result.error instanceof Error ? result.error.message : String(result.error)}`
}
// Every hook logs its event, so the order across tools and extensions is visible.
const observe = (events: Array<string>): CodeMode.Hooks => ({
"tool.before": (call) => Effect.sync(() => void events.push(`tool.before ${JSON.stringify(call)}`)),
"tool.after": (call, result) => Effect.sync(() => void events.push(`tool.after ${call.name} ${ended(result)}`)),
"extension.before": (call) => Effect.sync(() => void events.push(`extension.before ${JSON.stringify(call)}`)),
"extension.after": (call, result) =>
Effect.sync(() => void events.push(`extension.after ${call.name} ${ended(result)}`)),
})
test("surround the tools actually invoked, with decoded input", async () => {
const events: Array<string> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) => Effect.succeed(query),
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
})
const runtime = CodeMode.make({ tools: { context: { lookup } }, hooks: observe(events) })
const result = await Effect.runPromise(
CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
}).execute(`
const success = await Effect.runPromise(
runtime.execute(`
if (false) await tools.context.lookup({ query: "not called" })
return await tools.context.lookup({ query: "deployment failure" })
`),
)
expect(result.ok).toBe(true)
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
})
test("observes settled calls with outcome and duration", async () => {
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
const lookup = Tool.make({
description: "Look up a value",
input: Schema.Struct({ query: Schema.String }),
output: Schema.String,
execute: ({ query }) =>
query === "boom"
? Effect.fail(toolError("Lookup refused"))
: query === "defect"
? Effect.die("broken")
: Effect.succeed(query),
})
const runtime = CodeMode.make({
tools: { context: { lookup } },
onToolCallStart: (call) =>
Effect.sync(() => {
events.push({ phase: "start", index: call.index, name: call.name })
}),
onToolCallEnd: (call) =>
Effect.sync(() => {
expect(call.durationMs).toBeGreaterThanOrEqual(0)
events.push({
phase: "end",
index: call.index,
name: call.name,
outcome: call.outcome,
...(call.message === undefined ? {} : { message: call.message }),
})
}),
})
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
expect(success.ok).toBe(true)
expect(success).toMatchObject({ ok: true, value: "deployment failure" })
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
expect(failure.ok).toBe(false)
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
expect(defect.ok).toBe(false)
expect(events).toEqual([
'tool.before {"name":"context.lookup","input":{"query":"deployment failure"}}',
"tool.after context.lookup success",
'tool.before {"name":"context.lookup","input":{"query":"boom"}}',
"tool.after context.lookup failure:Lookup refused",
])
})
expect(events).toStrictEqual([
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
{ phase: "start", index: 0, name: "context.lookup" },
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
test("surround each call to an extension global, with its arguments, but not functions inside results", async () => {
const events: Array<string> = []
const runtime = CodeMode.make({
extensions: [
Extension.make({
name: "web",
globals: {
fetch: async (url: string, init?: { method?: string }) => ({
url,
method: init?.method ?? "GET",
json: () => 1,
}),
fail: () => {
throw new RangeError("nope")
},
},
}),
],
hooks: observe(events),
})
const result = await Effect.runPromise(
runtime.execute(`
const res = await fetch("https://a.test/", { method: "POST" })
res.json()
try { fail() } catch {}
return res.url
`),
)
expect(result).toMatchObject({ ok: true, value: "https://a.test/", toolCalls: [] })
expect(events).toEqual([
'extension.before {"extension":"web","name":"fetch","args":["https://a.test/",{"method":"POST"}]}',
"extension.after fetch success",
'extension.before {"extension":"web","name":"fail","args":[]}',
"extension.after fail failure:nope",
])
})
test("a failing before hook denies the call; the program catches the host's error", async () => {
const runtime = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
})
expect(
await Effect.runPromise(
runtime.execute(`
const denied = await fetch("https://a.test/").catch((e) => [e instanceof Error, e.message])
return [denied, await tools.lookup({})]
`),
),
).toMatchObject({ ok: true, value: [[true, "fetch is not allowed"], "ok"] })
expect(await Effect.runPromise(runtime.execute(`await fetch("https://a.test/")`))).toMatchObject({
ok: false,
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
})
const refused = CodeMode.make({
tools: {
lookup: Tool.make({
description: "Look up",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("ok"),
}),
},
hooks: { "tool.before": () => Effect.fail(toolError("lookup is not allowed")) },
})
expect(
await Effect.runPromise(
refused.execute(`try { await tools.lookup({}) } catch (e) { return [e.name, e.message] }`),
),
).toMatchObject({ ok: true, value: ["Error", "lookup is not allowed"], toolCalls: [{ name: "lookup" }] })
expect(await Effect.runPromise(refused.execute(`await tools.lookup({})`))).toMatchObject({
ok: false,
error: { kind: "ToolFailure", message: "lookup is not allowed" },
})
})
test("see a tool defect as the failure the program gets, and an interrupted extension call", async () => {
const events: Array<string> = []
const broken = Tool.make({
description: "Broken",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.die("broken"),
})
const runtime = CodeMode.make({
tools: { broken },
extensions: [Extension.make({ name: "slow", globals: { forever: () => new Promise(() => {}) } })],
hooks: observe(events),
})
expect(await Effect.runPromise(runtime.execute(`await tools.broken({})`))).toMatchObject({ ok: false })
expect(await Effect.runPromise(runtime.execute(`forever(); return "done"`))).toMatchObject({
ok: true,
value: "done",
})
expect(events).toEqual([
'tool.before {"name":"broken","input":{}}',
"tool.after broken failure:broken",
'extension.before {"extension":"slow","name":"forever","args":[]}',
"extension.after forever interrupted",
])
})
@@ -243,15 +320,11 @@ describe("CodeMode tool-call observation", () => {
execute: () => Effect.interrupt,
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute("return await tools.host.call({})"),
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["start", "end:interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
test("observes running calls interrupted during completion", async () => {
@@ -263,39 +336,17 @@ describe("CodeMode tool-call observation", () => {
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.sync(() => events.push("start")),
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
}).execute('tools.host.call({}); return "done"'),
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute(
'tools.host.call({}); return "done"',
),
)
expect(result).toMatchObject({ ok: true, value: "done" })
expect(events).toEqual(["start", "end:interrupted"])
})
test("ends calls interrupted during start observation", async () => {
const events: Array<string> = []
const call = Tool.make({
description: "Unused",
input: Schema.Struct({}),
output: Schema.String,
execute: () => Effect.succeed("unused"),
})
const exit = await Effect.runPromiseExit(
CodeMode.make({
tools: { host: { call } },
onToolCallStart: () => Effect.interrupt,
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
}).execute("return await tools.host.call({})"),
)
expect(exit._tag).toBe("Failure")
expect(events).toEqual(["interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
test("observes calls interrupted by the execution timeout", async () => {
const outcomes: Array<string> = []
const events: Array<string> = []
const call = Tool.make({
description: "Pending",
input: Schema.Struct({}),
@@ -303,15 +354,13 @@ describe("CodeMode tool-call observation", () => {
execute: () => Effect.never,
})
const result = await Effect.runPromise(
CodeMode.make({
tools: { host: { call } },
limits: { timeoutMs: 10 },
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
}).execute("return await tools.host.call({})"),
CodeMode.make({ tools: { host: { call } }, limits: { timeoutMs: 10 }, hooks: observe(events) }).execute(
"return await tools.host.call({})",
),
)
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
expect(outcomes).toEqual(["interrupted"])
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
})
})
@@ -840,20 +889,17 @@ describe("CodeMode public contract", () => {
}
})
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
const started: Array<string> = []
const ended: Array<string> = []
test("search is a counted tool call: it burns maxToolCalls and is observed", async () => {
const observed: Array<CodeMode.ToolInvocation> = []
const limited = CodeMode.make({
tools,
limits: { maxToolCalls: 1 },
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call)) },
})
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
expect(result.ok).toBe(false)
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
expect(started).toEqual(["search"])
expect(ended).toEqual(["search:success"])
expect(observed).toEqual([{ name: "search", input: {} }])
})
test("search is an opaque, shadowable global like other built-ins", async () => {
@@ -863,10 +909,9 @@ describe("CodeMode public contract", () => {
const shadowed = await Effect.runPromise(runtime.execute(`const search = () => "local"; return search()`))
expect(shadowed.ok).toBe(true)
if (shadowed.ok) expect(shadowed.value).toBe("local")
// The reference itself cannot cross the data boundary.
// The reference itself vanishes at the data boundary, as functions do in JSON.stringify.
const escaped = await Effect.runPromise(runtime.execute(`return { search }`))
expect(escaped.ok).toBe(false)
if (!escaped.ok) expect(escaped.error.kind).toBe("InvalidDataValue")
expect(escaped).toMatchObject({ ok: true, value: {} })
})
test("search defaults to 10 results and resolves exact tool paths", async () => {
@@ -1090,7 +1135,7 @@ describe("CodeMode public contract", () => {
})
const runtime = CodeMode.make({
tools: { math: { double: transformed } },
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call.input)) },
})
const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
@@ -1104,7 +1149,7 @@ describe("CodeMode public contract", () => {
expect(observed).toStrictEqual([{ value: 21 }, 21])
})
test("returns JSON-safe data and normalizes undefined to null", async () => {
test("returns JSON-safe data: undefined vanishes as in JSON.stringify, and a bare undefined is null", async () => {
const result = await Effect.runPromise(
CodeMode.execute({
code: `return { top: undefined, nested: [1, undefined] }`,
@@ -1112,9 +1157,10 @@ describe("CodeMode public contract", () => {
)
expect(result).toStrictEqual({
ok: true,
value: { top: null, nested: [1, null] },
value: { nested: [1, null] },
toolCalls: [],
})
expect(await Effect.runPromise(CodeMode.execute({ code: `return undefined` }))).toMatchObject({ value: null })
expect(Schema.decodeUnknownSync(CodeMode.Result)(JSON.parse(JSON.stringify(result)))).toStrictEqual(result)
})

Some files were not shown because too many files have changed in this diff Show More