Compare commits

..
23 Commits
Author SHA1 Message Date
Aiden Cline 7f3a08a64f fix(ai): classify gateway account limits as quota and keep 4xx non-retryable 2026-09-15 22:24:29 -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
141 changed files with 3713 additions and 4698 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. |
+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')
+21 -6
View File
@@ -59,7 +59,15 @@ export const isContextOverflowFailure = (failure: unknown) =>
: Schema.is(ProviderErrorEvent)(failure) && failure.classification === "context-overflow"
const decodeJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown))
const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"])
// OpenCode Zen reports account caps as typed 429/402 errors that are not throttles.
const QUOTA_CODES = new Set([
"insufficient_quota",
"usage_not_included",
"billing_error",
"gousagelimiterror",
"freeusagelimiterror",
"creditlimitexceeded",
])
const AUTH_CODES = new Set(["authentication_error", "permission_error"])
const SERVER_CODES = new Set([
"api_error",
@@ -87,7 +95,8 @@ const CONTENT_POLICY_CODES = new Set([
// as a `[code]` label at the start of the rewritten message.
const GATEWAY_CODE_LABEL = /^[^:\n]+: \[([A-Za-z0-9_.-]+)\]/
const RATE_LIMIT_TEXT = /rate increased too quickly|rate[-_\s]?limit|too[_\s]?many[_\s]?requests/i
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded/i
// Only consulted on 429, where throttles and account caps share a status.
const QUOTA_TEXT = /insufficient[-_\s]?quota|quota[-_\s]?exceeded|budget exceeded|usage limit/i
// Policy rejections without a dedicated code, matched against the provider's own
// explanation only. OpenAI reuses `invalid_prompt` for usage-policy rejections while
// Bedrock Mantle reuses it for schema validation; Anthropic reports blocked output
@@ -143,7 +152,11 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
return new InvalidRequestError({ ...details, classification: "payload-too-large" })
if (codes.some((code) => CONTENT_POLICY_CODES.has(code)) || (clientScoped && CONTENT_POLICY_TEXT.test(input.message)))
return new ContentPolicyError(details)
if (codes.some((code) => QUOTA_CODES.has(code)) || (input.status === 429 && QUOTA_TEXT.test(text)))
if (
input.status === 402 ||
codes.some((code) => QUOTA_CODES.has(code)) ||
(input.status === 429 && QUOTA_TEXT.test(text))
)
return new QuotaExceededError(details)
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details)
@@ -163,10 +176,12 @@ export function classifyProviderFailure(input: ProviderFailure): AIError["reason
input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
// Server codes and phrasing only decide when no HTTP status contradicts them:
// gateways such as OpenCode Zen substitute `server_error` for codes they do
// not forward, so a 4xx with a server code is still a rejected request.
((input.status === undefined || input.status < 400) &&
!codes.some((code) => INVALID_REQUEST_CODES.has(code)) &&
SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))
((!codes.some((code) => INVALID_REQUEST_CODES.has(code)) && SERVER_ERROR_TEXT.test(text)) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable"))))
)
return new ProviderInternalError({
...details,
+3 -3
View File
@@ -309,7 +309,7 @@ describe("RequestExecutor", () => {
}),
)
it.effect("classifies provider overloads hidden behind HTTP 400", () =>
it.effect("does not let server codes override a 4xx rejection", () =>
Effect.gen(function* () {
const classify = (body: string) =>
Effect.gen(function* () {
@@ -317,11 +317,11 @@ describe("RequestExecutor", () => {
const error = yield* executor.execute(request).pipe(Effect.flip)
expectAIError(error)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "InvalidRequest" })
}).pipe(Effect.provide(fixedResponse(body, { status: 400 })))
yield* classify('{"code":"resource_exhausted"}')
yield* classify('{"code":"service_unavailable"}')
yield* classify('{"error":{"type":"server_error","message":"Upstream request failed: Model is unavailable."}}')
}),
)
+47 -3
View File
@@ -249,10 +249,54 @@ describe("provider error classification", () => {
test("classifies any remaining 4xx status as an invalid request", () => {
expect(
[400, 402, 404, 418, 422, 451].map(
(status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag,
[400, 404, 418, 422, 451].map((status) => classifyProviderFailure({ message: `HTTP ${status}`, status })._tag),
).toEqual(Array(5).fill("InvalidRequest"))
})
test("classifies 402 as exhausted quota", () => {
expect(classifyProviderFailure({ message: "Payment Required", status: 402 })._tag).toBe("QuotaExceeded")
})
test("classifies OpenCode Zen account limits as quota rather than throttling", () => {
const typed = (type: string, message: string) => ({ type: "error", error: { type, message } })
const substituted = (message: string) => ({
error: { type: "server_error", message: `Upstream request failed: ${message}` },
})
const cases: ReadonlyArray<[number, { error: { message: string } }]> = [
[429, typed("GoUsageLimitError", "Go usage limit exceeded")],
[429, typed("FreeUsageLimitError", "Rate limit exceeded. Please try again later.")],
[402, typed("CreditLimitExceeded", "Credit limit exceeded.")],
[402, substituted("Insufficient account funds")],
[402, substituted("Account invoice is overdue")],
[429, substituted("Account budget exceeded")],
]
expect(
cases.map(
([status, body]) =>
classifyProviderFailure({ message: body.error.message, status, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(Array(6).fill("InvalidRequest"))
).toEqual(Array(6).fill("QuotaExceeded"))
})
test("does not let substituted server codes make a 4xx retryable", () => {
const openai = { error: { type: "server_error", message: "Upstream request failed: Model is unavailable." } }
const anthropic = {
type: "error",
error: { type: "api_error", message: "Upstream request failed: Model is unavailable." },
}
expect(
[openai, anthropic].map(
(body) =>
classifyProviderFailure({ message: body.error.message, status: 400, rawBody: JSON.stringify(body) })._tag,
),
).toEqual(["InvalidRequest", "InvalidRequest"])
// Without a contradicting status the same codes still mark provider trouble.
expect(classifyProviderFailure({ message: openai.error.message, rawBody: JSON.stringify(openai) })._tag).toBe(
"ProviderInternal",
)
expect(
classifyProviderFailure({ message: openai.error.message, status: 200, rawBody: JSON.stringify(openai) })._tag,
).toBe("ProviderInternal")
})
test("classifies nested provider codes when a top-level code is also present", () => {
@@ -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 }) => {
@@ -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")
+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,
@@ -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
+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 -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: "{}",
})
+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" },
])
})
-20
View File
@@ -97,26 +97,6 @@ input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return
`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.
### `Extension.make` and `Web.make`
An extension is a set of named host functions installed as globals. Arguments arrive as copies, results return as
copies, and a function inside a result is callable the same way. Extension calls are not tool calls and are not shown
in the tool catalog, so hosts describe them in their own instructions.
`Web.make` is the built-in outbound HTTP extension. Nothing is reachable unless a host lists it:
```ts
const runtime = CodeMode.make({
extensions: [Web.make({ allow: ["https://api.example.com"], methods: ["GET", "POST"], maxBodyBytes: 1_048_576 })],
})
// program: const res = await fetch("https://api.example.com/users"); return await res.json()
```
`fetch(url, init?)` accepts `method`, `headers`, and a `string`, `Uint8Array`, or `URLSearchParams` body, and
resolves to `{ url, status, statusText, ok, redirected, headers: { get, has, entries }, text(), json(), bytes() }`.
Every redirect hop is checked against `allow`, bodies larger than `maxBodyBytes` are refused, and `timeoutMs` bounds
the whole request. `Web.signature` is the model-facing signature for host instructions.
### OpenAPI tools
`OpenAPI.fromSpec` converts an OpenAPI 3.x document into one tool per supported operation. Dotted `operationId` values
-15
View File
@@ -474,21 +474,6 @@ Nothing is exposed unless a host provides it; extension calls are not tool calls
- [ ] 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.
### Web
`Web.make({ allow, methods, maxBodyBytes, timeoutMs })` is an extension exposing `fetch`. It is unavailable unless a
host provides it.
- [x] `fetch(url, init?)` with `method`, `headers` (record or pairs), and a `string`, `Uint8Array`, or
`URLSearchParams` body. The response is a plain object: `url`, `status`, `statusText`, `ok`, `redirected`,
`headers.get/has/entries`, and `text()`, `json()`, `bytes()` over the fully read body.
- [x] Policy is enforced before any request: origins outside `allow` (or `"*"`), methods outside `methods` (default
`GET`, `HEAD`), non-http(s) URLs, and unsupported `init` keys (`signal`, `credentials`, ...) throw a
`TypeError` naming the problem. Redirects are followed by hand with each hop checked against `allow`, at most
five hops; 303 and 301/302-after-POST switch to GET like browsers. Bodies over `maxBodyBytes` throw a
`RangeError`; `timeoutMs` bounds the whole request.
- [ ] `Headers`, `Response`, and `Request` as runtime types; streaming bodies; `AbortSignal`; `FormData`; `Blob`.
## Errors and diagnostics
- [x] `Error`, `TypeError`, `RangeError`, `SyntaxError`, `ReferenceError`, `EvalError`, and `URIError`, callable with
-1
View File
@@ -2,7 +2,6 @@ export * as CodeMode from "./codemode.js"
export * as Extension from "./extension.js"
export * as Namespace from "./namespace.js"
export * as Tool from "./tool.js"
export * as Web from "./web.js"
export * as OpenAPI from "./openapi/index.js"
export { searchSignature, toolExpression } from "./codemode.js"
export { ToolError, toolError } from "./tool-error.js"
+7 -1
View File
@@ -409,7 +409,13 @@ export const make = <R>(
keys: (path) => namespaceKeys(root, path),
search: (args) => Effect.suspend(() => executeTool("search", searchTool, 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", searchTool, args)
return executeTool(segments.join("."), resolve(root, path), args)
}),
}
}
-140
View File
@@ -1,140 +0,0 @@
export * as Web from "./web.js"
import { Extension } from "./extension.js"
/** Outbound HTTP for programs. Nothing is reachable unless a host lists it. */
export type Options = {
/** Origins the program may request, such as `"https://api.example.com"`; `"*"` allows every origin. */
readonly allow: ReadonlyArray<string>
/** Request methods the program may use. Default: `GET` and `HEAD`. */
readonly methods?: ReadonlyArray<string>
/** Largest response body accepted, in bytes. Default: 1 MiB. */
readonly maxBodyBytes?: number
/** Time allowed for a request, including redirects and reading the body. Default: 30 seconds. */
readonly timeoutMs?: number
}
const MAX_REDIRECTS = 5
/** The model-facing signature of the `fetch` global, for hosts to include in their instructions. */
export const signature = `fetch(url: string | URL, init?: { method?: string; headers?: Record<string, string> | Array<[string, string]>; body?: string | Uint8Array | URLSearchParams }): Promise<{ url: string; status: number; statusText: string; ok: boolean; redirected: boolean; headers: { get(name: string): string | null; has(name: string): boolean; entries(): Array<[string, string]> }; text(): Promise<string>; json(): Promise<unknown>; bytes(): Promise<Uint8Array> }>`
export const make = (options: Options): Extension => {
const methods = new Set((options.methods ?? ["GET", "HEAD"]).map((method) => method.toUpperCase()))
const maxBodyBytes = options.maxBodyBytes ?? 1024 * 1024
const timeoutMs = options.timeoutMs ?? 30_000
const checkUrl = (url: URL, what: string) => {
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new TypeError(`fetch: ${what} ${url.href} must use http or https.`)
}
if (options.allow.includes("*") || options.allow.includes(url.origin)) return
throw new TypeError(`fetch: ${what} ${url.href} is not an allowed origin. Allowed: ${options.allow.join(", ")}.`)
}
// Redirects are followed by hand so every hop is checked against the allow list before it is requested.
const request = async (
url: URL,
method: string,
headers: Headers,
body: string | Uint8Array<ArrayBuffer> | URLSearchParams | undefined,
signal: AbortSignal,
hop: number,
): Promise<Response> => {
const response = await globalThis
.fetch(url, { method, headers, body, signal, redirect: "manual" })
.catch((cause: unknown) => {
if (signal.aborted) throw new Error(`fetch: request to ${url.href} timed out after ${timeoutMs}ms.`)
throw new TypeError(`fetch: request to ${url.href} failed: ${cause instanceof Error ? cause.message : cause}`)
})
const location = response.headers.get("location")
if (response.status < 300 || response.status > 399 || location === null) return response
if (hop === MAX_REDIRECTS) throw new TypeError(`fetch: ${url.href} redirected more than ${MAX_REDIRECTS} times.`)
const next = new URL(location, url)
checkUrl(next, "redirect to")
// Like browsers: 303 always switches to GET, 301/302 do so for POST, 307/308 keep the method and body.
const toGet = response.status === 303 || ((response.status === 301 || response.status === 302) && method === "POST")
return request(next, toGet ? "GET" : method, headers, toGet ? undefined : body, signal, hop + 1)
}
const readBody = async (response: Response, url: URL): Promise<Uint8Array> => {
const tooLarge = () => new RangeError(`fetch: response from ${url.href} exceeds ${maxBodyBytes} bytes.`)
if (Number(response.headers.get("content-length")) > maxBodyBytes) throw tooLarge()
if (response.body === null) return new Uint8Array()
const chunks: Array<Uint8Array> = []
let total = 0
for await (const chunk of response.body) {
total += chunk.byteLength
if (total > maxBodyBytes) throw tooLarge()
chunks.push(chunk)
}
const bytes = new Uint8Array(total)
let offset = 0
for (const chunk of chunks) {
bytes.set(chunk, offset)
offset += chunk.byteLength
}
return bytes
}
const fetch = async (input: unknown, init: unknown = {}) => {
if (typeof input !== "string" && !(input instanceof URL)) {
throw new TypeError("fetch: the first argument must be a URL string or URL.")
}
const url = URL.parse(String(input))
if (url === null) throw new TypeError(`fetch: ${JSON.stringify(String(input))} is not a valid URL.`)
checkUrl(url, "request to")
if (init === null || typeof init !== "object" || Array.isArray(init)) {
throw new TypeError("fetch: init must be an object with method, headers, and body.")
}
for (const key of Object.keys(init)) {
if (key !== "method" && key !== "headers" && key !== "body") {
throw new TypeError(`fetch: init.${key} is not supported here; only method, headers, and body are.`)
}
}
const given = init as { method?: unknown; headers?: unknown; body?: unknown }
const method = given.method === undefined ? "GET" : String(given.method).toUpperCase()
if (!methods.has(method)) {
throw new TypeError(`fetch: method ${method} is not allowed. Allowed: ${[...methods].join(", ")}.`)
}
if (given.headers !== undefined && (given.headers === null || typeof given.headers !== "object")) {
throw new TypeError("fetch: init.headers must be a { name: value } object or an array of [name, value] pairs.")
}
const raw = given.body
if (
raw !== undefined &&
typeof raw !== "string" &&
!(raw instanceof Uint8Array) &&
!(raw instanceof URLSearchParams)
) {
throw new TypeError("fetch: init.body must be a string, Uint8Array, or URLSearchParams.")
}
const body = raw instanceof Uint8Array ? Uint8Array.from(raw) : raw
const headers = new Headers(given.headers as Record<string, string> | Array<[string, string]> | undefined)
const signal = AbortSignal.timeout(timeoutMs)
const response = await request(url, method, headers, body, signal, 0)
const bytes = await readBody(response, url).catch((cause: unknown) => {
if (signal.aborted) throw new Error(`fetch: request to ${url.href} timed out after ${timeoutMs}ms.`)
throw cause
})
const received = 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.url !== url.href,
headers: {
get: (name: string) => received[String(name).toLowerCase()] ?? null,
has: (name: string) => String(name).toLowerCase() in received,
entries: () => Object.entries(received),
},
text: async () => text(),
json: async () => JSON.parse(text()) as unknown,
bytes: async () => bytes,
}
}
return Extension.make({ name: "web", globals: { fetch } })
}
+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")
})
})
-221
View File
@@ -1,221 +0,0 @@
import { afterAll, describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode, Web } from "../src/index.js"
const seen: Array<{ method: string; path: string; headers: Record<string, string>; body: string }> = []
const other = Bun.serve({
port: 0,
fetch: (request) => {
seen.push({ method: request.method, path: new URL(request.url).pathname, headers: {}, body: "" })
return new Response("other")
},
})
const server = Bun.serve({
port: 0,
fetch: async (request) => {
const url = new URL(request.url)
seen.push({
method: request.method,
path: url.pathname,
headers: Object.fromEntries(request.headers),
body: await request.text(),
})
switch (url.pathname) {
case "/json":
return Response.json({ hello: "world" }, { headers: { "X-Custom": "yes" } })
case "/text":
return new Response("plain text", { status: 201, statusText: "Created" })
case "/bytes":
return new Response(new Uint8Array([1, 2, 3]))
case "/bad-json":
return new Response("{oops", { headers: { "content-type": "application/json" } })
case "/redirect":
return Response.redirect(`${url.origin}/json`, 302)
case "/redirect-post":
return Response.redirect(`${url.origin}/text`, 307)
case "/redirect-away":
return Response.redirect(`${other.url.origin}/leaked`, 302)
case "/redirect-loop":
return Response.redirect(`${url.origin}/redirect-loop`, 302)
case "/big":
return new Response("x".repeat(2048))
case "/big-chunked": {
const stream = new ReadableStream({
start(controller) {
for (let i = 0; i < 4; i++) controller.enqueue(new Uint8Array(1024))
controller.close()
},
})
return new Response(stream)
}
case "/slow":
await Bun.sleep(300)
return new Response("late")
default:
return new Response("not found", { status: 404 })
}
},
})
afterAll(() => {
server.stop(true)
other.stop(true)
})
const origin = server.url.origin
const runtime = CodeMode.make({ extensions: [Web.make({ allow: [origin], methods: ["GET", "POST"] })] })
const value = async (code: string, target = runtime) => {
const result = await Effect.runPromise(target.execute(code))
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const failure = async (code: string, target = runtime) => {
const result = await Effect.runPromise(target.execute(code))
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error.message
}
describe("fetch", () => {
test("GET json with status, headers, and body readers", async () => {
expect(
await value(`
const res = await fetch("${origin}/json")
return [res.status, res.ok, res.redirected, res.url, res.headers.get("X-Custom"), res.headers.has("content-type"), await res.json(), await res.text()]
`),
).toEqual([200, true, false, `${origin}/json`, "yes", true, { hello: "world" }, '{"hello":"world"}'])
})
test("status text, bytes, and header entries", async () => {
expect(
await value(`
const res = await fetch(new URL("${origin}/text"))
const bytes = await (await fetch("${origin}/bytes")).bytes()
return [res.status, res.statusText, await res.text(), [...bytes], bytes instanceof Uint8Array, res.headers.entries().some(([name]) => name === "content-type")]
`),
).toEqual([201, "Created", "plain text", [1, 2, 3], true, true])
})
test("POST with headers and each body kind", async () => {
seen.length = 0
await value(`
await fetch("${origin}/text", { method: "post", headers: { "X-A": "1" }, body: "hello" })
await fetch("${origin}/text", { method: "POST", headers: [["X-B", "2"]], body: new Uint8Array([104, 105]) })
await fetch("${origin}/text", { method: "POST", body: new URLSearchParams({ q: "x y" }) })
`)
expect(seen.map((request) => [request.method, request.body])).toEqual([
["POST", "hello"],
["POST", "hi"],
["POST", "q=x+y"],
])
expect(seen[0].headers["x-a"]).toBe("1")
expect(seen[1].headers["x-b"]).toBe("2")
expect(seen[2].headers["content-type"]).toContain("application/x-www-form-urlencoded")
})
test("a JSON parse failure is a catchable SyntaxError", async () => {
expect(
await value(`
try { await (await fetch("${origin}/bad-json")).json() } catch (e) { return [e instanceof SyntaxError, e.message.length > 0] }
`),
).toEqual([true, true])
})
test("a non-2xx response is returned, not thrown", async () => {
expect(await value(`const res = await fetch("${origin}/missing"); return [res.ok, res.status]`)).toEqual([
false,
404,
])
})
test("fetch is not a tool call", async () => {
const limited = CodeMode.make({ extensions: [Web.make({ allow: [origin] })], limits: { maxToolCalls: 0 } })
const result = await Effect.runPromise(limited.execute(`return (await fetch("${origin}/json")).status`))
expect(result.ok).toBe(true)
expect(result.toolCalls).toEqual([])
})
})
describe("policy", () => {
test("origins outside the allow list are refused before any request is made", async () => {
seen.length = 0
expect(await failure(`await fetch("${other.url.origin}/leaked")`)).toContain(
`request to ${other.url.origin}/leaked is not an allowed origin. Allowed: ${origin}.`,
)
expect(await failure(`await fetch("file:///etc/passwd")`)).toContain("must use http or https")
expect(await failure(`await fetch("not a url")`)).toContain('"not a url" is not a valid URL')
expect(await failure(`await fetch(42)`)).toContain("first argument must be a URL string or URL")
expect(seen).toEqual([])
})
test("* allows every origin", async () => {
const open = CodeMode.make({ extensions: [Web.make({ allow: ["*"] })] })
expect(await value(`return (await fetch("${other.url.origin}/")).status`, open)).toBe(200)
})
test("methods default to GET and HEAD", async () => {
const readOnly = CodeMode.make({ extensions: [Web.make({ allow: [origin] })] })
expect(await failure(`await fetch("${origin}/text", { method: "POST" })`, readOnly)).toContain(
"method POST is not allowed. Allowed: GET, HEAD.",
)
expect(await failure(`await fetch("${origin}/text", { method: "DELETE" })`)).toContain(
"method DELETE is not allowed. Allowed: GET, POST.",
)
})
test("init keys and shapes that mean nothing here are rejected by name", async () => {
expect(await failure(`await fetch("${origin}/json", { signal: 1 })`)).toContain(
"init.signal is not supported here; only method, headers, and body are.",
)
expect(await failure(`await fetch("${origin}/json", { credentials: "include" })`)).toContain("init.credentials")
expect(await failure(`await fetch("${origin}/json", "GET")`)).toContain("init must be an object")
expect(await failure(`await fetch("${origin}/json", { headers: "X: 1" })`)).toContain("init.headers must be")
expect(await failure(`await fetch("${origin}/json", { method: "POST", body: { a: 1 } })`)).toContain(
"init.body must be a string, Uint8Array, or URLSearchParams.",
)
})
test("redirects are followed within the allow list and reported", async () => {
expect(
await value(`const res = await fetch("${origin}/redirect"); return [res.redirected, res.url, await res.json()]`),
).toEqual([true, `${origin}/json`, { hello: "world" }])
seen.length = 0
expect(
await value(
`const res = await fetch("${origin}/redirect-post", { method: "POST", body: "keep" }); return res.status`,
),
).toBe(201)
expect(seen.map((request) => [request.path, request.method, request.body])).toEqual([
["/redirect-post", "POST", "keep"],
["/text", "POST", "keep"],
])
})
test("a redirect to a disallowed origin is refused and never requested", async () => {
seen.length = 0
expect(await failure(`await fetch("${origin}/redirect-away")`)).toContain(
`redirect to ${other.url.origin}/leaked is not an allowed origin`,
)
expect(seen.map((request) => request.path)).toEqual(["/redirect-away"])
})
test("redirect loops stop", async () => {
expect(await failure(`await fetch("${origin}/redirect-loop")`)).toContain("redirected more than 5 times")
})
test("oversized bodies are refused, declared or streamed", async () => {
const small = CodeMode.make({ extensions: [Web.make({ allow: [origin], maxBodyBytes: 1024 })] })
expect(await failure(`await fetch("${origin}/big")`, small)).toContain("exceeds 1024 bytes")
expect(await failure(`await fetch("${origin}/big-chunked")`, small)).toContain("exceeds 1024 bytes")
expect(await value(`return (await fetch("${origin}/json")).status`, small)).toBe(200)
})
test("slow requests time out", async () => {
const quick = CodeMode.make({ extensions: [Web.make({ allow: [origin], timeoutMs: 50 })] })
expect(await failure(`await fetch("${origin}/slow")`, quick)).toContain("timed out after 50ms")
})
})
test("the signature names the global and its shape", () => {
expect(Web.signature).toStartWith("fetch(url: string | URL, init?:")
})
+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),
+1 -1
View File
@@ -51,7 +51,7 @@ export const Plugin = {
const sessionID = input.sessionID ?? context.sessionID
const title = input.title.trim()
if (!title) return Effect.fail(new ToolFailure({ message: "Session title must not be empty" }))
return ctx.session.rename({ sessionID, title }).pipe(
return ctx.session.update({ sessionID, title }).pipe(
Effect.as({
output: { sessionID, title },
content: `Renamed session ${sessionID} to ${title}.`,
+4 -1
View File
@@ -123,7 +123,10 @@ const layer = Layer.effect(
const changed = yield* Effect.gen(function* () {
const provider = selected()
const next: Info = provider
? yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })
? {
...(yield* protect(provider, "info", provider.info(scope).pipe(Effect.flatMap(decodeInfo)), { branch: {} })),
provider: provider.id,
}
: { branch: {} }
const changed = current.info.branch.current !== next.branch.current
current.info = next
+2 -2
View File
@@ -804,12 +804,12 @@ it.effect("classifies retryable AI SDK failures with retry-after details", () =>
it.effect("classifies data-only AI SDK provider codes", () =>
Effect.gen(function* () {
const data = {
error: { code: "api_error", metadata: { requestId: "data-request", retryable: true } },
error: { code: "rate_limit_error", metadata: { requestId: "data-request", retryable: true } },
trace: { region: "test-region" },
}
const cause = apiCallError({ statusCode: 400, data })
const error = yield* streamFailure(cause)
expect(error.reason).toMatchObject({ _tag: "ProviderInternal" })
expect(error.reason).toMatchObject({ _tag: "RateLimit" })
expect(error.reason.http?.status).toBe(400)
expect(SessionRunnerRetry.isRetryable(error)).toBeTrue()
expect(error.reason.body).toBe(JSON.stringify(data))
+160 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect } from "bun:test"
import { LanguageModel } from "@opencode/ai"
import { OpenAIChat } from "@opencode/ai/protocols"
import { Effect, Fiber, Layer, Stream } from "effect"
import { Effect, Fiber, Layer, Ref, Stream } from "effect"
import { Integration } from "@opencode/core/integration"
import { Credential } from "@opencode/core/credential"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
@@ -148,6 +148,156 @@ describe("Provider and Model", () => {
}).pipe(Effect.scoped, Effect.provide(localProviderLayer))
})
it.effect("reuses the model catalog across credential switches", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const bus = yield* Bus.Service
const providerID = Provider.ID.make("switchable")
const integrationID = Integration.ID.make(providerID)
yield* integrations.transform((editor) => editor.update(integrationID, () => {}))
yield* providers.transform((editor) =>
editor.add({
info: Provider.Info.empty(providerID),
models: [Model.Info.default(providerID, Model.ID.make("chat"))],
}),
)
expect(yield* models.available()).toEqual([])
const log = yield* Ref.make<string[]>([])
yield* bus.subscribe().pipe(
Stream.runForEach((event) => Ref.update(log, (types) => [...types, event.type])),
Effect.forkScoped({ startImmediately: true }),
)
yield* Effect.yieldNow
const updates = Ref.get(log).pipe(
Effect.map((types) => types.filter((type) => type === Model.Event.Updated.type).length),
)
const first = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "first" }),
})
const materialized = yield* models.available()
expect(materialized).toHaveLength(1)
// Credential events reach Model on other fibers; let the connect land before switching.
yield* settle(updates.pipe(Effect.map((count) => count >= 1)))
const second = yield* credentials.create({
integrationID,
value: Credential.Key.make({ type: "key", key: "second" }),
})
expect(yield* models.available()).toBe(materialized)
yield* credentials.activate(first.id)
expect(yield* models.available()).toBe(materialized)
yield* credentials.remove(first.id)
expect(yield* models.available()).toBe(materialized)
// Disconnecting is a real change whose model.updated follows every earlier one in the log,
// so once it has arrived the total shows whether any switch above published as well.
yield* credentials.remove(second.id)
expect(yield* models.available()).toEqual([])
yield* settle(
Ref.get(log).pipe(
Effect.map(
(types) =>
types.lastIndexOf(Model.Event.Updated.type) > types.lastIndexOf(Credential.Event.Updated.type),
),
),
)
expect(yield* updates).toBe(2)
}),
)
it.effect("persists direct edits to models returned by list and get", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const providerID = Provider.ID.make("direct")
const listed = Model.ID.make("listed")
const fetched = Model.ID.make("fetched")
const definitions = [Model.Info.default(providerID, listed), Model.Info.default(providerID, fetched)]
yield* providers.transform((editor) =>
editor.add({ info: { ...Provider.Info.empty(providerID), activation: "enabled" }, models: definitions }),
)
yield* models.transform((editor) => {
editor.list(providerID).forEach((model) => {
model.limit.context = 4096
})
required(editor.get(providerID, fetched)).capabilities.input.push("pdf")
})
expect(yield* models.get(providerID, listed)).toMatchObject({
limit: { context: 4096 },
capabilities: { input: ["text", "image"] },
})
expect(yield* models.get(providerID, fetched)).toMatchObject({
limit: { context: 4096 },
capabilities: { input: ["text", "image", "pdf"] },
})
expect(definitions.map((model) => model.limit.context)).toEqual([200_000, 200_000])
}),
)
it.effect("gives foreign definitions the registering provider's identity", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const source = Provider.ID.make("source")
const mirror = Provider.ID.make("mirror")
const modelID = Model.ID.make("chat")
const definitions = [Model.Info.default(source, modelID)]
yield* providers.transform((editor) => {
editor.add({ info: { ...Provider.Info.empty(source), activation: "enabled" }, models: definitions })
editor.add({ info: { ...Provider.Info.empty(mirror), activation: "enabled" }, models: definitions })
})
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([mirror, source])
expect(yield* models.get(mirror, modelID)).toMatchObject({ id: modelID, providerID: mirror })
expect((yield* providers.snapshot()).records.get(source)?.models.get(modelID)).toBe(definitions[0])
}),
)
it.effect("keeps materialized models when another provider becomes available", () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
const existing = Provider.ID.make("existing")
const added = Provider.ID.make("added")
const edited = Model.ID.make("edited")
const untouched = Model.ID.make("untouched")
yield* integrations.transform((editor) => editor.update(Integration.ID.make(added), () => {}))
yield* providers.transform((editor) => {
editor.add({
info: { ...Provider.Info.empty(existing), activation: "enabled" },
models: [Model.Info.default(existing, edited), Model.Info.default(existing, untouched)],
})
editor.add({ info: Provider.Info.empty(added), models: [Model.Info.default(added, Model.ID.make("chat"))] })
})
yield* models.transform((editor) =>
editor.update(existing, edited, (model) => {
model.limit.context = 1
}),
)
const before = required(yield* models.get(existing, untouched))
yield* credentials.create({
integrationID: Integration.ID.make(added),
value: Credential.Key.make({ type: "key", key: "secret" }),
})
expect((yield* models.available()).map((model) => model.providerID).toSorted()).toEqual([
added,
existing,
existing,
])
expect(yield* models.get(existing, untouched)).toBe(before)
expect(yield* models.get(existing, edited)).toMatchObject({ limit: { context: 1 } })
}),
)
it.effect("derives availability from a provider's integration", () => {
const integrationID = Integration.ID.make("gateway")
const providerID = Provider.ID.make("remote")
@@ -511,3 +661,12 @@ describe("Provider and Model", () => {
}),
)
})
// Bus subscribers run on their own fibers, so give them turns until the condition holds.
const settle = Effect.fnUntraced(function* (condition: Effect.Effect<boolean>) {
for (let attempt = 0; attempt < 100; attempt++) {
if (yield* condition) return
yield* Effect.yieldNow
}
return yield* Effect.die("Timed out waiting for catalog events")
})
+3 -1
View File
@@ -329,7 +329,9 @@ describe("Permission", () => {
).toMatchObject([{ action: "read", resource: "src/*" }])
const saved = yield* PermissionSaved.Service
const id = (yield* saved.list())[0]!.id
expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
expect(yield* saved.list()).toMatchObject([
{ id, projectID: Project.ID.global, action: "read", resource: "src/*" },
])
yield* service.assert(assertion({ id: Permission.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* saved.remove(id)
expect(yield* saved.list()).toEqual([])
+4 -3
View File
@@ -107,7 +107,6 @@ export function host(overrides: Overrides = {}): Plugin.Context {
list: () => Effect.die("unused permission.list"),
get: () => Effect.die("unused permission.get"),
reply: () => Effect.die("unused permission.reply"),
rules: () => Effect.die("unused permission.rules"),
},
plugin: overrides.plugin ?? {
list: () => Effect.die("unused plugin.list"),
@@ -139,7 +138,9 @@ export function host(overrides: Overrides = {}): Plugin.Context {
vcs: overrides.vcs ?? {
base: () => Effect.die("unused vcs.base"),
get: () => Effect.die("unused vcs.get"),
branches: () => Effect.die("unused vcs.branches"),
branch: {
list: () => Effect.die("unused vcs.branch.list"),
},
status: () => Effect.die("unused vcs.status"),
diff: () => Effect.die("unused vcs.diff"),
transform: () => Effect.die("unused vcs.transform"),
@@ -168,7 +169,7 @@ export function host(overrides: Overrides = {}): Plugin.Context {
prompt: overrides.session?.prompt ?? (() => Effect.die("unused session.prompt")),
generate: overrides.session?.generate ?? (() => Effect.die("unused session.generate")),
command: overrides.session?.command ?? (() => Effect.die("unused session.command")),
rename: overrides.session?.rename ?? (() => Effect.die("unused session.rename")),
update: overrides.session?.update ?? (() => Effect.die("unused session.update")),
move: overrides.session?.move ?? (() => Effect.die("unused session.move")),
synthetic: overrides.session?.synthetic ?? (() => Effect.die("unused session.synthetic")),
interrupt: overrides.session?.interrupt ?? (() => Effect.die("unused session.interrupt")),
+3 -3
View File
@@ -151,13 +151,13 @@ describeHg("Vcs mercurial", () => {
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
.pipe(Stream.take(1), Stream.runHead, Effect.forkScoped({ startImmediately: true }))
yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "default", default: "default" } })
yield* bus.publish(FileSystem.Event.Changed, {
file: path.join(directory, ".hg", "branch"),
@@ -167,7 +167,7 @@ describeHg("Vcs mercurial", () => {
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
expect(yield* vcs.info()).toEqual({ provider: "hg", branch: { current: "feature", default: "default" } })
}),
),
)
+10 -10
View File
@@ -126,7 +126,7 @@ describe("Vcs", () => {
editor.default.set("custom")
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "feature", default: "main" } })
expect(yield* vcs.base()).toBeNull()
expect(yield* vcs.branches()).toEqual(["feature", "main"])
expect(yield* vcs.status()).toEqual([{ file: "file.txt", additions: 1, deletions: 0, status: "added" }])
@@ -146,10 +146,10 @@ describe("Vcs", () => {
Effect.gen(function* () {
const vcs = yield* Vcs.Service
const registration = yield* vcs.transform((editor) => editor.add(provider({ id: "git" })))
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
yield* registration.dispose
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
}),
),
)
@@ -188,7 +188,7 @@ describe("Vcs", () => {
}),
)
expect(reads).toEqual(["final"])
expect(yield* vcs.info()).toEqual({ branch: { current: "final" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "final" } })
}),
)
@@ -456,7 +456,7 @@ describe("Vcs", () => {
),
)
expect((yield* vcs.status())[0]?.file).toBe("config.txt")
expect(yield* vcs.info()).toEqual({ branch: { current: "initial" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "initial" } })
yield* Deferred.succeed(accepted, undefined)
}),
).pipe(Effect.forkScoped({ startImmediately: true }))
@@ -466,7 +466,7 @@ describe("Vcs", () => {
yield* Deferred.succeed(release, undefined)
yield* Fiber.join(configured)
expect(Option.getOrUndefined(yield* Fiber.join(updates))?.data.branch).toBe("config")
expect(yield* vcs.info()).toEqual({ branch: { current: "config" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "config" } })
expect(reads).toEqual(["initial", "filesystem", "config"])
}).pipe(Effect.ensuring(Deferred.succeed(release, undefined)))
}),
@@ -502,7 +502,7 @@ describe("Vcs", () => {
})
yield* bus.publish(Done, {})
const events = (yield* Fiber.join(updates)).filter((event) => event.type === VcsEvent.BranchUpdated.type)
expect(yield* vcs.info()).toEqual({ branch: { current: "listener" } })
expect(yield* vcs.info()).toEqual({ provider: "custom", branch: { current: "listener" } })
expect(events.length).toBeGreaterThanOrEqual(2)
expect(events.at(-1)?.data.branch).toBe((yield* vcs.info()).branch.current)
}).pipe(Effect.ensuring(unsubscribe))
@@ -567,7 +567,7 @@ describe("Vcs", () => {
})
const vcs = yield* Vcs.Service
const bus = yield* Bus.Service
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
const updated = yield* bus
.subscribe(VcsEvent.BranchUpdated)
@@ -575,14 +575,14 @@ describe("Vcs", () => {
yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: undefined } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "main", default: undefined } })
yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
expect(yield* Fiber.join(updated)).toMatchObject({
_tag: "Some",
value: { location: { directory }, data: { branch: "feature" } },
})
expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(yield* vcs.info()).toEqual({ provider: "git", branch: { current: "feature", default: "main" } })
}),
),
)
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply" | "rules"> & {
export type PermissionDomain = Pick<PermissionApi<unknown>, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
+1 -1
View File
@@ -134,7 +134,7 @@ export type SessionDomain = Pick<
| "command"
| "synthetic"
| "interrupt"
| "rename"
| "update"
| "move"
| "wait"
| "context"
+4 -3
View File
@@ -439,7 +439,6 @@ export function fromPromise(plugin: Plugin) {
list: adaptApiMethod(PermissionEndpoints["session.permission.list"], host.permission.list),
get: adaptApiMethod(PermissionEndpoints["session.permission.get"], host.permission.get),
reply: adaptApiMethod(PermissionEndpoints["session.permission.reply"], host.permission.reply),
rules: adaptApiMethod(PermissionEndpoints["session.permission.rules"], host.permission.rules),
},
plugin: {
list: adaptApiMethod(PluginEndpoints["plugin.list"], host.plugin.list),
@@ -502,7 +501,9 @@ export function fromPromise(plugin: Plugin) {
vcs: {
get: adaptApiMethod(VcsEndpoints["vcs.get"], host.vcs.get),
base: adaptApiMethod(VcsEndpoints["vcs.base"], host.vcs.base),
branches: adaptApiMethod(VcsEndpoints["vcs.branches"], host.vcs.branches),
branch: {
list: adaptApiMethod(VcsEndpoints["vcs.branch.list"], host.vcs.branch.list),
},
status: adaptApiMethod(VcsEndpoints["vcs.status"], host.vcs.status),
diff: adaptApiMethod(VcsEndpoints["vcs.diff"], host.vcs.diff),
reload: () => run(host.vcs.reload()),
@@ -581,7 +582,7 @@ export function fromPromise(plugin: Plugin) {
command: adaptApiMethod(SessionEndpoints["session.command"], host.session.command),
synthetic: adaptApiMethod(SessionEndpoints["session.synthetic"], host.session.synthetic),
interrupt: adaptApiMethod(SessionEndpoints["session.interrupt"], host.session.interrupt),
rename: adaptApiMethod(SessionEndpoints["session.rename"], host.session.rename),
update: adaptApiMethod(SessionEndpoints["session.update"], host.session.update),
move: adaptApiMethod(SessionEndpoints["session.move"], host.session.move),
wait: adaptApiMethod(SessionEndpoints["session.wait"], host.session.wait),
context: adaptApiMethod(SessionEndpoints["session.context"], host.session.context),
+1 -1
View File
@@ -19,6 +19,6 @@ export interface PermissionHooks {
readonly evaluate: PermissionEvaluation
}
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply" | "rules"> & {
export type PermissionDomain = Pick<PermissionApi, "list" | "get" | "reply"> & {
readonly hook: Hooks<PermissionHooks>
}
+1 -1
View File
@@ -134,7 +134,7 @@ export type SessionDomain = Pick<
| "command"
| "synthetic"
| "interrupt"
| "rename"
| "update"
| "move"
| "wait"
| "context"
+10 -1
View File
@@ -4,6 +4,7 @@ import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { Host } from "./host.js"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"
let generation = Date.now()
@@ -37,7 +38,15 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
item.path.startsWith("./") || item.path.startsWith("../")
? new URL(item.path, pathToFileURL(file))
: localSource(item.path, path.dirname(file))
if (!local) continue
if (!local) {
try {
Bun.resolveSync(item.path, path.dirname(file))
} catch {
const target = missingPackageTarget(item.path, file)
if (target) track(target, true)
}
continue
}
const requested = fileURLToPath(local)
// Resolving a workspace symlink can erase its node_modules boundary.
if (requested.split(path.sep).includes("node_modules")) continue
+10 -1
View File
@@ -2,6 +2,7 @@ import { registerHooks } from "node:module"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { localSource } from "./source.js"
import { missingPackageTarget } from "./source.package.js"
import { Host } from "./host.js"
let generation = Date.now()
@@ -21,7 +22,15 @@ export async function prepareSource(entrypoint: string, track: (file: string, di
specifier.startsWith("./") || specifier.startsWith("../")
? new URL(specifier, context.parentURL)
: localSource(specifier, path.dirname(fileURLToPath(context.parentURL)))
if (!local) return nextResolve(specifier, context)
if (!local) {
try {
return nextResolve(specifier, context)
} catch (error) {
const target = missingPackageTarget(specifier, fileURLToPath(context.parentURL))
if (target) track(target, true)
throw error
}
}
if (fileURLToPath(local).split(path.sep).includes("node_modules")) return nextResolve(specifier, context)
const resolved = (() => {
try {
+13
View File
@@ -0,0 +1,13 @@
import { existsSync } from "node:fs"
import path from "node:path"
export function missingPackageTarget(specifier: string, importer: string) {
if (specifier.startsWith("#")) return undefined
const parts = specifier.split("/")
const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]
const start = path.dirname(importer)
for (let directory = start; ; directory = path.dirname(directory)) {
if (existsSync(path.join(directory, "package.json"))) return path.join(directory, "node_modules", name)
if (path.dirname(directory) === directory) return path.join(start, "node_modules", name)
}
}
+4 -4
View File
@@ -1,9 +1,9 @@
import type {
AgentInfo,
CommandInfo,
FormCancelInput,
SessionFormCancelInput,
FormInfo,
FormReplyInput,
SessionFormReplyInput,
IntegrationInfo,
LocationRef,
McpResource,
@@ -95,8 +95,8 @@ export interface Data {
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
sync(sessionID: string, location?: LocationRef): Promise<void>
invalidate(sessionID: string, location?: LocationRef): void
reply(input: FormReplyInput, location?: LocationRef): Promise<void>
cancel(input: FormCancelInput, location?: LocationRef): Promise<void>
reply(input: SessionFormReplyInput, location?: LocationRef): Promise<void>
cancel(input: SessionFormCancelInput, location?: LocationRef): Promise<void>
}
}
readonly project: {
File diff suppressed because it is too large Load Diff
+16 -12
View File
@@ -54,16 +54,20 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
| typeof MessageGroup
type FormGroups<
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
type SessionGroups<
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
FormLocationId extends HttpApiMiddleware.AnyId,
FormLocationService,
> = ReturnType<typeof makeFormGroup<LocationId, LocationService, FormLocationId, FormLocationService>>
> =
| ReturnType<
typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
>
| typeof MessageGroup
type FormGroups<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeFormGroup<LocationId, LocationService>
>
type MixedMiddlewareGroups<
LocationId extends HttpApiMiddleware.AnyId,
@@ -89,8 +93,8 @@ type ApiGroups<
| typeof PersistentPtyGroup
| typeof CredentialGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
| FormGroups<LocationId, LocationService>
| SessionGroups<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
| Event
@@ -151,7 +155,7 @@ const makeApiFromGroup = <
.add(LocationGroup.middleware(locationMiddleware))
.add(AgentGroup.middleware(locationMiddleware))
.add(PluginGroup.middleware(locationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware, formLocationMiddleware))
.add(MessageGroup)
.add(ModelGroup.middleware(locationMiddleware))
.add(GenerateGroup)
@@ -160,7 +164,7 @@ const makeApiFromGroup = <
.add(McpGroup.middleware(locationMiddleware))
.add(CredentialGroup)
.add(ProjectGroup.middleware(locationMiddleware))
.add(makeFormGroup(locationMiddleware, formLocationMiddleware))
.add(makeFormGroup(locationMiddleware))
.add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
.add(FileSystemGroup.middleware(locationMiddleware))
.add(CommandGroup.middleware(locationMiddleware))
+6 -123
View File
@@ -1,143 +1,26 @@
import { Form } from "@opencode/schema/form"
import { Location } from "@opencode/schema/location"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidRequestError,
SessionNotFoundError,
} from "../errors.js"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const CreatePayload = Schema.Struct({
id: Form.ID.pipe(Schema.optional),
title: Form.Info.fields.title,
metadata: Form.Info.fields.metadata,
fields: Form.Info.fields.fields,
}).annotate({ identifier: "Form.CreatePayload" })
export type CreatePayload = typeof CreatePayload.Type
// Form routes intentionally look session-scoped, but use a form-specific middleware instead of
// SessionLocationMiddleware. The middleware treats real session IDs normally and has an
// undocumented `global` sentinel branch for MCP elicitation forms that are still Location-scoped
// but not session-owned. This is temporary and should disappear once elicitations are attributable.
export const makeFormGroup = <
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
FormLocationId extends HttpApiMiddleware.AnyId,
FormLocationService,
>(
export const makeFormGroup = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
locationMiddleware: Context.Key<LocationId, LocationService>,
formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>,
) =>
HttpApiGroup.make("server.form")
.add(
HttpApiEndpoint.get("form.request.list", "/api/form/request", {
HttpApiEndpoint.get("form.list", "/api/form", {
query: LocationQuery,
success: Location.response(Schema.Array(Form.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "form.request.list",
summary: "List pending form requests",
identifier: "form.list",
summary: "List pending forms",
description: "Retrieve pending forms for a location.",
}),
),
)
.middleware(locationMiddleware)
.add(
HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
payload: CreatePayload,
success: Schema.Struct({ data: Form.Info }),
error: [SessionNotFoundError, ConflictError, InvalidRequestError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.create",
summary: "Create session form",
description: "Create a form for a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", {
params: { sessionID: Schema.String, formID: Form.ID },
success: Schema.Struct({ data: Form.Info }),
error: [SessionNotFoundError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.get",
summary: "Get session form",
description: "Retrieve a form for a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.form.state", "/api/session/:sessionID/form/:formID/state", {
params: { sessionID: Schema.String, formID: Form.ID },
success: Schema.Struct({ data: Form.State }),
error: [SessionNotFoundError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.state",
summary: "Get form state",
description: "Retrieve the current state for a form.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.reply", "/api/session/:sessionID/form/:formID/reply", {
params: { sessionID: Schema.String, formID: Form.ID },
payload: Form.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.reply",
summary: "Reply to form",
description: "Submit an answer to a pending form.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.cancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { sessionID: Schema.String, formID: Form.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.cancel",
summary: "Cancel form",
description: "Cancel a pending form.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "form", description: "Session form routes." }))
.annotateMerge(OpenApi.annotations({ title: "form", description: "Location form routes." }))
+2 -2
View File
@@ -5,7 +5,7 @@ import { InvalidRequestError, ServiceUnavailableError } from "../errors.js"
export const GenerateGroup = HttpApiGroup.make("server.generate")
.add(
HttpApiEndpoint.post("generate.text", "/api/generate", {
HttpApiEndpoint.post("generate.text", "/api/experimental/generate", {
payload: Schema.Struct({
prompt: Schema.String,
model: Model.Ref.pipe(Schema.optional),
@@ -16,7 +16,7 @@ export const GenerateGroup = HttpApiGroup.make("server.generate")
error: [InvalidRequestError, ServiceUnavailableError],
}).annotateMerge(
OpenApi.annotations({
identifier: "generate.text",
identifier: "experimental.generate.text",
summary: "Generate text",
description:
"Run one stateless model generation using the server's base configuration and return the assistant text. Uses the base configuration's default model when none is specified.",
+1 -18
View File
@@ -117,7 +117,7 @@ export const makePermissionGroup = <
HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
params: { sessionID: Session.ID, requestID: Permission.ID },
payload: Schema.Struct({
reply: Permission.Reply,
decision: Permission.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
@@ -132,21 +132,4 @@ export const makePermissionGroup = <
}),
),
)
.add(
HttpApiEndpoint.put("session.permission.rules", "/api/session/:sessionID/permission/rules", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ permissions: Permission.Ruleset }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.permission.rules",
summary: "Replace session permission rules",
description:
"Replace the session-scoped permission rules. Rules are evaluated after the agent's rules, and the last matching rule wins.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "permission", description: "Experimental permission routes." }))
+111 -27
View File
@@ -20,6 +20,9 @@ import {
ConflictError,
CommandExecutionError,
CommandNotFoundError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidCursorError,
InvalidRequestError,
MessageNotFoundError,
@@ -37,6 +40,7 @@ import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
import { Form } from "@opencode/schema/form"
import { PublicSessionMessage } from "./message.js"
const ParentIDFilter = Schema.Union([
@@ -140,6 +144,13 @@ const PublicInboxInfo = Schema.Union([
PublicMove,
]).annotate({ identifier: "Session.Inbox.Info" })
const FormCreatePayload = Schema.Struct({
id: Form.ID.pipe(Schema.optional),
title: Form.Info.fields.title,
metadata: Form.Info.fields.metadata,
fields: Form.Info.fields.fields,
}).annotate({ identifier: "Form.CreatePayload" })
const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
@@ -159,7 +170,12 @@ export const SessionsQuery = Schema.Struct({
cursor: SessionsQueryCursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
export const makeSessionGroup = <
I extends HttpApiMiddleware.AnyId,
S,
FormI extends HttpApiMiddleware.AnyId,
FormS,
>(sessionLocationMiddleware: Context.Key<I, S>, formLocationMiddleware: Context.Key<FormI, FormS>) =>
HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
@@ -339,18 +355,21 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.patch("session.rename", "/api/session/:sessionID", {
HttpApiEndpoint.patch("session.update", "/api/session/:sessionID", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ title: Schema.String }),
payload: Schema.Struct({
title: Schema.String.pipe(Schema.optional),
permissions: Permission.Ruleset.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
})
.middleware(sessionLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.rename",
summary: "Rename session",
description: "Update the session title.",
identifier: "session.update",
summary: "Update session",
description: "Update mutable session properties.",
}),
),
)
@@ -599,38 +618,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.delete("session.inbox.cancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.inbox.cancel",
summary: "Cancel inbox input",
description: "Cancel an inbox item that has not yet been delivered.",
description: "Cancel an inbox item that has not yet been delivered. Unavailable items are a no-op.",
}),
),
)
.add(
HttpApiEndpoint.post("session.inbox.steer", "/api/session/:sessionID/inbox/:inboxID/steer", {
HttpApiEndpoint.patch("session.inbox.update", "/api/session/:sessionID/inbox/:inboxID", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
payload: Schema.Struct({ delivery: SessionInbox.Delivery }),
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.inbox.steer",
summary: "Steer queued item",
description: "Change a queued inbox item to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.inbox.queue", "/api/session/:sessionID/inbox/:inboxID/queue", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.inbox.queue",
summary: "Queue steered item",
description: "Change a steered inbox item to queued delivery.",
identifier: "session.inbox.update",
summary: "Update inbox item",
description: "Change a pending inbox item's delivery mode. Steering wakes session execution.",
}),
),
)
@@ -783,14 +790,91 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.put("session.environment", "/api/experimental/session/:sessionID/environment", {
HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
payload: FormCreatePayload,
success: Schema.Struct({ data: Form.Info }),
error: [SessionNotFoundError, ConflictError, InvalidRequestError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.create",
summary: "Create session form",
description: "Create a form for a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", {
params: { sessionID: Schema.String, formID: Form.ID },
success: Schema.Struct({ data: Form.Detail }),
error: [SessionNotFoundError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.get",
summary: "Get session form",
description: "Retrieve a form and its current state for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.reply", "/api/session/:sessionID/form/:formID/reply", {
params: { sessionID: Schema.String, formID: Form.ID },
payload: Form.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.reply",
summary: "Reply to form",
description: "Submit an answer to a pending form.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.form.cancel", "/api/session/:sessionID/form/:formID", {
params: { sessionID: Schema.String, formID: Form.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.cancel",
summary: "Cancel form",
description: "Cancel a pending form.",
}),
),
)
.add(
HttpApiEndpoint.put("session.environment", "/api/session/:sessionID/environment", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ variables: Schema.Record(Schema.String, Schema.String) }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.session.environment",
identifier: "session.environment",
summary: "Set session environment",
description: "Replace the process environment used by local shell commands for this session.",
}),
+1 -24
View File
@@ -1,15 +1,10 @@
import { Shell } from "@opencode/schema/shell"
import { Location } from "@opencode/schema/location"
import { NonNegativeInt } from "@opencode/schema/schema"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { ShellNotFoundError } from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const TimeoutInput = Schema.Struct({
timeout: NonNegativeInt,
})
export const ShellGroup = HttpApiGroup.make("server.shell")
.add(
HttpApiEndpoint.get("shell.list", "/api/shell", {
@@ -57,23 +52,6 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
}),
),
)
.add(
HttpApiEndpoint.patch("shell.timeout", "/api/shell/:id/timeout", {
params: { id: Shell.ID },
query: LocationQuery,
payload: TimeoutInput,
success: Location.response(Shell.Info),
error: ShellNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "shell.timeout",
summary: "Update shell timeout",
description: "Replace a running shell command's timeout from now, or clear it with zero.",
}),
),
)
.add(
HttpApiEndpoint.get("shell.output", "/api/shell/:id/output", {
params: { id: Shell.ID },
@@ -95,7 +73,6 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
params: { id: Shell.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
error: ShellNotFoundError,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -107,5 +84,5 @@ export const ShellGroup = HttpApiGroup.make("server.shell")
),
)
.annotateMerge(
OpenApi.annotations({ title: "shell", description: "Experimental location-scoped shell command routes." }),
OpenApi.annotations({ title: "shell", description: "Location-scoped shell command routes." }),
)
+2 -2
View File
@@ -66,14 +66,14 @@ export const VcsGroup = HttpApiGroup.make("server.vcs")
),
)
.add(
HttpApiEndpoint.get("vcs.branches", "/api/vcs/branches", {
HttpApiEndpoint.get("vcs.branch.list", "/api/vcs/branch", {
query: BranchesQuery,
success: Location.response(Vcs.BranchList),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "vcs.branches",
identifier: "vcs.branch.list",
summary: "VCS branches",
description: "List local and remote branches available at the requested location.",
}),
+6
View File
@@ -152,6 +152,12 @@ export const State = Schema.Union([
.annotate({ identifier: "Form.State" })
export type State = typeof State.Type
export const Detail = Schema.Struct({
...Info.fields,
state: State,
}).annotate({ identifier: "Form.Detail" })
export interface Detail extends Schema.Schema.Type<typeof Detail> {}
export const Reply = Schema.Struct({
answer: Answer,
}).annotate({ identifier: "Form.Reply" })
+5 -1
View File
@@ -3,7 +3,7 @@ export * as PermissionSaved from "./permission-saved.js"
import { Schema } from "effect"
import { ascending } from "./identifier.js"
import { ProjectID } from "./project-id.js"
import { statics } from "./schema.js"
import { DateTimeUtcFromMillis, statics } from "./schema.js"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
@@ -16,5 +16,9 @@ export const Info = Schema.Struct({
projectID: ProjectID,
action: Schema.String,
resource: Schema.String,
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
}),
}).annotate({ identifier: "PermissionSaved.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+4 -4
View File
@@ -111,15 +111,15 @@ export const Renamed = Event.durable({
})
export type Renamed = typeof Renamed.Type
export const PermissionsUpdated = Event.durable({
type: "session.permissions.updated",
export const Permissions = Event.durable({
type: "session.permissions",
...options,
schema: {
...Base,
permissions: Permission.Ruleset,
},
})
export type PermissionsUpdated = typeof PermissionsUpdated.Type
export type Permissions = typeof Permissions.Type
export const Viewed = Event.durable({
type: "session.viewed",
@@ -646,7 +646,7 @@ export const Definitions = Event.inventory(
ModelSelected,
Moved,
Renamed,
PermissionsUpdated,
Permissions,
Viewed,
UsageUpdated,
Deleted,
+5 -3
View File
@@ -23,8 +23,10 @@ export const Status = Schema.Literals(["running", "exited", "timeout", "killed"]
export type Status = typeof Status.Type
export const Time = Schema.Struct({
started: Schema.Finite,
completed: optional(Schema.Finite),
started: Schema.Finite.annotate({ description: "Start time in milliseconds since the Unix epoch" }),
completed: optional(Schema.Finite).annotate({
description: "Completion time in milliseconds since the Unix epoch",
}),
})
export interface Time extends Schema.Schema.Type<typeof Time> {}
@@ -57,7 +59,7 @@ export const Event = { Created, Exited, Deleted, Definitions: inventory(Created,
export const CreateInput = Schema.Struct({
command: Schema.String,
cwd: optional(Schema.String),
timeout: NonNegativeInt,
timeout: optional(NonNegativeInt),
metadata: optional(Metadata),
})
export interface CreateInput extends Schema.Schema.Type<typeof CreateInput> {}
+1
View File
@@ -10,6 +10,7 @@ export const Branch = Schema.Struct({
export interface Branch extends Schema.Schema.Type<typeof Branch> {}
export const Info = Schema.Struct({
provider: optional(Schema.String),
branch: Branch,
}).annotate({ identifier: "Vcs.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+1 -1
View File
@@ -115,7 +115,7 @@ describe("public event manifest", () => {
"session.model.selected.1",
"session.moved.1",
"session.renamed.1",
"session.permissions.updated.1",
"session.permissions.1",
"session.viewed.1",
"session.message.content.updated.1",
"session.usage.recorded.1",
+1 -1
View File
@@ -83,7 +83,7 @@ it.live(
Effect.gen(function* () {
executed.push(generation)
yield* ctx.session
.rename({ sessionID: tool.sessionID, title: `${key}:${generation}` })
.update({ sessionID: tool.sessionID, title: `${key}:${generation}` })
.pipe(Effect.orDie)
return { output: key, content: key }
}),
+1 -1
View File
@@ -90,7 +90,7 @@ test("Promise instances are lazy, share by key and Location, and stay isolated b
expect(setups).toEqual([])
// Permission and form lists read instance services, so they acquire the Session's instance.
expect(await first.permission.list({ sessionID })).toEqual([])
expect(await first.form.list({ sessionID })).toEqual([])
expect(await first.session.form.list({ sessionID })).toEqual([])
expect(configured).toEqual(["first:alpha"])
await Promise.all(
+8 -116
View File
@@ -1,123 +1,15 @@
import { Form } from "@opencode/core/form"
import { Instance } from "@opencode/core/instance/service"
import { LocationServiceMap } from "@opencode/core/location-services"
import { Session } from "@opencode/core/session"
import {
ConflictError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidRequestError,
} from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { requestRef, response, sessionInfo } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
}
import { response } from "../location"
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const instances = yield* Instance.Service
const sessions = yield* Session.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
handlers.handle(
"form.list",
Effect.fn(function* () {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
if (info.sessionID !== sessionID) return yield* missingForm(formID)
return { form, info }
})
return handlers
.handle(
"form.request.list",
Effect.fn(function* () {
const form = yield* Form.Service
return yield* response(form.list())
}),
)
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const session =
ctx.params.sessionID === "global" ? undefined : yield* sessionInfo(sessions, ctx.params.sessionID)
const read = Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID }))
const forms = yield* session
? read.pipe(instances.provide(session))
: read.pipe(Effect.provide(locations.get(requestRef(ctx.request))))
return { data: forms }
}),
)
.handle(
"session.form.create",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
const created = yield* form
.create({
id: ctx.payload.id,
sessionID: ctx.params.sessionID,
title: ctx.payload.title,
metadata: ctx.payload.metadata,
fields: ctx.payload.fields,
})
.pipe(
Effect.catchTags({
"Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
"Form.InvalidFormError": (error) =>
new InvalidRequestError({ message: error.message, field: "fields" }),
}),
)
return { data: created }
}),
)
.handle(
"session.form.get",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
return { data: owned.info }
}),
)
.handle(
"session.form.state",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
const data = yield* owned.form
.state(ctx.params.formID)
.pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(ctx.params.formID)))
return { data }
}),
)
.handle(
"session.form.reply",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
yield* owned.form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
Effect.catchTags({
"Form.AlreadySettledError": (error) =>
new FormAlreadySettledError({ id: error.id, message: error.message }),
"Form.InvalidAnswerError": (error) =>
new FormInvalidAnswerError({ id: error.id, message: error.message }),
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.form.cancel",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
yield* owned.form.cancel(ctx.params.formID).pipe(
Effect.catchTags({
"Form.AlreadySettledError": (error) =>
new FormAlreadySettledError({ id: error.id, message: error.message }),
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
return yield* response(form.list())
}),
),
)
+1 -10
View File
@@ -78,20 +78,11 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedRequest(ctx.params.sessionID, ctx.params.requestID)
yield* owned.permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.decision, message: ctx.payload.message })
.pipe(Effect.catchTag("Permission.NotFoundError", () => missingRequest(ctx.params.requestID)))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.permission.rules",
Effect.fn(function* (ctx) {
yield* sessions
.setPermissions({ sessionID: ctx.params.sessionID, permissions: ctx.payload.permissions })
.pipe(Effect.catchTag("Session.NotFoundError", missingSession))
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"permission.saved.list",
Effect.fn(function* (ctx) {

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