Compare commits

..
Author SHA1 Message Date
Aiden Cline 9569ddc6a0 fix(codemode): let Headers methods report a bad receiver 2026-09-16 00:39:27 -05:00
Aiden Cline 32ae1062f5 docs(codemode): replace the stale Values section with Extension.make 2026-09-16 00:14:04 -05:00
Aiden Cline 1594bdab43 feat(codemode): add the Headers built-in 2026-09-16 00:08:50 -05: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
211 changed files with 5482 additions and 5063 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.4",
"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.4",
"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.4",
"bin": {
"opencode2": "./bin/opencode2.cjs",
},
@@ -177,7 +177,7 @@
},
"packages/client": {
"name": "@opencode/client",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/protocol": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -203,7 +203,7 @@
},
"packages/codemode": {
"name": "@opencode/codemode",
"version": "2.0.3",
"version": "2.0.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"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.4",
"dependencies": {
"effect": "catalog:",
"prettier": "3.6.2",
@@ -545,7 +546,7 @@
},
"packages/latex": {
"name": "@opencode/latex",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -559,7 +560,7 @@
},
"packages/merman": {
"name": "@opencode/merman",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opentui/core": "catalog:",
@@ -574,7 +575,7 @@
},
"packages/plugin": {
"name": "@opencode/plugin",
"version": "2.0.3",
"version": "2.0.4",
"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.4",
"dependencies": {
"@opencode/plugin": "workspace:*",
"@opencode/schema": "workspace:*",
@@ -643,7 +644,7 @@
},
"packages/protocol": {
"name": "@opencode/protocol",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/schema": "workspace:*",
"effect": "catalog:",
@@ -658,7 +659,7 @@
},
"packages/schema": {
"name": "@opencode/schema",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@standard-schema/spec": "catalog:",
"effect": "catalog:",
@@ -682,7 +683,7 @@
},
"packages/sdk": {
"name": "@opencode/sdk",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -703,7 +704,7 @@
},
"packages/server": {
"name": "@opencode/server",
"version": "2.0.3",
"version": "2.0.4",
"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.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode/client": "workspace:*",
@@ -760,7 +761,7 @@
},
"packages/simulation": {
"name": "@opencode/simulation",
"version": "2.0.3",
"version": "2.0.4",
"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.4",
"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.4",
"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.4",
"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.4",
"dependencies": {
"@opentui/core": "catalog:",
"effect": "catalog:",
@@ -893,7 +894,7 @@
},
"packages/tui": {
"name": "@opencode/tui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@opencode/client": "workspace:*",
"@opencode/core": "workspace:*",
@@ -928,7 +929,7 @@
},
"packages/ui": {
"name": "@opencode/ui",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -963,7 +964,7 @@
},
"packages/util": {
"name": "@opencode/util",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@effect/opentelemetry": "catalog:",
"@effect/platform-node": "catalog:",
@@ -996,7 +997,7 @@
},
"packages/web": {
"name": "@opencode/web",
"version": "2.0.3",
"version": "2.0.4",
"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.4",
"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.4",
"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.4",
"name": "@opencode/ai",
"type": "module",
"license": "MIT",
@@ -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.4",
"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
@@ -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
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/cli",
"version": "2.0.3",
"version": "2.0.4",
"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 -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"
+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(),
)
+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.4",
"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" },
])
})
+19 -8
View File
@@ -13,8 +13,8 @@ The idea of code mode was originally introduced by Cloudflare. See
## How it differs from JavaScript
- **Only supported APIs are available.** Programs can use the provided tools and supported JavaScript built-ins. APIs
such as `fetch`, timers, `process`, filesystem access, imports, and modules are unavailable.
- **Only supported APIs are available.** Programs can use the provided tools, supported JavaScript built-ins, and the
globals the host adds through extensions. Timers, `process`, filesystem access, imports, and modules are unavailable.
- **Unfinished work is interrupted.** Tool calls and async functions start when called. When the program finishes,
anything still running is interrupted. Unhandled rejections from un-awaited promises are returned as warnings.
- **REPL-style results.** Without an explicit `return`, the final top-level expression becomes the result. `undefined`
@@ -88,14 +88,25 @@ 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`
### `Extension.make`
`Values` exports the runtime's non-JSON value classes: `Values.URL`, `Values.URLSearchParams`, `Values.Date`,
`Values.RegExp`, `Values.Map`, `Values.Set`, and `Values.Promise`. The interpreter recognizes these by class; a
program's `new URL(...)` is a `Values.URL` wrapping the host `URL`. `Values.isValue` narrows to the data-like kinds.
Extensions are host functions a program calls directly as globals, such as `fetch`. Unlike tools they are not in the
catalog, not counted against `maxToolCalls`, and not described to the model; the host decides what they mean.
```ts
const web = Extension.make({ name: "web", globals: { fetch: (url: string) => globalThis.fetch(url) } })
const runtime = CodeMode.make({ tools, extensions: [web] })
```
Every value crossing in either direction is converted, never shared: arguments come in as copies, results go out as
copies, and a function inside a result is callable the same way. A global that shadows a built-in or another
extension throws at `CodeMode.make`.
### OpenAPI tools
+36 -30
View File
@@ -22,14 +22,14 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [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, generators, and extension handles serialize as `{}`. A bare `undefined` result is `null`.
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] Live Date, RegExp, Map, Set, URL, URLSearchParams, Headers, 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
shadowable by program declarations like other globals.
@@ -47,8 +47,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
## Values and literals
- [x] `null`, `undefined`, booleans, finite and non-finite numbers, and strings.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, custom synchronous
iterators, and synchronous generators.
- [x] Array literals, including holes and spread from arrays, strings, Maps, Sets, URLSearchParams, Headers, custom
synchronous iterators, and synchronous generators.
- [x] Object literals with shorthand, computed string/number keys, and spread following ToObject: data objects and
arrays copy own enumerable keys, strings copy index keys, and other values contribute nothing.
- [x] Template literals with interpolation.
@@ -95,8 +95,8 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] `if`/`else` and conditional expressions.
- [x] `switch`, including default clauses and fallthrough.
- [x] `for`, `while`, and `do...while`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, custom synchronous iterators, and confined
synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...of` over arrays, strings, Maps, Sets, URLSearchParams, Headers, custom synchronous iterators, and
confined synchronous generators. Abrupt completion invokes the iterator's optional `return()`.
- [x] `for...in` over own keys of plain objects, arrays, strings, and tool references; other values iterate nothing.
- [x] Unlabeled `break` and `continue`.
- [x] `try`, `catch`, optional catch bindings, and `finally`.
@@ -127,7 +127,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
string). A detached method loses its receiver, as in JS: `values.filter("abc".includes)` is a `TypeError`
because `includes` is called without a string `this`.
- [x] Constructors work as callbacks with JS call semantics: `Error` types construct (`messages.map(Error)`),
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Promise`) throw a `TypeError`,
and new-requiring constructors (`Map`, `Set`, `URL`, `URLSearchParams`, `Headers`, `Promise`) throw a `TypeError`,
like JS.
- [x] Tool references and detached `Promise` statics are rejected as callbacks with a hint to wrap them in an
arrow function.
@@ -179,10 +179,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Sequence expressions (the comma operator).
- [x] `await` for CodeMode promises and callable thenables; a plain value passes through unchanged, though every
`await` still defers its continuation one reaction turn.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, and Promise. `new` on any
other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number` say
`new` is unsupported and point at the plain call, user-defined functions report the constructor gap below, and
non-callable values are not constructors.
- [x] `new` for Array, Object, Error types, Date, RegExp, Map, Set, URL, URLSearchParams, Headers, and Promise. `new`
on any other value throws a catchable `TypeError` naming the callee: other built-in functions such as `Number`
say `new` is unsupported and point at the plain call, user-defined functions report the constructor gap below,
and non-callable values are not constructors.
- [x] Arithmetic operators: `+`, `-`, `*`, `/`, `%`, and `**`.
- [x] Equality and ordering: `==`, `!=`, `===`, `!==`, `<`, `<=`, `>`, and `>=`.
- [x] Bitwise operators: `&`, `|`, `^`, `~`, `<<`, `>>`, and `>>>`.
@@ -192,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.
@@ -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`.
@@ -448,33 +450,37 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
- [x] `crypto.randomUUID()` and `crypto.getRandomValues(uint8Array)`.
- [x] `TextEncoder` and `TextDecoder` for UTF-8 only: any other label is a `RangeError`. `TextDecoder` accepts the
`fatal` and `ignoreBOM` options; `decode` takes a Uint8Array or nothing.
- [ ] `crypto.subtle`, `Blob`, and `TextDecoder` streaming or non-UTF-8 encodings.
- [x] `new Headers()` from records, synchronous iterables of pairs, and Headers, wrapping the host's `Headers`: names
fold to lowercase, values are normalized and combined, and invalid names or values throw a `TypeError`.
- [x] Headers `append`, `delete`, `get`, `getSetCookie`, `has`, `set`, `forEach`, `keys`, `values`, and `entries`;
iteration is live and sorted by name, with `set-cookie` values kept apart.
- [x] Headers serialize to a `{ name: value }` object in JSON, in results, and in tool arguments.
- [ ] `Request`, `Response`, and `Blob`.
- [ ] `crypto.subtle` and `TextDecoder` streaming or non-UTF-8 encodings.
## 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. A handle
serializes as `{}` like any object without enumerable properties, so the host object never crosses.
- [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),
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Headers`, `Map`, `Set`, and `Uint8Array` become fresh copies with
their contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
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.4",
"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)),
}
}
+2
View File
@@ -17,6 +17,7 @@ import {
record,
SetObj,
URLSearchParamsObj,
HeadersObj,
} from "./interpreter/objects.js"
import { typeofValue } from "./interpreter/references.js"
@@ -69,6 +70,7 @@ const walk = <R>(
)
}
if (boundary && value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return Object.fromEntries(value.headers)
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)
+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 } }
+1 -1
View File
@@ -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) {
+50 -178
View File
@@ -1,25 +1,22 @@
import { Effect } from "effect"
import type { Extension } from "../extension.js"
import { coerceToString } from "../stdlib/value.js"
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
import type { Interpreter } from "./interpreter.js"
import { createErrorValue, isErrorType } from "./intrinsics.js"
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,
@@ -27,34 +24,20 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} 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,12 +45,12 @@ 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)
if (value instanceof URLObj) return new URL(value.url.href)
if (value instanceof URLSearchParamsObj) return new URLSearchParams(value.params)
if (value instanceof HeadersObj) return new Headers(value.headers)
const next = (item: unknown) => toHost(item, label, depth + 1, seen)
if (value instanceof MapObj) return new Map([...value.map].map(([key, item]) => [next(key), next(item)]))
if (value instanceof SetObj) return new Set([...value.set].map(next))
@@ -83,7 +66,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 +85,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 +98,32 @@ 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)
if (value instanceof Headers) return new HeadersObj(builtins.Headers, new Headers(value))
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 +131,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 +181,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}`
@@ -11,6 +11,7 @@ import { objectGlobal } from "../stdlib/object.js"
import { regexpGlobal } from "../stdlib/regexp.js"
import { stringGlobal } from "../stdlib/string.js"
import { uriGlobal, urlGlobal, urlSearchParamsGlobal } from "../stdlib/url.js"
import { headersGlobal } from "../stdlib/headers.js"
import { coercion } from "../stdlib/value.js"
import { base64Global, cryptoGlobal } from "../stdlib/web.js"
import { ToolReference } from "../tool-runtime.js"
@@ -80,6 +81,7 @@ const table: Record<string, Factory> = {
Set: (ctx) => setGlobal(ctx),
URL: (ctx) => urlGlobal(ctx),
URLSearchParams: (ctx) => urlSearchParamsGlobal(ctx),
Headers: (ctx) => headersGlobal(ctx),
Uint8Array: (ctx) => uint8ArrayGlobal(ctx),
TextEncoder: (ctx) => textEncoderGlobal(ctx),
TextDecoder: (ctx) => textDecoderGlobal(ctx),
@@ -84,6 +84,7 @@ import {
PromiseObj,
SetObj,
URLSearchParamsObj,
HeadersObj,
record,
remove,
set,
@@ -653,7 +654,7 @@ class Frame<R> {
const cursor = iterator === undefined ? yield* self.iterate(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, URLSearchParams, or Headers, or custom iterator value.`,
node,
)
}
@@ -756,9 +757,11 @@ class Frame<R> {
? value.set.values()
: value instanceof URLSearchParamsObj
? value.params.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
: value instanceof HeadersObj
? value.headers.entries()
: value instanceof Bytes
? value.bytes.values()
: undefined
if (iterator !== undefined) {
const proto = this.ctx.builtins.Array
return Effect.succeed({
@@ -1848,6 +1851,7 @@ class Frame<R> {
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
) {
const cursor = yield* self.iterate(value, node)
@@ -29,6 +29,7 @@ const builtins = [
"Set",
"URL",
"URLSearchParams",
"Headers",
"Uint8Array",
"TextEncoder",
"TextDecoder",
@@ -80,6 +81,7 @@ export const createBuiltins = (): Builtins => {
Set: plain(),
URL: plain(),
URLSearchParams: plain(),
Headers: plain(),
Uint8Array: plain(),
TextEncoder: plain(),
TextDecoder: plain(),
+11 -12
View File
@@ -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 })
}
}
@@ -157,6 +156,15 @@ export class URLSearchParamsObj extends Obj {
}
}
export class HeadersObj extends Obj {
constructor(
proto: Obj,
readonly headers: Headers,
) {
super(proto)
}
}
export class URLObj extends Obj {
readonly searchParams: URLSearchParamsObj
constructor(
@@ -179,26 +187,17 @@ 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,
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | Bytes =>
): value is DateObj | RegExpObj | MapObj | SetObj | URLObj | URLSearchParamsObj | HeadersObj | Bytes =>
value instanceof DateObj ||
value instanceof RegExpObj ||
value instanceof MapObj ||
value instanceof SetObj ||
value instanceof URLObj ||
value instanceof URLSearchParamsObj ||
value instanceof HeadersObj ||
value instanceof Bytes
const MAX_ARRAY_INDEX = 4_294_967_295
@@ -9,7 +9,6 @@ import {
Bytes,
DateObj,
GeneratorObj,
Handle,
MapObj,
Obj,
PromiseObj,
@@ -17,13 +16,13 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "./objects.js"
/** Values that cannot cross the data boundary. */
export const isRuntimeReference = (value: unknown): boolean =>
value instanceof Callable ||
value instanceof GeneratorObj ||
value instanceof Handle ||
value instanceof ToolReference ||
value instanceof PromiseObj ||
isWrapper(value)
@@ -87,9 +86,9 @@ export const describeValue = (value: unknown): string => {
if (value instanceof SetObj) return "a Set"
if (value instanceof URLObj) return "a URL"
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
if (value instanceof HeadersObj) return "a Headers"
if (value instanceof Bytes) return "a Uint8Array"
if (value instanceof GeneratorObj) return "a generator"
if (value instanceof 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}`
+2
View File
@@ -12,6 +12,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import { containsOpaqueReference, isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -66,6 +67,7 @@ const formatConsoleValue = (value: unknown, seen: Set<object>, depth: number): s
if (value instanceof RegExpObj) return coerceToString(value)
if (value instanceof URLObj) return coerceToString(value)
if (value instanceof URLSearchParamsObj) return coerceToString(value)
if (value instanceof HeadersObj) return `Headers ${JSON.stringify(Object.fromEntries(value.headers))}`
if (value instanceof Bytes) return `Uint8Array(${value.bytes.length}) [${value.bytes.join(",")}]`
if (depth > MAX_CONSOLE_DEPTH) return "..."
if (seen.has(value)) return "[Circular]"
+119
View File
@@ -0,0 +1,119 @@
import { Effect } from "effect"
import { constructor, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { PendingThrow, typeError } from "../interpreter/model.js"
import { entries, Arr, HeadersObj, Obj } from "../interpreter/objects.js"
import { applyCollectionCallback } from "../interpreter/callback.js"
import { isRuntimeReference } from "../interpreter/references.js"
import type { Interpreter } from "../interpreter/interpreter.js"
import { coerceToString } from "./value.js"
import { readPairs } from "./url.js"
// The host validates header names and values and throws its own TypeError; the program gets one of its own.
const attempt = <T>(run: () => T): T => {
try {
return run()
} catch (error) {
if (error instanceof PendingThrow) throw error
throw typeError(error instanceof Error ? error.message : String(error))
}
}
const constructHeaders = <R>(ctx: Interpreter<R>, init: unknown, proto: Obj): Effect.Effect<HeadersObj, unknown, R> => {
const wrap = (headers: Headers) => new HeadersObj(proto, headers)
if (init === undefined) return Effect.succeed(wrap(new Headers()))
if (init instanceof HeadersObj) return Effect.succeed(wrap(new Headers(init.headers)))
return Effect.gen(function* () {
const pairs = init instanceof Obj ? yield* readPairs(ctx, init, "new Headers(...)") : undefined
if (pairs !== undefined) return wrap(attempt(() => new Headers(pairs)))
if (!(init instanceof Obj) || isRuntimeReference(init)) {
throw typeError("new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.")
}
return wrap(
attempt(() => new Headers(Object.fromEntries(entries(init).map(([key, value]) => [key, coerceToString(value)])))),
)
})
}
export const headersGlobal = <R>(ctx: Interpreter<R>) => {
const builtins = ctx.builtins
const proto = builtins.Headers
const headers = constructor<R>(builtins, proto, {
name: "Headers",
call: requiresNew("Headers"),
construct: (args, newTarget) => constructHeaders(ctx, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string) => receiver(HeadersObj, thisValue, `Headers.prototype.${name}`)
const wrap = (items: Array<unknown>) => new Arr(builtins.Array, items)
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(`Headers.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
methods(builtins, proto, [
[
"append",
2,
(thisValue, args) => {
requireArgs("append", args, 2)
attempt(() => self(thisValue, "append").headers.append(arg(args, 0), arg(args, 1)))
return undefined
},
],
[
"delete",
1,
(thisValue, args) => {
requireArgs("delete", args, 1)
attempt(() => self(thisValue, "delete").headers.delete(arg(args, 0)))
return undefined
},
],
[
"get",
1,
(thisValue, args) => {
requireArgs("get", args, 1)
return attempt(() => self(thisValue, "get").headers.get(arg(args, 0)))
},
],
["getSetCookie", 0, (thisValue) => wrap(self(thisValue, "getSetCookie").headers.getSetCookie())],
[
"has",
1,
(thisValue, args) => {
requireArgs("has", args, 1)
return attempt(() => self(thisValue, "has").headers.has(arg(args, 0)))
},
],
[
"set",
2,
(thisValue, args) => {
requireArgs("set", args, 2)
attempt(() => self(thisValue, "set").headers.set(arg(args, 0), arg(args, 1)))
return undefined
},
],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").headers.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").headers.values()))],
[
"entries",
0,
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").headers.entries(), ([key, value]) => wrap([key, value]))),
],
[
"forEach",
1,
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(ctx, args[0], "Headers.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.headers.entries())) yield* apply([value, key, target])
return undefined
})
},
],
])
return headers
}
+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)
},
+24 -18
View File
@@ -107,12 +107,10 @@ export const urlGlobal = <R>(ctx: Interpreter<R>) => {
return url
}
const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(ctx: Interpreter<R>, value: unknown, label: string): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(value)
if (cursor === undefined) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
if (cursor === undefined) throw typeError(`${label} expects iterable [name, value] pairs.`)
const items: Array<string> = []
while (true) {
const step = yield* cursor.next
@@ -126,6 +124,26 @@ const readPair = <R>(ctx: Interpreter<R>, value: unknown): Effect.Effect<Array<s
}
})
/** Reads a synchronous iterable of `[name, value]` pairs as strings; `undefined` when `init` is not iterable. */
export const readPairs = <R>(
ctx: Interpreter<R>,
init: unknown,
label: string,
): Effect.Effect<Array<[string, string]> | undefined, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* ctx.iterate(init)
if (cursor === undefined) return undefined
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) throw typeError(`${label} expects iterable [name, value] pairs.`)
return pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value, label)))
}
})
const constructURLSearchParams = <R>(
ctx: Interpreter<R>,
init: unknown,
@@ -139,20 +157,8 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const cursor = yield* ctx.iterate(init)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(ctx, step.value)))
}
}
const pairs = yield* readPairs(ctx, init, "new URLSearchParams(...)")
if (pairs !== undefined) return wrap(new URLSearchParams(pairs))
if (isRuntimeReference(init)) {
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
+2
View File
@@ -13,6 +13,7 @@ import {
SetObj,
URLObj,
URLSearchParamsObj,
HeadersObj,
} from "../interpreter/objects.js"
import type { Interpreter } from "../interpreter/interpreter.js"
@@ -28,6 +29,7 @@ export const coerceToString = (value: unknown): string => {
if (value instanceof SetObj) return "[object Set]"
if (value instanceof URLObj) return value.url.href
if (value instanceof URLSearchParamsObj) return value.params.toString()
if (value instanceof HeadersObj) return "[object Headers]"
if (value instanceof Bytes) return value.bytes.join(",")
if (value instanceof ErrorObj) {
// Match Error.prototype.toString: "name: message", or just one when the other is empty.
+82 -55
View File
@@ -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))
},
}
}
@@ -316,6 +355,7 @@ export class ToolRuntimeError extends Error {
/** 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 hooks: Hooks<R>
readonly execute: (
path: ReadonlyArray<string>,
args: Array<Json | undefined>,
@@ -328,27 +368,10 @@ export type ToolRuntime<R = never> = {
export const make = <R>(
prepared: Prepared<R>,
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) {
@@ -371,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
@@ -400,16 +421,22 @@ export const make = <R>(
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)),
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
execute: (path, args) =>
Effect.suspend(() => executeTool(canonicalSegments(path).join("."), resolve(root, path), args)),
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)
}),
}
}
+152 -106
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 () => {
@@ -1089,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" })`))
+166 -172
View File
@@ -2,67 +2,12 @@ import { describe, expect, test } from "bun:test"
import { Effect, Schema } from "effect"
import { CodeMode, Extension, Tool } from "../src/index.js"
class Bag {
static made = 0
static of(...items: Array<string>) {
return new this(items)
}
constructor(readonly items: Array<string> = []) {
Bag.made++
}
get size() {
return this.items.length
}
set size(length: number) {
this.items.length = length
}
add(item: string) {
this.items.push(item)
return this
}
toArray() {
return [...this.items]
}
pair() {
return { self: this, list: [this, new Bag()] }
}
async later<T>(value: T) {
return value
}
async reject(reason: unknown) {
throw reason
}
fail() {
throw new RangeError("boom")
}
get lazy() {
return Promise.resolve(1)
}
detached() {
return new Other()
}
}
class Other {}
class Big extends Bag {
double() {
return this.items.length * 2
}
}
class Vault {
secrets = new Map<string, string>()
set(key: string, value: string) {
this.secrets.set(key, value)
}
}
const held: Array<unknown> = []
const config = { retries: 3, nested: { deep: true } }
const requests: Array<unknown> = []
const extension = Extension.make({
name: "bag",
name: "web",
globals: {
Bag,
Big,
Vault,
keep: (value: unknown) => {
held.push(value)
return value
@@ -70,6 +15,19 @@ const extension = Extension.make({
settings: () => config,
later: async (value: number) => value + 1,
first: (map: Map<unknown, unknown>) => map.get("k"),
fetch: async (url: string, init?: { method?: string }) => {
requests.push([url, init])
const bytes = new TextEncoder().encode(`{"url":"${url}"}`)
return {
status: 200,
ok: true,
headers: { get: (name: string) => (name === "content-type" ? "application/json" : null) },
text: () => new TextDecoder().decode(bytes),
json: () => JSON.parse(new TextDecoder().decode(bytes)),
bytes: () => bytes,
handlers: [(step: number) => step + 1],
}
},
},
})
@@ -87,66 +45,61 @@ const failure = async (code: string, target = runtime) => {
return result.error
}
describe("extension classes behave like JS", () => {
test("construct, call methods, read and write accessors", async () => {
expect(await value(`const b = new Bag(["a"]); b.add("b"); return [b.size, b.toArray()]`)).toEqual([2, ["a", "b"]])
expect(await value(`const b = new Bag(["a", "b"]); b.size = 1; return b.toArray()`)).toEqual(["a"])
})
test("instanceof, constructor, typeof, and prototype identity", async () => {
expect(
await value(
`const b = new Bag(); return [b instanceof Bag, b.constructor === Bag, typeof Bag, Bag.prototype.constructor === Bag]`,
),
).toEqual([true, true, "function", true])
})
test("statics, including `new this()` through an exposed subclass", async () => {
expect(await value(`return [Bag.of("x", "y").toArray(), Big.of("q") instanceof Big, Big.of === Bag.of]`)).toEqual([
["x", "y"],
true,
true,
])
})
test("data properties are invisible, so a program write never reaches the host class", async () => {
Bag.made = 0
expect(await value(`Bag.made = 999; return Bag.made`)).toBe(999)
expect(Bag.made).toBe(0)
expect(await value(`return [Bag.made, new Bag(["a"]).items]`)).toEqual([null, null])
})
test("inheritance chains to the exposed ancestor", async () => {
expect(
await value(`const b = new Big(["a"]); return [b.double(), b.add("b").size, b instanceof Bag, b instanceof Big]`),
).toEqual([2, 2, true, true])
})
test("calling a class without new throws the host TypeError", async () => {
const error = await failure(`Bag()`)
expect(error.message).toStartWith("TypeError: ")
expect(error.message).toContain("new")
})
test("a function global is callable, awaitable, and not constructible", async () => {
describe("extension functions", () => {
test("a global is callable, awaitable, and not constructible", async () => {
expect(await value(`return await later(1)`)).toBe(2)
expect(await value(`return [typeof later, later.name, later.length]`)).toEqual(["function", "later", 1])
expect((await failure(`new later()`)).message).toContain("new later(...) is not supported")
})
test("a program can patch a prototype for its own run only", async () => {
expect(await value(`Bag.prototype.add = () => "patched"; return new Bag().add("x")`)).toBe("patched")
expect(await value(`return new Bag().add("x").toArray()`)).toEqual(["x"])
test("a function inside a result is callable and crosses the same way", async () => {
requests.length = 0
expect(
await value(
`const res = await fetch("https://a.test/", { method: "GET" }); return [res.status, res.ok, res.headers.get("content-type"), res.text(), res.json(), [...res.bytes()].length, typeof res.json, res.json.name, res.handlers[0](1)]`,
),
).toEqual([
200,
true,
"application/json",
'{"url":"https://a.test/"}',
{ url: "https://a.test/" },
25,
"function",
"json",
2,
])
expect(requests).toEqual([["https://a.test/", { method: "GET" }]])
})
test("a function inside a result is named by its path in diagnostics", async () => {
expect((await failure(`const res = await fetch("https://a.test/"); res.handlers[0](() => 1)`)).message).toContain(
"Argument 1 to fetch.handlers[0] contains a function",
)
const target = CodeMode.make({
extensions: [Extension.make({ name: "odd", globals: { make: () => ({ sym: () => Symbol("s") }) } })],
})
expect((await failure(`make().sym()`, target)).message).toContain("make.sym produced a symbol")
})
test("a function is invisible to the data boundary like any program function", async () => {
expect(await value(`return await fetch("https://a.test/")`)).toEqual({
status: 200,
ok: true,
headers: {},
handlers: [null],
})
expect(await value(`return JSON.stringify((await fetch("https://a.test/")).headers)`)).toBe("{}")
})
test("a class global is only a function; calling it throws the host TypeError", async () => {
const target = CodeMode.make({ extensions: [Extension.make({ name: "cls", globals: { Bag: class Bag {} } })] })
expect((await failure(`Bag()`, target)).message).toContain("without")
expect((await failure(`new Bag()`, target)).message).toContain("new Bag(...) is not supported")
})
})
describe("values are converted at the boundary, never shared", () => {
test("the same host instance is the same handle", async () => {
expect(
await value(`const b = new Bag(); const p = b.pair(); return [p.self === b, p.list[0] === b, keep(b) === b]`),
).toEqual([true, true, true])
expect(await value(`const p = new Bag().pair(); return p.list[1] instanceof Bag`)).toBe(true)
})
test("plain data passed in is a copy the program cannot change afterwards", async () => {
held.length = 0
await value(
@@ -163,14 +116,46 @@ describe("values are converted at the boundary, never shared", () => {
expect(config).toEqual({ retries: 3, nested: { deep: true } })
})
test("Map and Set contents are converted element-wise, so handles unwrap inside them", async () => {
expect(await value(`const b = new Bag(); return first(new Map([["k", b]])) === b`)).toBe(true)
test("the same host value returned twice is two program values", async () => {
expect(await value(`return settings() === settings()`)).toBe(false)
expect(await value(`return keep(settings()) === settings()`)).toBe(false)
})
test("Map and Set contents are converted element-wise", async () => {
expect(await value(`return first(new Map([["k", { z: 1 }]]))`)).toEqual({ z: 1 })
held.length = 0
await value(`const inner = { z: 1 }; keep(new Set([inner])); inner.z = 2`)
expect([...(held[0] as Set<{ z: number }>)][0]).toEqual({ z: 1 })
})
test("Headers cross as copies in both directions", async () => {
const stored = new Headers({ "X-A": "1" })
const target = CodeMode.make({
extensions: [
Extension.make({
name: "http",
globals: {
headers: () => stored,
keep: (value: Headers) => {
held.push(value)
return value
},
},
}),
],
})
held.length = 0
expect(
await value(
`const h = headers(); h.set("x-a", "2"); const back = keep(h); back.set("x-a", "3"); return [h instanceof Headers, h.get("x-a"), back === h, back.get("x-a"), [...back]]`,
target,
),
).toEqual([true, "2", false, "3", [["x-a", "3"]]])
expect(stored.get("x-a")).toBe("1")
expect(held[0]).toBeInstanceOf(Headers)
expect((held[0] as Headers).get("x-a")).toBe("2")
})
test("bytes cross as copies in both directions; ArrayBuffer comes in as Uint8Array", async () => {
const stored = new Uint8Array([1, 2, 3])
const target = CodeMode.make({
@@ -214,6 +199,13 @@ describe("values are converted at the boundary, never shared", () => {
expect(Object.keys(held[0] as object)).toEqual([])
})
test("an Error with an unknown name crosses as a plain Error", async () => {
held.length = 0
await value(`const e = new Error("x"); e.name = "constructor"; keep(e); e.name = "__proto__"; keep(e)`)
expect(held[0]).toBeInstanceOf(Error)
expect(held[1]).toBeInstanceOf(Error)
})
test("functions, promises, and symbols cannot be passed in", async () => {
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
@@ -228,82 +220,55 @@ describe("values are converted at the boundary, never shared", () => {
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
})
test("an instance of an unexposed class cannot come out", async () => {
expect((await failure(`new Bag().detached()`)).message).toContain("produced a Other, which the program cannot hold")
})
test("a getter must be synchronous", async () => {
expect((await failure(`new Bag().lazy`)).message).toContain("Bag.prototype.lazy returned a Promise")
})
})
describe("the host object behind a handle is unreachable", () => {
test("enumeration, spread, and JSON see no own properties", async () => {
expect(
await value(`const b = new Bag(["a"]); return [Object.keys(b), Object.entries({ ...b }), String(b)]`),
).toEqual([[], [], "[object Object]"])
})
test("a handle serializes as {} when returned, stringified, or handed to a tool", async () => {
expect(await value(`return new Bag()`)).toEqual({})
expect(await value(`return JSON.stringify(new Bag())`)).toBe("{}")
const tools = CodeMode.make({
extensions: [extension],
tools: {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({ v: Schema.Unknown }),
output: Schema.Unknown,
execute: (input) => Effect.succeed(input.v),
}),
},
})
expect(await value(`return await tools.echo({ v: new Bag() })`, tools)).toEqual({})
})
test("a method only runs on a handle of its own class", async () => {
expect((await failure(`const add = new Bag().add; add("x")`)).message).toContain(
"Illegal invocation: Bag.prototype.add called on undefined",
)
expect((await failure(`const o = { add: Bag.prototype.add }; o.add("x")`)).message).toContain(
"called on a data object",
)
const vault = new Vault()
test("a value that cannot come out carries a location like any built-in error, sync or async", async () => {
const target = CodeMode.make({
extensions: [Extension.make({ name: "vault", globals: { Bag, Vault, vault: () => vault } })],
extensions: [
Extension.make({ name: "odd", globals: { sym: () => Symbol("s"), later: async () => Symbol("s") } }),
],
})
expect((await failure(`const v = vault(); v.add = Bag.prototype.add; v.add("x")`, target)).message).toContain(
"Illegal invocation: Bag.prototype.add called on a Vault",
)
expect(vault.secrets.size).toBe(0)
expect((await failure(`sym()`, target)).location).toEqual((await failure(`JSON.parse("{")`, target)).location)
expect((await failure(`await later()`, target)).location).toBeDefined()
})
test("reading an accessor off the prototype itself is an illegal invocation", async () => {
expect((await failure(`Bag.prototype.size`)).message).toContain("Illegal invocation")
test("a class instance cannot come out", async () => {
class Other {}
const target = CodeMode.make({
extensions: [Extension.make({ name: "odd", globals: { detached: () => new Other() } })],
})
expect((await failure(`detached()`, target)).message).toContain("produced a Other, which the program cannot hold")
})
})
describe("host errors", () => {
test("a synchronous throw becomes the matching program error", async () => {
expect(await value(`try { new Bag().fail() } catch (e) { return [e instanceof RangeError, e.message] }`)).toEqual([
const target = CodeMode.make({
extensions: [
Extension.make({
name: "odd",
globals: {
fail: () => {
throw new RangeError("boom")
},
},
}),
],
})
expect(await value(`try { fail() } catch (e) { return [e instanceof RangeError, e.message] }`, target)).toEqual([
true,
"boom",
])
})
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
expect(
await value(
`try { await new Bag().reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
),
).toEqual([true, "bad"])
expect(await value(`try { await new Bag().reject("plain") } catch (e) { return e }`)).toBe("plain")
const reason = { status: 404, nested: { a: 1 } }
const target = CodeMode.make({
extensions: [
Extension.make({
name: "api",
globals: {
reject: async (reason: unknown) => {
throw reason
},
get: async () => Promise.reject(reason),
boom: () => {
throw reason
@@ -312,6 +277,13 @@ describe("host errors", () => {
}),
],
})
expect(
await value(
`try { await reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
target,
),
).toEqual([true, "bad"])
expect(await value(`try { await reject("plain") } catch (e) { return e }`, target)).toBe("plain")
expect(await value(`try { await get() } catch (e) { e.status = 0; return e }`, target)).toEqual({
status: 0,
nested: { a: 1 },
@@ -325,23 +297,45 @@ describe("host errors", () => {
describe("configuration", () => {
test("extension calls are not tool calls", async () => {
const limited = CodeMode.make({ extensions: [extension], limits: { maxToolCalls: 0 } })
const result = await Effect.runPromise(limited.execute(`new Bag().add("x"); return await later(1)`))
const result = await Effect.runPromise(
limited.execute(`(await fetch("https://a.test/")).json(); return await later(1)`),
)
expect(result.ok).toBe(true)
expect(result.toolCalls).toEqual([])
})
test("a global must be a class or a function", () => {
test("a result handed to a tool is plain data", async () => {
const tools = CodeMode.make({
extensions: [extension],
tools: {
echo: Tool.make({
description: "Echo",
input: Schema.Struct({ v: Schema.Unknown }),
output: Schema.Unknown,
execute: (input) => Effect.succeed(input.v),
}),
},
})
expect(await value(`return await tools.echo({ v: await fetch("https://a.test/") })`, tools)).toEqual({
status: 200,
ok: true,
headers: {},
handlers: [null],
})
})
test("a global must be a function", () => {
expect(() => Extension.make({ name: "bad", globals: { n: 1 as never } })).toThrow(
'Extension "bad" global "n" must be a class or a function.',
'Extension "bad" global "n" must be a function.',
)
})
test("a global may not shadow a built-in or another extension", () => {
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: class {} } })] })).toThrow(
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: () => 1 } })] })).toThrow(
'Extension "web" global "URL" is already defined.',
)
expect(() =>
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { Bag: class {} } })] }),
).toThrow('Extension "again" global "Bag" is already defined.')
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { fetch: () => 1 } })] }),
).toThrow('Extension "again" global "fetch" is already defined.')
})
})
+240 -20
View File
@@ -237,7 +237,7 @@ describe("RegExp", () => {
).toEqual(["1", "22"])
})
test("lastIndex is writable and exec coerces its stored value", async () => {
test("lastIndex is writable and stores a number", async () => {
expect(
await value(`
const pattern = /(?:ab|cd)\\d?/g
@@ -247,40 +247,112 @@ describe("RegExp", () => {
pattern.lastIndex = 0
return [stored, match[0], match.index, pattern.lastIndex]
`),
).toEqual([["12", "string"], "ab4", 17, 0])
expect((await error(`delete /a/.lastIndex`)).message).toContain("Cannot delete property 'lastIndex'")
).toEqual([[12, "number"], "ab4", 17, 0])
// lastIndex is a prototype accessor, so delete is a no-op rather than a TypeError.
expect(await value(`const re = /a/; return [delete re.lastIndex, re.lastIndex]`)).toEqual([true, 0])
})
test("exec coerces CodeMode data objects assigned to lastIndex", async () => {
test("a non-numeric lastIndex runs from 0; non-global exec and test leave it alone", async () => {
expect(
await value(`
const pattern = /a/g
pattern.lastIndex = {}
const stored = pattern.lastIndex
const match = pattern.exec("ba")
pattern.lastIndex = 10
const missed = pattern.exec("a")
return [stored, match.index, pattern.lastIndex, missed]
const plain = /a/
plain.lastIndex = 5
return [match.index, pattern.lastIndex, missed, plain.exec("ba").index, plain.test("ba"), plain.lastIndex]
`),
).toEqual([{}, 1, 0, null])
).toEqual([1, 0, null, 1, true, 5])
})
test("non-global exec and test coerce and preserve lastIndex", async () => {
test("String methods read and update lastIndex like exec", async () => {
expect(
await value(`
const execPattern = /a/
const execIndex = {}
execPattern.lastIndex = execIndex
const match = execPattern.exec("ba")
const testPattern = /a/
const testIndex = {}
testPattern.lastIndex = testIndex
const matched = testPattern.test("ba")
return [match.index, execPattern.lastIndex === execIndex, matched, testPattern.lastIndex === testIndex]
const re = /a/y
re.exec("aa")
re.lastIndex = 0
return ["aa".replace(re, "b"), re.lastIndex]
`),
).toEqual([1, true, true, true])
).toEqual(["ba", 1])
expect(
await value(`
const re = /a/g
re.exec("aaa")
return ["aaa".match(re), re.lastIndex]
`),
).toEqual([["a", "a", "a"], 0])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["aaa".replace(re, () => "b"), re.lastIndex]
`),
).toEqual(["bbb", 0])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["aaa".replaceAll(re, "b"), re.lastIndex]
`),
).toEqual(["bbb", 0])
expect(
await value(`
const re = /a/y
re.lastIndex = 1
const m = "baa".match(re)
return [m.index, re.lastIndex]
`),
).toEqual([1, 2])
})
test("split, search, and matchAll leave lastIndex unchanged like JS", async () => {
expect(
await value(`
const re = /a/y
re.lastIndex = 2
return ["banana".split(re), re.lastIndex]
`),
).toEqual([["b", "n", "n", ""], 2])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["banana".search(re), re.lastIndex]
`),
).toEqual([1, 2])
expect(
await value(`
const re = /a/y
re.lastIndex = 2
return ["banana".search(re), re.lastIndex]
`),
).toEqual([-1, 2])
expect(
await value(`
const re = /a/g
re.lastIndex = 2
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
`),
).toEqual([[3, 5], 2])
expect(
await value(`
const re = /a/gy
re.lastIndex = 1
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
`),
).toEqual([[1], 1])
})
test("String methods leave lastIndex untouched without g or y", async () => {
expect(
await value(`
const re = /a/
re.lastIndex = 5
return ["aaa".replace(re, "b"), "aaa".match(re).index, "aaa".split(re), "aaa".search(re), re.lastIndex]
`),
).toEqual(["baa", 0, ["", "", "", ""], 0, 5])
})
test("an unmatched string pattern returns null", async () => {
@@ -563,6 +635,154 @@ describe("URL and URI helpers", () => {
})
})
describe("Headers", () => {
test("constructs from records, pairs, Maps, and Headers; names fold to lowercase and values combine", async () => {
expect(
await value(`
const headers = new Headers({ "Content-Type": "text/plain", "X-Count": 1, "X-Null": null })
headers.append("Accept", "text/html")
headers.append("accept", "application/json")
headers.set("x-count", "2")
headers.delete("x-null")
const copy = new Headers(headers)
copy.set("content-type", "text/html")
return {
get: headers.get("content-type"),
missing: headers.get("x-missing"),
combined: headers.get("ACCEPT"),
has: [headers.has("Accept"), headers.has("x-null")],
count: headers.get("x-count"),
copied: [headers.get("content-type"), copy.get("content-type")],
pairs: [...new Headers([["b", "2"], ["A", "1"]])],
map: [...new Headers(new Map([["k", "v"]]))],
keys: headers.keys(),
values: headers.values(),
entries: headers.entries(),
}
`),
).toEqual({
get: "text/plain",
missing: null,
combined: "text/html, application/json",
has: [true, false],
count: "2",
copied: ["text/plain", "text/html"],
pairs: [
["a", "1"],
["b", "2"],
],
map: [["k", "v"]],
keys: ["accept", "content-type", "x-count"],
values: ["text/html, application/json", "text/plain", "2"],
entries: [
["accept", "text/html, application/json"],
["content-type", "text/plain"],
["x-count", "2"],
],
})
})
test("iterates in sorted order everywhere iteration is allowed, and getSetCookie keeps cookies apart", async () => {
expect(
await value(`
const headers = new Headers({ b: "2", a: "1" })
headers.append("Set-Cookie", "x=1")
headers.append("set-cookie", "y=2")
const seen = []
headers.forEach((value, name, self) => seen.push(name + "=" + value + ":" + (self === headers)))
const [first] = headers
function* pairs() { yield* headers }
return {
seen,
first,
spread: [...headers],
from: Array.from(headers).length,
generator: [...pairs()].length,
object: Object.fromEntries(headers),
cookies: headers.getSetCookie(),
}
`),
).toEqual({
seen: ["a=1:true", "b=2:true", "set-cookie=x=1:true", "set-cookie=y=2:true"],
first: ["a", "1"],
spread: [
["a", "1"],
["b", "2"],
["set-cookie", "x=1"],
["set-cookie", "y=2"],
],
from: 4,
generator: 4,
object: { a: "1", b: "2", "set-cookie": "y=2" },
cookies: ["x=1", "y=2"],
})
})
test("serializes as a name-to-value object at the boundary and in JSON; prints for console", async () => {
const result = await run(`
const headers = new Headers({ "X-A": "1", b: "2" })
console.log(headers)
return { headers, json: JSON.stringify({ headers }), text: String(headers), type: typeof headers, is: headers instanceof Headers }
`)
expect(result.ok && result.value).toEqual({
headers: { b: "2", "x-a": "1" },
json: '{"headers":{"b":"2","x-a":"1"}}',
text: "[object Headers]",
type: "object",
is: true,
})
expect(result.ok && result.logs?.[0]).toBe('Headers {"b":"2","x-a":"1"}')
})
test("rejects what it cannot build from, and invalid names and values, with TypeErrors the program can catch", async () => {
expect(
await value(`
function message(run) {
try { run(); return null } catch (error) { return error instanceof TypeError ? error.message : error }
}
const headers = new Headers()
return [
message(() => Headers()),
message(() => new Headers(null)),
message(() => new Headers(1)),
message(() => new Headers("a=1")),
message(() => new Headers(new Date())),
message(() => new Headers(() => 1)),
message(() => new Headers([["name"]])),
message(() => new Headers([["a", "b", "c"]])),
message(() => new Headers({ "bad name": "x" })),
message(() => new Headers({ name: "bad\u0000value" })),
message(() => headers.get("invalid\u0100")),
message(() => headers.has({})),
message(() => headers.set("a", "invalid\u0100")),
message(() => headers.append("a")),
message(() => headers.forEach()),
message(() => headers.forEach(1)),
message(() => { const get = headers.get; return get("a") }),
]
`),
).toEqual([
"Constructor Headers requires 'new'.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects a record of names to values, iterable [name, value] pairs, or Headers.",
"new Headers(...) expects iterable [name, value] pairs.",
"new Headers(...) expects iterable [name, value] pairs.",
expect.stringContaining("bad name"),
expect.stringContaining("invalid value"),
expect.stringContaining("Invalid header name"),
expect.stringContaining("[object Object]"),
expect.stringContaining("invalid value"),
"Headers.append requires 2 arguments.",
"Headers.forEach requires 1 argument.",
"Headers.forEach expects a function callback.",
"Headers.prototype.get called on incompatible receiver undefined.",
])
})
})
describe("Map", () => {
test("get/set/has/size with chaining", async () => {
expect(
+14
View File
@@ -337,3 +337,17 @@ describe("tool arguments cross in a useful form where JSON.stringify would give
expect(seen).toEqual({ s: [1, 2], r: {}, p: "a=1&b=2", m: {} })
})
})
describe("tools.search alias", () => {
test("tools.search(...) behaves like the bare search(...) when no tool owns that path", async () => {
const runtime = CodeMode.make({ tools: { api: { list: echo("List things", "listed") } } })
const direct = await value(runtime, `return search({ query: "list" })`)
expect(await value(runtime, `return tools.search({ query: "list" })`)).toStrictEqual(direct)
expect(await value(runtime, `return (await tools.search({ query: "list" })).items[0].path`)).toBe("tools.api.list")
})
test("a registered root-level search tool takes precedence", async () => {
const runtime = CodeMode.make({ tools: { search: echo("Custom search", "custom") } })
expect(await value(runtime, `return await tools.search({})`)).toBe("custom")
})
})
+412
View File
@@ -3,10 +3,14 @@
* - html/webappapis/atob/base64.any.js (btoa reference encoder, input list, and atob WebIDL cases)
* - fetch/data-urls/resources/base64.json (copied to fixtures/wpt-base64.json)
* - WebCryptoAPI/randomUUID.https.any.js
* - fetch/api/headers/{headers-basic,headers-casing,headers-combine,headers-errors,headers-normalize,header-setcookie}.any.js
*
* Copyright © web-platform-tests contributors. Governed by the 3-Clause BSD license in LICENSE.wpt.
*
* `assert_throws_dom("InvalidCharacterError", …)` becomes a check for a TypeError: CodeMode has no DOMException.
* Headers cases that need `Symbol.iterator`, iterator objects from `keys()`/`values()`/`entries()` (CodeMode returns
* arrays), or a custom iterator on a Headers instance are left out, as are two set-cookie cases that Bun's own Headers
* fails by sorting `set-cookie2` ahead of `set-cookie`.
*/
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
@@ -166,3 +170,411 @@ describe("crypto.randomUUID WPT parity (WebCryptoAPI/randomUUID.https.any.js)",
).toEqual([true, true, true, 768])
})
})
// Enough of testharness.js to run the Headers files close to verbatim; each `test` records its failure, if any.
const testharness = `
const failures = []
function test(run, name) { try { run() } catch (error) { failures.push(name + ": " + (error && error.message ? error.message : error)) } }
function assert_equals(actual, expected, message) { if (actual !== expected) throw new Error((message || "") + " expected " + JSON.stringify(expected) + " got " + JSON.stringify(actual)) }
function assert_true(actual, message) { assert_equals(actual, true, message) }
function assert_false(actual, message) { assert_equals(actual, false, message) }
function assert_array_equals(actual, expected, message) { assert_equals(JSON.stringify(actual), JSON.stringify(expected), message) }
function assert_nested_array_equals(actual, expected) { assert_array_equals(actual, expected) }
function assert_throws_js(type, run) { try { run() } catch (error) { if (error instanceof type) return; throw new Error("threw " + error.name) } throw new Error("did not throw") }
function assert_unreached() { throw new Error("unreachable") }
`
describe("Headers WPT parity (fetch/api/headers)", () => {
test("headers-basic.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { new Headers() }, "Create headers from no parameter")
test(function() { new Headers(undefined) }, "Create headers from undefined parameter")
test(function() { new Headers({}) }, "Create headers from empty object")
var parameters = [null, 1]
parameters.forEach(function(parameter) {
test(function() { assert_throws_js(TypeError, function() { new Headers(parameter) }) }, "Create headers with " + parameter + " should throw")
})
var headerDict = {"name1": "value1", "name2": "value2", "name3": "value3", "name4": null, "name5": undefined, "name6": 1, "Content-Type": "value4"}
var headerSeq = []
for (var name in headerDict) headerSeq.push([name, headerDict[name]])
test(function() {
var headers = new Headers(headerSeq)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("length"), null, "init should be treated as a sequence, not as a dictionary")
}, "Create headers with sequence")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with record")
test(function() {
var headers = new Headers(headerDict)
var headers2 = new Headers(headers)
for (name in headerDict) assert_equals(headers2.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}, "Create headers with existing headers")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.append(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check append method")
test(function() {
var headers = new Headers()
for (name in headerDict) {
headers.set(name, headerDict[name])
assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
}
}, "Check set method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_true(headers.has(name), "headers has name " + name)
assert_false(headers.has("nameNotInHeaders"), "headers do not have header: nameNotInHeaders")
}, "Check has method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) {
assert_true(headers.has(name), "headers have a header: " + name)
headers.delete(name)
assert_true(!headers.has(name), "headers do not have anymore a header: " + name)
}
}, "Check delete method")
test(function() {
var headers = new Headers(headerDict)
for (name in headerDict) assert_equals(headers.get(name), String(headerDict[name]), "name: " + name + " has value: " + headerDict[name])
assert_equals(headers.get("nameNotInHeaders"), null, "header: nameNotInHeaders has no value")
}, "Check get method")
var headerEntriesDict = {"name1": "value1", "Name2": "value2", "name": "value3", "content-Type": "value4", "Content-Typ": "value5", "Content-Types": "value6"}
var sortedHeaderDict = {}
var headerValues = []
var sortedHeaderKeys = Object.keys(headerEntriesDict).map(function(value) {
sortedHeaderDict[value.toLowerCase()] = headerEntriesDict[value]
headerValues.push(headerEntriesDict[value])
return value.toLowerCase()
}).sort()
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.keys(), sortedHeaderKeys)
for (const key of headers.keys()) assert_true(sortedHeaderKeys.indexOf(key) != -1)
}, "Check keys method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.values(), sortedHeaderKeys.map((key) => sortedHeaderDict[key]))
for (const value of headers.values()) assert_true(headerValues.indexOf(value) != -1)
}, "Check values method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals(headers.entries(), sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
for (const entry of headers.entries()) assert_equals(entry[1], sortedHeaderDict[entry[0]])
}, "Check entries method")
test(function() {
var headers = new Headers(headerEntriesDict)
assert_array_equals([...headers], sortedHeaderKeys.map((key) => [key, sortedHeaderDict[key]]))
}, "Check Symbol.iterator method")
test(function() {
var headers = new Headers(headerEntriesDict)
var index = 0
headers.forEach(function(value, key, container) {
assert_equals(headers, container)
assert_equals(key, sortedHeaderKeys[index])
assert_equals(value, sortedHeaderDict[sortedHeaderKeys[index]])
index++
})
assert_equals(index, sortedHeaderKeys.length)
}, "Check forEach method")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
headers.delete("foo")
}
assert_array_equals(actualKeys, ["bar", "baz"])
assert_array_equals(actualValues, ["0", "1"])
}, "Iteration skips elements removed while iterating")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.delete("bar")
}
assert_array_equals(actualKeys, ["bar", "baz", "quux"])
assert_array_equals(actualValues, ["0", "1", "3"])
}, "Removing elements already iterated over causes an element to be skipped during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("X-yZ", "4")
}
assert_array_equals(actualKeys, ["bar", "baz", "foo", "quux", "x-yz"])
assert_array_equals(actualValues, ["0", "1", "2", "3", "4"])
}, "Appending a value pair during iteration causes it to be reached during iteration")
test(() => {
const headers = new Headers({"foo": "2", "baz": "1", "BAR": "0", "quux": "3"})
const actualKeys = []
const actualValues = []
for (const [header, value] of headers) {
actualKeys.push(header)
actualValues.push(value)
if (header === "baz") headers.append("abc", "-1")
}
assert_array_equals(actualKeys, ["bar", "baz", "baz", "foo", "quux"])
assert_array_equals(actualValues, ["0", "1", "1", "2", "3"])
}, "Prepending a value pair before the current element position causes it to be skipped during iteration and adds the current element a second time")
return failures
`),
).toEqual([])
})
test("headers-casing.any.js", async () => {
expect(
await value(`
${testharness}
var headerDictCase = {"UPPERCASE": "value1", "lowercase": "value2", "mixedCase": "value3", "Content-TYPE": "value4"}
function checkHeadersCase(originalName, headersToCheck, expectedDict) {
var lowCaseName = originalName.toLowerCase()
var upCaseName = originalName.toUpperCase()
var expectedValue = expectedDict[originalName]
assert_equals(headersToCheck.get(originalName), expectedValue, "name: " + originalName + " has value: " + expectedValue)
assert_equals(headersToCheck.get(lowCaseName), expectedValue, "name: " + lowCaseName + " has value: " + expectedValue)
assert_equals(headersToCheck.get(upCaseName), expectedValue, "name: " + upCaseName + " has value: " + expectedValue)
}
test(function() {
var headers = new Headers(headerDictCase)
for (const name in headerDictCase) checkHeadersCase(name, headers, headerDictCase)
}, "Create headers, names use characters with different case")
test(function() {
var headers = new Headers()
for (const name in headerDictCase) {
headers.append(name, headerDictCase[name])
checkHeadersCase(name, headers, headerDictCase)
}
}, "Check append method, names use characters with different case")
test(function() {
var headers = new Headers()
for (const name in headerDictCase) {
headers.set(name, headerDictCase[name])
checkHeadersCase(name, headers, headerDictCase)
}
}, "Check set method, names use characters with different case")
test(function() {
var headers = new Headers()
for (const name in headerDictCase) headers.set(name, headerDictCase[name])
for (const name in headerDictCase) headers.delete(name.toLowerCase())
for (const name in headerDictCase) assert_false(headers.has(name), "header " + name + " should have been deleted")
}, "Check delete method, names use characters with different case")
return failures
`),
).toEqual([])
})
test("headers-combine.any.js", async () => {
expect(
await value(`
${testharness}
var headerSeqCombine = [["single", "singleValue"], ["double", "doubleValue1"], ["double", "doubleValue2"], ["triple", "tripleValue1"], ["triple", "tripleValue2"], ["triple", "tripleValue3"]]
var expectedDict = {"single": "singleValue", "double": "doubleValue1, doubleValue2", "triple": "tripleValue1, tripleValue2, tripleValue3"}
test(function() {
var headers = new Headers(headerSeqCombine)
for (const name in expectedDict) assert_equals(headers.get(name), expectedDict[name])
}, "Create headers using same name for different values")
test(function() {
var headers = new Headers(headerSeqCombine)
for (const name in expectedDict) {
assert_true(headers.has(name), "name: " + name + " has value(s)")
headers.delete(name)
assert_false(headers.has(name), "name: " + name + " has no value(s) anymore")
}
}, "Check delete and has methods when using same name for different values")
test(function() {
var headers = new Headers(headerSeqCombine)
for (const name in expectedDict) {
headers.set(name, "newSingleValue")
assert_equals(headers.get(name), "newSingleValue", "name: " + name + " has value: newSingleValue")
}
}, "Check set methods when called with already used name")
test(function() {
var headers = new Headers(headerSeqCombine)
for (const name in expectedDict) {
var value = headers.get(name)
headers.append(name, "newSingleValue")
assert_equals(headers.get(name), (value + ", " + "newSingleValue"))
}
}, "Check append methods when called with already used name")
test(() => {
const headers = new Headers([["1", "a"], ["1", "b"]])
for (let header of headers) assert_array_equals(header, ["1", "a, b"])
}, "Iterate combined values")
test(() => {
const headers = new Headers([["2", "a"], ["1", "b"], ["2", "b"]]), expected = [["1", "b"], ["2", "a, b"]]
let i = 0
for (let header of headers) {
assert_array_equals(header, expected[i])
i++
}
assert_equals(i, 2)
}, "Iterate combined values in sorted order")
return failures
`),
).toEqual([])
})
test("headers-errors.any.js", async () => {
expect(
await value(`
${testharness}
test(function() { assert_throws_js(TypeError, function() { new Headers([["name"]]) }) }, "Create headers giving an array having one string as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid", "invalidValue1", "invalidValue2"]]) }) }, "Create headers giving an array having three strings as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["invalid\u0100", "Value1"]]) }) }, "Create headers giving bad header name as init argument")
test(function() { assert_throws_js(TypeError, function() { new Headers([["name", "invalidValue\u0100"]]) }) }, "Create headers giving bad header value as init argument")
var badNames = ["invalid\u0100", {}]
var badValues = ["invalid\u0100"]
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.get(name) }) }, "Check headers get with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.delete(name) }) }, "Check headers delete with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.has(name) }) }, "Check headers has with an invalid name " + name)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set(name, "Value1") }) }, "Check headers set with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.set("name", value) }) }, "Check headers set with an invalid value " + value)
})
badNames.forEach(function(name) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("invalid\u0100", "Value1") }) }, "Check headers append with an invalid name " + name)
})
badValues.forEach(function(value) {
test(function() { var headers = new Headers(); assert_throws_js(TypeError, function() { headers.append("name", value) }) }, "Check headers append with an invalid value " + value)
})
test(function() {
var headers = new Headers([["name", "value"]])
assert_throws_js(TypeError, function() { headers.forEach() })
assert_throws_js(TypeError, function() { headers.forEach(undefined) })
assert_throws_js(TypeError, function() { headers.forEach(1) })
}, "Headers forEach throws if argument is not callable")
test(function() {
var headers = new Headers([["name1", "value1"], ["name2", "value2"], ["name3", "value3"]])
var counter = 0
try {
headers.forEach(function(value, name) {
counter++
if (name == "name2") throw "error"
})
} catch (e) {
assert_equals(counter, 2)
assert_equals(e, "error")
return
}
assert_unreached()
}, "Headers forEach loop should stop if callback is throwing exception")
return failures
`),
).toEqual([])
})
test("headers-normalize.any.js", async () => {
expect(
await value(`
${testharness}
const expectations = {
"name1": [" space ", "space"],
"name2": ["\\ttab\\t", "tab"],
"name3": [" spaceAndTab\\t", "spaceAndTab"],
"name4": ["\\r\\n newLine", "newLine"],
"name5": ["newLine\\r\\n ", "newLine"],
"name6": ["\\r\\n\\tnewLine", "newLine"],
"name7": ["\\t\\f\\tnewLine\\n", "\\f\\tnewLine"],
"name8": ["newLine\\xa0", "newLine\\xa0"],
}
test(function () {
const headerDict = Object.fromEntries(Object.entries(expectations).map(([name, [actual]]) => [name, actual]))
var headers = new Headers(headerDict)
for (const name in expectations) {
const expected = expectations[name][1]
assert_equals(headers.get(name), expected, "name: " + name + " has normalized value: " + expected)
}
}, "Create headers with not normalized values")
test(function () {
var headers = new Headers()
for (const name in expectations) {
headers.append(name, expectations[name][0])
const expected = expectations[name][1]
assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected)
}
}, "Check append method with not normalized values")
test(function () {
var headers = new Headers()
for (const name in expectations) {
headers.set(name, expectations[name][0])
const expected = expectations[name][1]
assert_equals(headers.get(name), expected, "name: " + name + " has value: " + expected)
}
}, "Check set method with not normalized values")
return failures
`),
).toEqual([])
})
test("header-setcookie.any.js", async () => {
expect(
await value(`
${testharness}
const headerList = [["set-cookie", "foo=bar"], ["Set-Cookie", "fizz=buzz; domain=example.com"]]
const setCookie2HeaderList = [["set-cookie2", "foo2=bar2"], ["Set-Cookie2", "fizz2=buzz2; domain=example2.com"]]
test(function () {
const headers = new Headers(headerList)
assert_equals(headers.get("set-cookie"), "foo=bar, fizz=buzz; domain=example.com")
}, "Headers.prototype.get combines set-cookie headers in order")
test(function () {
const headers = new Headers(headerList)
assert_nested_array_equals([...headers], [["set-cookie", "foo=bar"], ["set-cookie", "fizz=buzz; domain=example.com"]])
}, "Headers iterator does not combine set-cookie headers")
test(function () {
const headers = new Headers(setCookie2HeaderList)
assert_nested_array_equals([...headers], [["set-cookie2", "foo2=bar2, fizz2=buzz2; domain=example2.com"]])
}, "Headers iterator does not special case set-cookie2 headers")
test(function () {
const headers = new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]])
assert_nested_array_equals([...headers], [["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]])
}, "Headers iterator preserves set-cookie ordering")
test(function () {
const headers = new Headers(headerList)
assert_true(headers.has("sEt-cOoKiE"))
}, "Headers.prototype.has works for set-cookie")
test(function () {
const headers = new Headers(headerList)
headers.set("set-cookie", "foo2=bar2")
assert_nested_array_equals([...headers], [["set-cookie", "foo2=bar2"]])
}, "Headers.prototype.set works for set-cookie")
test(function () {
const headers = new Headers(headerList)
headers.delete("set-Cookie")
assert_nested_array_equals([...headers], [])
}, "Headers.prototype.delete works for set-cookie")
test(function () { assert_array_equals(new Headers().getSetCookie(), []) }, "Headers.prototype.getSetCookie with no headers present")
test(function () { assert_array_equals(new Headers([headerList[0]]).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header")
test(function () { assert_array_equals(new Headers({ "Set-Cookie": "foo=bar" }).getSetCookie(), ["foo=bar"]) }, "Headers.prototype.getSetCookie with one header created from an object")
test(function () { assert_array_equals(new Headers(headerList).getSetCookie(), ["foo=bar", "fizz=buzz; domain=example.com"]) }, "Headers.prototype.getSetCookie with multiple headers")
test(function () { assert_array_equals(new Headers([["set-cookie", ""]]).getSetCookie(), [""]) }, "Headers.prototype.getSetCookie with an empty header")
test(function () { assert_array_equals(new Headers([["set-cookie", "x"], ["set-cookie", "x"]]).getSetCookie(), ["x", "x"]) }, "Headers.prototype.getSetCookie with two equal headers")
test(function () { assert_array_equals(new Headers([["set-cookie2", "x"], ["set-cookie", "y"], ["set-cookie2", "z"]]).getSetCookie(), ["y"]) }, "Headers.prototype.getSetCookie ignores set-cookie2 headers")
test(function () { assert_array_equals(new Headers([["set-cookie", "z=z"], ["set-cookie", "a=a"], ["set-cookie", "n=n"]]).getSetCookie(), ["z=z", "a=a", "n=n"]) }, "Headers.prototype.getSetCookie preserves header ordering")
return failures
`),
).toEqual([])
})
})
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-app",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode/console-core",
"version": "2.0.3",
"version": "2.0.4",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-function",
"version": "2.0.3",
"version": "2.0.4",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-mail",
"version": "2.0.3",
"version": "2.0.4",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode/console-support",
"version": "2.0.3",
"version": "2.0.4",
"type": "module",
"license": "MIT",
"scripts": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "2.0.3",
"version": "2.0.4",
"name": "@opencode/core",
"type": "module",
"license": "MIT",
@@ -111,6 +111,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:*",
+40 -27
View File
@@ -13,6 +13,7 @@ import type {
import { Effect, Ref, Schema, Semaphore } from "effect"
import { definition, normalizedName } from "../tool/runtime.js"
import { CodeModeCatalog } from "./catalog.js"
import { CodeModeWeb } from "./web.js"
const ExecuteFile = Schema.Struct({
data: Schema.String,
@@ -59,8 +60,8 @@ export type Inventory = {
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
const description = [
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
@@ -82,9 +83,9 @@ export const create = (
const files = yield* Ref.make<Array<CollectedFiles>>([])
const calls = yield* Ref.make<Array<ExecuteCall>>([])
const lock = Semaphore.makeUnsafe(1)
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
const record = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
lock.withPermit(
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))),
)
const result = yield* runtime(
inventory,
@@ -103,27 +104,7 @@ export const create = (
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
return text === "" ? null : text
}),
{
onToolCallStart: ({ index, name, input }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
return next
})
},
onToolCallEnd: ({ index, name, input, outcome }) => {
const shown = displayInput(input)
return updateCalls((items) => {
const next = [...items]
next[index] = {
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
status: outcome === "success" ? "completed" : "error",
}
return next
})
},
},
progressHooks(record),
).execute(code)
const toolCalls = yield* Ref.get(calls)
const collected = (yield* Ref.get(files))
@@ -158,6 +139,38 @@ export const create = (
} satisfies Info
}
// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
const rows = new WeakMap<object, number>()
const start = (call: object, entry: ExecuteCall) =>
record((items) => {
rows.set(call, items.length)
return [...items, entry]
})
const settle = (call: object, result: CodeMode.CallResult) =>
record((items) => {
const index = rows.get(call)
if (index === undefined) return items
const next = [...items]
next[index] = { ...items[index], status: result.status === "success" ? "completed" : "error" }
return next
})
return {
"tool.before": (call) => {
const shown = displayInput(call.input)
return start(call, { tool: call.name, status: "running", ...(shown ? { input: shown } : {}) })
},
"tool.after": settle,
"extension.before": (call) =>
start(call, {
tool: call.name,
status: "running",
...(call.name === "fetch" ? { input: CodeModeWeb.display(call.args) } : {}),
}),
"extension.after": settle,
} satisfies CodeMode.Hooks
}
export const catalog = (inventory: Inventory) => {
const pinned = new Set(
Array.from(inventory.tools.values())
@@ -204,7 +217,7 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
function runtime(
inventory: Inventory,
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
hooks?: CodeMode.ToolCallHooks,
hooks?: CodeMode.Hooks,
) {
// A path may carry namespace metadata, a callable tool, child tools, or all three.
const root: ToolNode = { children: new Map() }
@@ -219,7 +232,7 @@ function runtime(
})
}
const tools = renderTools(root)
return CodeMode.make<typeof tools>({ tools, ...hooks })
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], hooks })
}
function getNode<T>(root: Node<T>, path: string) {
+46
View File
@@ -0,0 +1,46 @@
export * as CodeModeWeb from "./web.js"
import { Extension } from "@opencode/codemode"
const TIMEOUT_MS = 30_000
type Init = {
readonly method?: string
readonly headers?: Record<string, string> | Array<[string, string]>
readonly body?: string | Uint8Array<ArrayBuffer> | URLSearchParams
}
const fetch = async (input: string | URL, init: Init = {}) => {
const response = await globalThis.fetch(input, {
method: init.method,
headers: init.headers,
body: init.body,
signal: AbortSignal.timeout(TIMEOUT_MS),
})
const bytes = await response.bytes()
const headers = Object.fromEntries(response.headers)
const text = () => new TextDecoder().decode(bytes)
return {
url: response.url,
status: response.status,
statusText: response.statusText,
ok: response.ok,
redirected: response.redirected,
headers: {
get: (name: string) => headers[name.toLowerCase()] ?? null,
has: (name: string) => name.toLowerCase() in headers,
entries: () => Object.entries(headers),
},
text: async () => text(),
json: async () => JSON.parse(text()) as unknown,
bytes: async () => bytes,
}
}
export const extension = Extension.make({ name: "web", globals: { fetch } })
/** What to show for a fetch call: its method and URL. */
export const display = (args: ReadonlyArray<unknown>) => {
const [input, init] = args as [string | URL, Init | undefined]
return { method: init?.method?.toUpperCase() ?? "GET", url: String(input) }
}
+7 -1
View File
@@ -111,7 +111,13 @@ export class CodeRequiredError extends Schema.TaggedError<CodeRequiredError>()("
export class AuthorizationError extends Schema.TaggedError<AuthorizationError>()("Integration.Authorization", {
cause: Schema.Defect(),
}) {}
}) {
override get message() {
const cause = this.cause
if (cause instanceof Error && cause.message) return cause.message
return "Authorization failed"
}
}
export class AttemptNotFoundError extends Schema.TaggedError<AttemptNotFoundError>()("Integration.AttemptNotFound", {
integrationID: ID,
+9 -4
View File
@@ -142,9 +142,14 @@ function snapshot(job: Active): Info {
}
}
function errorText(error: unknown) {
if (error instanceof Error) return error.message
return String(error)
function errorText(cause: Cause.Cause<unknown>) {
const render = (error: Error): string => {
const message = error.message || error.name || "Unknown error"
if (!(error.cause instanceof Error)) return message
const detail = render(error.cause)
return detail === message || detail.startsWith(`${message}\n`) ? detail : `${message}\nCaused by: ${detail}`
}
return Cause.prettyErrors(cause).map(render).join("\n") || "Unknown error"
}
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
@@ -205,7 +210,7 @@ export const make = Effect.gen(function* () {
status,
completed_at,
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
...(Exit.isFailure(exit) ? { error: errorText(exit.cause) } : {}),
},
}
if (status !== "cancelled") yield* persistBackground(next)
+34 -3
View File
@@ -7,6 +7,7 @@ import {
discoverOAuthServerInfo,
extractWWWAuthenticateParams,
parseErrorResponse,
resourceUrlFromServerUrl,
UnauthorizedError,
type FetchLike,
type OAuthClientProvider,
@@ -14,6 +15,7 @@ import {
type StoredOAuthClientInformation,
type StoredOAuthTokens,
} from "@modelcontextprotocol/client"
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
import { Cause, Deferred, Effect } from "effect"
import { ConfigMCP } from "@opencode/schema/config/mcp"
import { Credential } from "../credential.js"
@@ -99,6 +101,25 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
return request
})
// A configured authorization server document stands in for RFC 9728 discovery: the SDK reuses this
// state instead of probing the resource server, whose well-known path may not exist.
export const configuredDiscovery = async (input: {
readonly config: typeof ConfigMCP.Remote.Type
readonly fetchFn: FetchLike
}): Promise<OAuthDiscoveryState | undefined> => {
const url = input.config.oauth ? input.config.oauth.auth_server_metadata_url : undefined
if (!url) return undefined
const response = await input.fetchFn(url, { headers: { accept: "application/json" } })
if (!response.ok) throw new Error(`HTTP ${response.status} trying to load OAuth authorization server metadata`)
const body = await response.json()
const metadata = OAuthMetadataSchema.safeParse(body).data ?? OpenIdProviderDiscoveryMetadataSchema.parse(body)
return {
authorizationServerUrl: metadata.issuer,
authorizationServerMetadata: metadata,
resourceMetadata: { resource: resourceUrlFromServerUrl(input.config.url).toString() },
}
}
export interface Store {
readonly tokens: () => Promise<StoredOAuthTokens | undefined>
readonly saveTokens: (tokens: StoredOAuthTokens) => Promise<void>
@@ -134,7 +155,10 @@ export const provider = (options: Options): OAuthClientProvider => {
let discovery: OAuthDiscoveryState | undefined = options.discovery
return {
redirectUrl,
discoveryState: () => discovery,
discoveryState: async () => {
discovery ??= await configuredDiscovery({ config: options.config, fetchFn: send })
return discovery
},
saveDiscoveryState: (state) => {
discovery = state
},
@@ -144,7 +168,12 @@ export const provider = (options: Options): OAuthClientProvider => {
if (!resource) return identity
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
return new URL(resource)
const canonical = new URL(resource)
// The transport dials the configured URL with extra query parameters and some servers echo
// that back as the resource. Query is transport detail, not identity: the token stays bound
// to the configured URL so a later refresh names the same resource the login did.
if (canonical.origin === identity.origin && canonical.pathname === identity.pathname) return identity
return canonical
},
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
...(redirect ? { state: () => redirect.state } : {}),
@@ -388,7 +417,9 @@ export const authorize = (input: {
// CIMD needs the server to advertise it and accept public clients, and our published document only
// lists the loopback redirect; a configured client_id always wins.
const discovery = yield* Effect.tryPromise({
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
try: async () =>
(await configuredDiscovery({ config: input.config, fetchFn })) ??
discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
})
const cimd =
+76 -57
View File
@@ -68,7 +68,7 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Model") {}
type Data = {
models: Map<Provider.ID, Map<ID, MutableInfo>>
models: Map<Provider.ID, ReadonlyMap<ID, Info>>
defaultModel?: { providerID: Provider.ID; modelID: ID }
}
@@ -82,59 +82,73 @@ const layer = Layer.effect(
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
name: "model",
initial: () => ({
models: new Map(
(input?.available ?? []).map((record) => [
record.provider.id,
new Map(
Array.from(record.models, ([id, model]) => [
id,
{
...structuredClone(model),
id,
providerID: record.provider.id,
} as MutableInfo,
]),
),
]),
),
models: new Map((input?.available ?? []).map((record) => [record.provider.id, record.models])),
}),
editor: (data) => ({
list: (providerID) =>
providerID === undefined
? Array.from(data.models.values()).flatMap((models) => Array.from(models.values()))
: Array.from(data.models.get(providerID)?.values() ?? []),
get: (providerID, modelID) => data.models.get(providerID)?.get(modelID),
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = data.models.get(providerID)
if (!models) return
const model = models.get(modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
models.set(modelID, model)
},
remove: (providerID, modelID) => {
data.models.get(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
editor: (data) => {
// Definitions are shared across Locations; a provider's map and a model are copied before their first edit.
const owned = new WeakSet<ReadonlyMap<ID, Info>>()
const drafts = new WeakSet<Info>()
const writable = (providerID: Provider.ID) => {
const current = data.models.get(providerID)
if (!current) return undefined
if (owned.has(current)) return current as Map<ID, Info>
const copy = new Map(current)
owned.add(copy)
data.models.set(providerID, copy)
return copy
}
const draft = (providerID: Provider.ID, modelID: ID) => {
const models = writable(providerID)
if (!models) return undefined
const current = models.get(modelID)
if (!current) return undefined
if (drafts.has(current)) return current as MutableInfo
const copy = structuredClone(current) as MutableInfo
drafts.add(copy)
models.set(modelID, copy)
return copy
}
return {
list: (providerID) => {
const ids = providerID === undefined ? Array.from(data.models.keys()) : [providerID]
return ids.flatMap((id) =>
Array.from(data.models.get(id)?.keys() ?? []).flatMap((modelID) => draft(id, modelID) ?? []),
)
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}),
get: draft,
update: (providerID, modelID, update) => {
// Model edits cannot create/enable a provider or bypass its availability decision.
const models = writable(providerID)
if (!models) return
const model = draft(providerID, modelID) ?? (Info.default(providerID, modelID) as MutableInfo)
update(model)
model.id = modelID
model.providerID = providerID
const provider = input?.records.get(providerID)?.provider
AISDKNative.rewrite(model, {
specifier: model.package ?? provider?.package,
providerID,
canonical: model.canonical ?? provider?.canonical,
modelID: model.modelID ?? modelID,
})
drafts.add(model)
models.set(modelID, model)
},
remove: (providerID, modelID) => {
writable(providerID)?.delete(modelID)
},
default: {
get: () => data.defaultModel,
set: (providerID, modelID) => {
data.defaultModel = { providerID, modelID }
},
},
provider: {
list: () => Array.from(input?.records.values() ?? []),
get: (providerID) => input?.records.get(providerID),
},
}
},
// read() also refreshes dependencies changed inside a State.batch before notification.
notify: () => notify,
})
@@ -147,6 +161,8 @@ const layer = Layer.effect(
byProvider: ReadonlyMap<Provider.ID, ReadonlyMap<ID, Info>>
}
| undefined
// An unedited model keeps its shared definition object across rebuilds, so its merged output is reusable.
const merged = new WeakMap<Info, { provider: Provider.Info | undefined; model: Info }>()
const read = Effect.fn("Model.snapshot")(function* () {
while (true) {
const current = yield* providers.snapshot()
@@ -165,9 +181,10 @@ const layer = Layer.effect(
return [
providerID,
new Map(
Array.from(models, ([id, model]) => [
id,
{
Array.from(models, ([id, model]) => {
const reusable = merged.get(model)
if (reusable && reusable.provider === provider) return [id, reusable.model]
const value = {
...model,
...(provider?.canonical === undefined ? {} : { canonical: provider.canonical }),
package: model.package ?? provider?.package,
@@ -176,8 +193,10 @@ const layer = Layer.effect(
settings: Provider.mergeOverlay(provider?.settings, model.settings),
headers: Provider.mergeHeaders(provider?.headers, model.headers),
body: Provider.mergeOverlay(provider?.body, model.body),
} satisfies Info,
]),
} satisfies Info
merged.set(model, { provider, model: value })
return [id, value]
}),
),
]
}),
+5 -1
View File
@@ -1,7 +1,7 @@
export * as PermissionSaved from "./saved.js"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode/schema/project"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -52,6 +52,10 @@ const layer = Layer.effect(
projectID: row.project_id,
action: row.action,
resource: row.resource,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
},
}),
)
})
+10 -4
View File
@@ -411,11 +411,10 @@ export const make = Effect.fn("PluginHost.make")(function* (
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message })
? permission.reply({ requestID: input.requestID, reply: input.decision, message: input.message })
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
rules: sessions.setPermissions,
},
plugin: {
list: () => response(plugin.list()),
@@ -459,7 +458,9 @@ export const make = Effect.fn("PluginHost.make")(function* (
vcs: {
get: () => response(vcs.info()),
base: () => response(vcs.base()),
branches: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
branch: {
list: (input) => response(vcs.branches({ search: input?.search, limit: input?.limit })),
},
status: () => response(vcs.status()),
diff: (input) => response(vcs.diff(input.mode, { context: input.context, base: input.base })),
transform: vcs.transform,
@@ -532,7 +533,12 @@ export const make = Effect.fn("PluginHost.make")(function* (
prompt: sessions.prompt,
generate: (input) => sessions.generate(input).pipe(Effect.map((text) => ({ text }))),
command: (input) => sessions.command({ ...input, command: input.name }),
rename: sessions.rename,
update: Effect.fn(function* (input) {
yield* sessions.get(input.sessionID)
if (input.title !== undefined) yield* sessions.rename({ sessionID: input.sessionID, title: input.title })
if (input.permissions !== undefined)
yield* sessions.setPermissions({ sessionID: input.sessionID, permissions: input.permissions })
}),
move: sessions.move,
synthetic: sessions.synthetic,
interrupt: (input) =>
+22 -13
View File
@@ -228,12 +228,18 @@ export interface Interface extends State.Transformable<Editor> {
export class Service extends Context.Service<Service, Interface>()("@opencode/Provider") {}
// Every location references the same index for a shared immutable definition array.
const definitions = new WeakMap<readonly Model.Info[], ReadonlyMap<Model.ID, Model.Info>>()
function index(models: readonly Model.Info[]) {
const cached = definitions.get(models)
const definitions = new WeakMap<readonly Model.Info[], Map<ID, ReadonlyMap<Model.ID, Model.Info>>>()
function index(providerID: ID, models: readonly Model.Info[]) {
const indexes = definitions.get(models) ?? new Map<ID, ReadonlyMap<Model.ID, Model.Info>>()
const cached = indexes.get(providerID)
if (cached) return cached
const result = freeze(new Map(models.map((model) => [model.id, model])), true)
definitions.set(models, result)
// Model shares these definitions without copying, so a foreign definition takes this provider's identity here.
const result = freeze(
new Map(models.map((model) => [model.id, model.providerID === providerID ? model : { ...model, providerID }])),
true,
)
indexes.set(providerID, result)
definitions.set(models, indexes)
return result
}
@@ -281,7 +287,7 @@ const layer = Layer.effect(
add: (definition) => {
records.set(definition.info.id, {
provider: structuredClone(definition.info) as MutableInfo,
models: index(definition.models),
models: index(definition.info.id, definition.models),
sourceConnection: definition.sourceConnection,
})
},
@@ -300,7 +306,7 @@ const layer = Layer.effect(
},
models: {
set: (id, values) => {
entry(id).models = index(values)
entry(id).models = index(id, values)
},
update: (providerID, modelID, update) => {
const record = entry(providerID)
@@ -338,7 +344,7 @@ const layer = Layer.effect(
// Registrations may outlive a borrowed service layer; their later disposal must
// not query dependencies that have already closed.
yield* Effect.addFinalizer(() => State.shutdown(state.reload()))
let cached: { records: Snapshot["records"]; access: string; value: Snapshot } | undefined
let cached: { records: Snapshot["records"]; value: Snapshot } | undefined
const snapshot = Effect.fn("Provider.snapshot")(function* () {
while (true) {
const revision = integrations.revision()
@@ -347,10 +353,6 @@ const layer = Layer.effect(
const connections = yield* integrations.list()
// Either fold can disable a plugin that also contributed to the other domain.
if (revision !== integrations.revision() || records !== state.get()) continue
const access = JSON.stringify(
connections.map((integration) => [integration.id, integration.connections.map(IntegrationConnection.key)]),
)
if (cached?.records === records && cached.access === access) return cached.value
const byID = new Map(connections.map((integration) => [integration.id, integration]))
const available = Array.from(records.values()).filter((record) => {
if (record.provider.activation === "disabled") return false
@@ -366,8 +368,15 @@ const layer = Layer.effect(
if (integration?.connections.length) return true
return record.provider.integrationID === undefined && !integration
})
// A credential change that leaves the same definitions available is not a catalog change.
if (
cached?.records === records &&
cached.value.available.length === available.length &&
cached.value.available.every((record, index) => record === available[index])
)
return cached.value
const value = freeze({ records, available, providers: available.map((record) => record.provider) }, true)
cached = { records, access, value }
cached = { records, value }
return value
}
})
+1 -1
View File
@@ -131,7 +131,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
)
}),
"session.renamed": () => Effect.void,
"session.permissions.updated": () => Effect.void,
"session.permissions": () => Effect.void,
"session.deleted": () => Effect.void,
"session.forked": () => Effect.void,
"session.inbox.delivered": () => Effect.void,
+1 -1
View File
@@ -573,7 +573,7 @@ const layer = Layer.effectDiscard(
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.PermissionsUpdated, (event) =>
yield* bus.project(SessionEvent.Permissions, (event) =>
db
.update(SessionTable)
.set({ permission: event.data.permissions, time_updated: event.created })
+1 -1
View File
@@ -78,7 +78,7 @@ export const make = Effect.fn("Session.make")(function* () {
input: { permissions: Permission.Ruleset },
) {
yield* get(sessionID)
yield* bus.publish(SessionEvent.PermissionsUpdated, { sessionID, permissions: input.permissions })
yield* bus.publish(SessionEvent.Permissions, { sessionID, permissions: input.permissions })
})
const switchAgent = Effect.fn("Session.switchAgent")(function* (
sessionID: SessionSchema.ID,
+1 -1
View File
@@ -257,7 +257,7 @@ const layer = () =>
const invocation: ShellCreateBefore = {
command: input.command,
cwd: input.cwd ?? location.directory,
timeout: input.timeout,
timeout: input.timeout ?? 0,
shell: input.shell ?? (yield* shell.resolve({ priority: "config" })),
env: {
...(sessionEnvironment ?? process.env),

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