mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-17 14:26:26 +00:00
Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b171a295df | ||
|
|
79d657b8fe | ||
|
|
606ec4fa38 | ||
|
|
2cdd938152 | ||
|
|
4d73b40e59 | ||
|
|
276f2eef3e | ||
|
|
8806d34ed4 | ||
|
|
cda2bc5100 | ||
|
|
ba753b383f | ||
|
|
eb052f70b4 | ||
|
|
5eaed68c2b | ||
|
|
7d7962b2fa | ||
|
|
0537fd47c7 | ||
|
|
9add9384be | ||
|
|
005522786f | ||
|
|
5379a2dfe0 | ||
|
|
6ee156e0b9 | ||
|
|
7c3c50bac2 | ||
|
|
194a006932 | ||
|
|
3c1f87d2d4 | ||
|
|
c57733a647 | ||
|
|
9bf5faa978 | ||
|
|
a062f4e803 | ||
|
|
fc49b16584 | ||
|
|
9b091ebd37 | ||
|
|
12201ee507 | ||
|
|
73bbeb7ce1 | ||
|
|
370b9d484a | ||
|
|
796c423df5 | ||
|
|
978bc53eba | ||
|
|
f7444aba27 | ||
|
|
c987590c5f | ||
|
|
1a3bd3458c | ||
|
|
67a6d52015 | ||
|
|
b1f6d21ef3 | ||
|
|
fa3721fad6 | ||
|
|
02b12aa60e | ||
|
|
1b6a07dda3 | ||
|
|
547d60183d | ||
|
|
12e53ee6d4 | ||
|
|
b629b458f7 | ||
|
|
24dff6929d | ||
|
|
7e2ca5fe26 | ||
|
|
aaed6eaa45 | ||
|
|
01443d5e00 | ||
|
|
70fe186df4 | ||
|
|
fc731d6013 | ||
|
|
4cd6093e31 | ||
|
|
b693c222da | ||
|
|
e31ad14c1d | ||
|
|
6c9115eb75 | ||
|
|
d9dfc5c1d6 | ||
|
|
c921067eb8 | ||
|
|
b76e56eca3 | ||
|
|
3c3ef00b32 | ||
|
|
d54fc305db | ||
|
|
a4aaf18855 |
+83
-83
@@ -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. |
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -54,7 +54,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -113,7 +113,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -177,7 +177,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -203,7 +203,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -217,7 +217,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -253,7 +253,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -280,7 +280,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -297,7 +297,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -321,7 +321,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -341,7 +341,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
@@ -353,6 +353,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/core": "2.0.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/codemode": "workspace:*",
|
||||
@@ -408,7 +409,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -460,7 +461,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -497,7 +498,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -513,7 +514,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -532,7 +533,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -545,7 +546,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -559,7 +560,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -574,7 +575,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -613,7 +614,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -643,7 +644,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -658,7 +659,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -682,7 +683,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -703,7 +704,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -725,7 +726,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -760,7 +761,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -780,7 +781,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -814,7 +815,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -833,7 +834,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -879,7 +880,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -893,7 +894,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -928,7 +929,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -963,7 +964,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -996,7 +997,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1037,7 +1038,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
@@ -168,7 +168,7 @@ else
|
||||
|
||||
package_scope="@opencode"
|
||||
if [ -z "$requested_version" ]; then
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/beta/cli/npm || true)
|
||||
metadata=$(curl -fsSL https://opencode.ai/update/api/latest/cli/npm || true)
|
||||
specific_version=$(echo "$metadata" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
|
||||
package=$(echo "$metadata" | sed -n 's/.*"package":"\([^"]*\)".*/\1/p')
|
||||
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-E5T4o3wNivOg8q4wRV7yE4CUNaUq4QNPxZsnvLaGUcg=",
|
||||
"aarch64-linux": "sha256-xQQi7LgxInZQVCznASfa0Pm+cNGBo5j4Tfp0E/bKnNw=",
|
||||
"aarch64-darwin": "sha256-qxb371dCf7WG09VvQE+HZ/x3EURpFIr11bdnQwGTHhw=",
|
||||
"x86_64-darwin": "sha256-xi1qiYr41hzbgAQl+I3xdtnkiPx2SIcA/0OMpN78qXw="
|
||||
"x86_64-linux": "sha256-U9IuP/ev6w4urvogOwQyl3rdumY6W4YaY18NkFaOVHU=",
|
||||
"aarch64-linux": "sha256-Wc8OT2DRZpVo56KaoGE0Hsj1NDknakbWXO9w2qy6j+0=",
|
||||
"aarch64-darwin": "sha256-wAea8+jajnMDxZ6XJL+Hsrf0621hwtBtWyD1+dS45dE=",
|
||||
"x86_64-darwin": "sha256-g8PCNBSV6rO+VQjKU9AtYqj+r18o+fhLDXEQq+X2EZ4="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -336,12 +336,18 @@ export type OutputItem = StreamItem & { readonly id: string }
|
||||
// Responses-compatible providers put streaming error details at the top level or
|
||||
// under `error`, and response failures under `response.error`. Accept all three shapes.
|
||||
// https://www.openresponses.org/specification
|
||||
const OpenResponsesErrorPayload = Schema.Struct({
|
||||
const OpenResponsesErrorObject = Schema.Struct({
|
||||
type: optionalNull(Schema.String),
|
||||
code: optionalNull(Schema.String),
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
const OpenResponsesErrorPayload = Schema.Union([Schema.String, OpenResponsesErrorObject]).pipe(
|
||||
Schema.decodeTo(OpenResponsesErrorObject, {
|
||||
decode: SchemaGetter.transform((error) => (typeof error === "string" ? { message: error } : error)),
|
||||
encode: SchemaGetter.passthrough(),
|
||||
}),
|
||||
)
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
@@ -434,7 +440,9 @@ export const decodeChannelEvent = (frame: string) =>
|
||||
decodeFrame(frame).pipe(
|
||||
Effect.flatMap((value) =>
|
||||
decodeEventValue(
|
||||
ProviderShared.isRecord(value) && value.type === undefined && ProviderShared.isRecord(value.error)
|
||||
ProviderShared.isRecord(value) &&
|
||||
value.type === undefined &&
|
||||
(typeof value.error === "string" || ProviderShared.isRecord(value.error))
|
||||
? { ...value, type: "error" }
|
||||
: value,
|
||||
),
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import { LLM, LLMClient } from "../../src/index.js"
|
||||
import { OpenResponses } from "../../src/protocols/open-responses.js"
|
||||
import { Meta } from "../../src/providers/index.js"
|
||||
import { Azure, Meta, OpenAI, XAI } from "../../src/providers/index.js"
|
||||
import { WebSocketTransport } from "../../src/route.js"
|
||||
import { configure } from "../../src/providers/openai-compatible-responses.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { fixedResponse } from "../lib/http.js"
|
||||
@@ -44,11 +45,59 @@ it.effect("normalizes flat errors in shared SSE and WebSocket decoding", () =>
|
||||
|
||||
it.effect("continues to normalize untyped xAI WebSocket errors", () =>
|
||||
Effect.gen(function* () {
|
||||
const frame = { error: { type: "api_error", message: "gRPC error: Response with id=resp_missing not found" } }
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify(frame))).toEqual({ ...frame, type: "error" })
|
||||
const message = "gRPC error: Response with id=resp_missing not found"
|
||||
for (const error of [{ type: "api_error", message }, message]) {
|
||||
expect(yield* OpenResponses.decodeChannelEvent(JSON.stringify({ error }))).toEqual({
|
||||
type: "error",
|
||||
error: typeof error === "string" ? { message } : error,
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("normalizes string errors in shared SSE and WebSocket decoding", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const decode of [decodeEvent, OpenResponses.decodeChannelEvent]) {
|
||||
expect(yield* decode(JSON.stringify({ type: "error", error: "Gateway failed" }))).toEqual({
|
||||
type: "error",
|
||||
error: { message: "Gateway failed" },
|
||||
})
|
||||
expect(
|
||||
yield* decode(
|
||||
JSON.stringify({ type: "response.failed", response: { id: "resp_failed", error: "Gateway failed" } }),
|
||||
),
|
||||
).toEqual({
|
||||
type: "response.failed",
|
||||
response: { id: "resp_failed", error: { message: "Gateway failed" } },
|
||||
})
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
for (const model of [
|
||||
OpenAI.configure({ apiKey: "fixture" }).responses("gpt-5.6-sol"),
|
||||
XAI.configure({ apiKey: "fixture" }).responses("grok-4.6"),
|
||||
Azure.configure({ apiKey: "fixture", resourceName: "fixture" }).responses("deployment"),
|
||||
]) {
|
||||
it.effect(`preserves string error messages and raw bodies through ${model.provider} Responses`, () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = '{ "type": "error", "error": "Gateway rejected the request", "trace": "original" }'
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () => Effect.succeed({ sendText: () => Effect.void, messages: Stream.make(raw), close: Effect.void }),
|
||||
})
|
||||
for (const options of [{ webSocket }, {}]) {
|
||||
const error = yield* LLMClient.generate(LLM.request({ model, prompt: "Hello" }), options).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents(raw))),
|
||||
Effect.flip,
|
||||
)
|
||||
expect(error.reason._tag).toBe("UnknownProvider")
|
||||
expect(error.message).toBe("Gateway rejected the request")
|
||||
expect(error.reason.body).toBe(raw)
|
||||
}
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("retains classification and original error bodies through Meta and generic Responses routes", () =>
|
||||
Effect.gen(function* () {
|
||||
const raw = `{
|
||||
|
||||
@@ -22,7 +22,7 @@ test("selects a base branch for a new workspace", async ({ page }) => {
|
||||
pageMessages: () => ({ items: [] }),
|
||||
vcsBranches: ["feature/api", "main", "origin/release"],
|
||||
})
|
||||
await page.route("**/api/vcs/branches?*", (route) => {
|
||||
await page.route("**/api/vcs/branch?*", (route) => {
|
||||
if (new URL(route.request().url()).searchParams.get("search") !== "feature") return route.fallback()
|
||||
return route.fulfill({ json: { location: { directory }, data: ["feature/api"] } })
|
||||
})
|
||||
|
||||
@@ -61,7 +61,7 @@ test("session settings use the remote server context", async ({ page }) => {
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-pending-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -153,7 +153,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
|
||||
@@ -180,14 +180,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-background-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
{
|
||||
origin: serverA,
|
||||
directory: undefined,
|
||||
sessionID: childSessionA.id,
|
||||
permissionID: "permission-background-a-child",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
@@ -243,7 +243,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-offline-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
// The reconnect sweep must resync active sessions instead of trusting
|
||||
@@ -280,7 +280,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
|
||||
directory: undefined,
|
||||
sessionID: sessionA.id,
|
||||
permissionID: "permission-synced-a",
|
||||
body: { reply: "once" },
|
||||
body: { decision: "once" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
item: { type: "user", payload: row.payload, delivery: row.delivery },
|
||||
})
|
||||
},
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
|
||||
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
|
||||
changes.push({ inboxID: input.inboxID, action: input.action })
|
||||
log.push(`${input.action}:${input.inboxID}`)
|
||||
const index = rows.findIndex((row) => row.id === input.inboxID)
|
||||
@@ -85,11 +85,11 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
|
||||
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
|
||||
return
|
||||
}
|
||||
row.delivery = "steer"
|
||||
row.delivery = input.action
|
||||
emit("session.inbox.delivery.changed", {
|
||||
sessionID: input.sessionID,
|
||||
inboxID: input.inboxID,
|
||||
delivery: "steer",
|
||||
delivery: input.action,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,7 +48,10 @@ test("shows a pending question dock", async ({ page }) => {
|
||||
const rejectRequests: string[] = []
|
||||
page.on("request", (request) => {
|
||||
if (request.method() !== "POST") return
|
||||
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
|
||||
if (
|
||||
request.method() === "DELETE" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
|
||||
)
|
||||
rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
@@ -107,7 +110,7 @@ test("shows a pending permission dock", async ({ page }) => {
|
||||
await permission.getByRole("button", { name: "Allow once" }).click()
|
||||
const request = await reply
|
||||
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
|
||||
expect(request.postDataJSON()).toEqual({ reply: "once" })
|
||||
expect(request.postDataJSON()).toEqual({ decision: "once" })
|
||||
})
|
||||
|
||||
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
|
||||
|
||||
@@ -23,6 +23,7 @@ for (const custom of [false, true]) {
|
||||
await page.goto(stressSessionHref(fixture.sourceID))
|
||||
const trigger = page.getByRole("button", { name: "Session details", exact: true })
|
||||
const summary = page.getByRole("dialog", { name: "Session details", exact: true })
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toBeEditable()
|
||||
await expect(trigger).toBeEnabled()
|
||||
await trigger.hover()
|
||||
const tooltip = page.getByRole("tooltip")
|
||||
|
||||
@@ -587,7 +587,7 @@ async function mockServer(page: Page) {
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: null })
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
|
||||
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
|
||||
@@ -278,7 +278,11 @@ async function installMotionProbe(page: Page) {
|
||||
probe.resetAnchorOnMotion = false
|
||||
}
|
||||
probe.terminalAnchorGaps.push(anchorGap)
|
||||
if (panelGap && terminalRegion.getBoundingClientRect().height > 1)
|
||||
if (
|
||||
panelGap &&
|
||||
reviewRegion.getBoundingClientRect().height > 1 &&
|
||||
terminalRegion.getBoundingClientRect().height > 1
|
||||
)
|
||||
probe.panelGaps.push(panelGap.getBoundingClientRect().height)
|
||||
if (!review) return
|
||||
probe.paintGaps.push({
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -42,7 +42,7 @@ export interface MockServerConfig {
|
||||
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
|
||||
inbox?: unknown[] | (() => unknown[])
|
||||
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
|
||||
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
|
||||
}
|
||||
|
||||
type MockStreamWindow = Window & {
|
||||
@@ -454,9 +454,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
|
||||
).pipe(Effect.andThen(noContent)),
|
||||
sessionInboxSteer: (ctx) =>
|
||||
sessionInboxUpdate: (ctx) =>
|
||||
Effect.sync(() =>
|
||||
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
|
||||
config.onInboxChange?.({
|
||||
sessionID: ctx.params.sessionID,
|
||||
inboxID: ctx.params.inboxID,
|
||||
action: ctx.payload.delivery,
|
||||
}),
|
||||
).pipe(Effect.andThen(noContent)),
|
||||
sessionSwitchAgent: () => noContent,
|
||||
sessionSwitchModel: () => noContent,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -146,7 +146,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const next = title.trim()
|
||||
if (!next || next === sessionLabel(session)) return true
|
||||
return ctx.sdk.api.session
|
||||
.rename({ sessionID: session.id, title: next })
|
||||
.update({ sessionID: session.id, title: next })
|
||||
.then(() => {
|
||||
ctx.data.session.remember({ ...(ctx.data.session.get(session.id) ?? session), title: next })
|
||||
// Rename advances time.updated server-side; re-sync the canonical
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -33,6 +33,15 @@ export default function Layout(props: ParentProps) {
|
||||
},
|
||||
install: installUpdate,
|
||||
}
|
||||
// A plain object avoids the compiler's conditional-prop memo, which leaks when read from event handlers.
|
||||
const debugTools = import.meta.env.DEV
|
||||
? {
|
||||
get visible() {
|
||||
return state.debugTools
|
||||
},
|
||||
toggle: () => setState("debugTools", (value) => !value),
|
||||
}
|
||||
: undefined
|
||||
|
||||
return (
|
||||
<TitlebarRightProvider>
|
||||
@@ -53,11 +62,7 @@ export default function Layout(props: ParentProps) {
|
||||
<Titlebar
|
||||
update={update}
|
||||
verticalTabs={verticalTabs() ? { mount: state.tabsMount } : undefined}
|
||||
debugTools={
|
||||
import.meta.env.DEV
|
||||
? { visible: state.debugTools, toggle: () => setState("debugTools", (value) => !value) }
|
||||
: undefined
|
||||
}
|
||||
debugTools={debugTools}
|
||||
/>
|
||||
<div class="flex flex-1 min-h-0 min-w-0 flex-row">
|
||||
<Show when={verticalTabs()}>
|
||||
|
||||
@@ -116,7 +116,7 @@ function SessionTabEntry(props: {
|
||||
|
||||
ctx.data.session.remember({ ...value, title })
|
||||
try {
|
||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||
await ctx.sdk.api.session.update({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx
|
||||
|
||||
@@ -792,7 +792,7 @@ function ChannelIndicator(props: {
|
||||
if (!channel || channel === "prod") return null
|
||||
|
||||
const label = () => language.t(`titlebar.channel.${channel}`)
|
||||
const debug = () => (channel === "dev" ? props.debugTools : undefined)
|
||||
const debug = () => (channel === "dev" || channel === "local" ? props.debugTools : undefined)
|
||||
return (
|
||||
<Tooltip
|
||||
placement={props.sidebar ? "right" : "bottom"}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -33,7 +33,7 @@ const processes: Array<ReturnType<typeof Bun.spawn>> = []
|
||||
const errors: Array<Promise<string>> = []
|
||||
let failure: unknown
|
||||
try {
|
||||
await fs.mkdir(path.join(root, ".opencode"))
|
||||
await fs.mkdir(path.join(root, ".opencode", "plugins"), { recursive: true })
|
||||
spawnService()
|
||||
spawnService()
|
||||
const registration = await waitForRegistration()
|
||||
@@ -54,7 +54,6 @@ try {
|
||||
if (tokenOpenApi.status !== 200) throw new Error("Compiled application rejected query authentication")
|
||||
if ((await pluginIDs(info.url, headers)).includes("smoke")) throw new Error("Smoke plugin existed before creation")
|
||||
const plugin = path.join(root, ".opencode", "plugins", "smoke.ts")
|
||||
await fs.mkdir(path.dirname(plugin), { recursive: true })
|
||||
await fs.writeFile(plugin, pluginSource())
|
||||
await waitForPlugin(info.url, headers)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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 ? "..." : ""),
|
||||
})
|
||||
|
||||
@@ -13,6 +13,7 @@ import { HttpServer } from "effect/unstable/http"
|
||||
import { Env } from "./env"
|
||||
import { ServiceConfig } from "./services/service-config"
|
||||
import { ServiceRegistration } from "./services/service-registration"
|
||||
import { ShellEnvironment } from "./shell-environment"
|
||||
import { Updater } from "./services/updater"
|
||||
import { WebUi } from "./services/web-ui"
|
||||
import { databasePath } from "./database-path"
|
||||
@@ -28,6 +29,9 @@ export type Options = {
|
||||
|
||||
// The process effect lives until server shutdown; tracing it would parent every request to one process-lifetime trace.
|
||||
export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
// A managed service may have been started by a GUI client with launchd's environment. Adopt the
|
||||
// login shell's before anything reads process.env, including the OPENCODE_* settings below.
|
||||
if (options.mode === "service") yield* ShellEnvironment.adopt()
|
||||
return yield* processEffect(options).pipe(
|
||||
Effect.provide(
|
||||
LayerNode.compile(LayerNode.group([Global.node, AppProcess.node]), {
|
||||
@@ -38,9 +42,8 @@ export const run = Effect.fnUntraced(function* (options: Options) {
|
||||
],
|
||||
}),
|
||||
),
|
||||
Effect.provide(NodeServices.layer),
|
||||
)
|
||||
})
|
||||
}, Effect.provide(NodeServices.layer))
|
||||
|
||||
const processEffect = Effect.fnUntraced(function* (options: Options) {
|
||||
const inherited = process.env.OPENCODE_PTY_HANDOFF
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
export * as ShellEnvironment from "./shell-environment"
|
||||
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { Effect } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
|
||||
// Set on the probe shell so an rc file that itself starts opencode cannot recurse into another probe.
|
||||
export const RESOLVING = "OPENCODE_RESOLVING_SHELL_ENVIRONMENT"
|
||||
|
||||
// Shell bookkeeping that describes the probe process, not the user's environment.
|
||||
const TRANSIENT = new Set([RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
|
||||
|
||||
export type Variables = Readonly<Record<string, string | undefined>>
|
||||
|
||||
/**
|
||||
* Resolve the environment of the user's interactive login shell when the current environment did
|
||||
* not come from one.
|
||||
*
|
||||
* A background service elected by a GUI client (the desktop app, an editor, a login item) inherits
|
||||
* launchd's environment: PATH is `/usr/bin:/bin:/usr/sbin:/sbin` and nothing exported from
|
||||
* `.zshrc` or `.bashrc` exists. Every process the server spawns extends `process.env`, so stdio MCP
|
||||
* servers using `#!/usr/bin/env node`, `npx`, `uvx`, formatters, and hooks fail with no useful error
|
||||
* while the bash tool, which prefers the client's terminal environment, keeps working. Editors such
|
||||
* as VS Code and Zed resolve the login shell at startup for the same reason.
|
||||
*
|
||||
* Returns `undefined` when there is nothing to adopt: Windows, an environment that already passed
|
||||
* through a shell (`SHLVL`), a probe already in progress, or a shell that fails to report.
|
||||
*/
|
||||
export const resolve = Effect.fnUntraced(function* (env: Variables = process.env) {
|
||||
if (process.platform === "win32") return undefined
|
||||
if (env[RESOLVING] !== undefined || env.SHLVL !== undefined) return undefined
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
const marker = randomUUID()
|
||||
// Interactive and login: most PATH edits live in `.zshrc`/`.bashrc`, which only interactive shells read.
|
||||
// `env -0` keeps values containing newlines intact.
|
||||
const output = yield* spawner
|
||||
.string(
|
||||
ChildProcess.make(env.SHELL || "/bin/sh", ["-ilc", `printf '%s' '${marker}'; env -0; printf '%s' '${marker}'`], {
|
||||
env: { ...env, [RESOLVING]: "1" },
|
||||
stdin: "ignore",
|
||||
// Interactive shells without a terminal warn on stderr; nothing reads it, so never let it fill.
|
||||
stderr: "ignore",
|
||||
}),
|
||||
)
|
||||
.pipe(
|
||||
Effect.timeout("10 seconds"),
|
||||
Effect.tapError((error) => Effect.logWarning("shell environment unavailable", { error })),
|
||||
Effect.orElseSucceed(() => undefined),
|
||||
)
|
||||
if (output === undefined) return undefined
|
||||
const start = output.indexOf(marker)
|
||||
const end = output.lastIndexOf(marker)
|
||||
if (start === -1 || end === start) {
|
||||
yield* Effect.logWarning("shell environment unavailable", { shell: env.SHELL, reason: "missing markers" })
|
||||
return undefined
|
||||
}
|
||||
return Object.fromEntries(
|
||||
output
|
||||
.slice(start + marker.length, end)
|
||||
.split("\0")
|
||||
.flatMap((entry) => {
|
||||
const separator = entry.indexOf("=")
|
||||
if (separator <= 0) return []
|
||||
const key = entry.slice(0, separator)
|
||||
return TRANSIENT.has(key) ? [] : [[key, entry.slice(separator + 1)] as const]
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
/** Apply the resolved login-shell environment to this process before anything reads `process.env`. */
|
||||
export const adopt = Effect.fnUntraced(function* () {
|
||||
const variables = yield* resolve()
|
||||
if (variables === undefined) return
|
||||
Object.assign(process.env, variables)
|
||||
yield* Effect.logInfo("shell environment adopted", {
|
||||
shell: process.env.SHELL,
|
||||
variables: Object.keys(variables).length,
|
||||
})
|
||||
})
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
declare module "virtual:opencode-app-assets" {
|
||||
const archive: string
|
||||
const archive: Readonly<Record<string, string>>
|
||||
export default archive
|
||||
}
|
||||
|
||||
@@ -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]] : []
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -240,17 +240,17 @@ async function run(input: {
|
||||
})()
|
||||
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
|
||||
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
|
||||
spyOn(sdk.form, "list").mockImplementation(
|
||||
spyOn(sdk.session.form, "list").mockImplementation(
|
||||
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
|
||||
)
|
||||
spyOn(sdk.form.request, "list").mockImplementation(
|
||||
spyOn(sdk.form, "list").mockImplementation(
|
||||
() =>
|
||||
ok({
|
||||
location: { ...location, project: { id: "proj_1", directory: location.directory } },
|
||||
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
|
||||
}) as never,
|
||||
)
|
||||
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
|
||||
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
|
||||
let promptID = "msg_prompt"
|
||||
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
|
||||
spyOn(sdk.message, "list").mockImplementation(() =>
|
||||
@@ -426,10 +426,10 @@ describe("runNonInteractivePrompt", () => {
|
||||
"x-opencode-directory": "%2Fwork%20tree",
|
||||
},
|
||||
}
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
||||
expect(sdk.form.request.list).toHaveBeenCalledWith({
|
||||
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
|
||||
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
|
||||
expect(sdk.form.list).toHaveBeenCalledWith({
|
||||
location: { directory: "/work tree" },
|
||||
})
|
||||
})
|
||||
@@ -440,10 +440,10 @@ describe("runNonInteractivePrompt", () => {
|
||||
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
|
||||
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
|
||||
})
|
||||
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||
expect(sdk.form.request.list).not.toHaveBeenCalled()
|
||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
|
||||
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
|
||||
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
|
||||
expect(sdk.form.list).not.toHaveBeenCalled()
|
||||
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
|
||||
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
|
||||
{ sessionID: "global", formID: "frm_pending_global" },
|
||||
expect.anything(),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NodeServices } from "@effect/platform-node"
|
||||
import { afterAll, beforeAll, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import fs from "node:fs/promises"
|
||||
import os from "node:os"
|
||||
import path from "node:path"
|
||||
import { ShellEnvironment } from "../src/shell-environment"
|
||||
|
||||
// A stand-in login shell: it records every invocation, applies "rc file" exports, then runs the
|
||||
// probe script exactly as `$SHELL -ilc <script>` would.
|
||||
let root: string
|
||||
let shell: string
|
||||
let invocations: string
|
||||
|
||||
beforeAll(async () => {
|
||||
root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-shell-env-"))
|
||||
shell = path.join(root, "login-shell")
|
||||
invocations = path.join(root, "invocations")
|
||||
await fs.writeFile(
|
||||
shell,
|
||||
[
|
||||
"#!/bin/sh",
|
||||
`printf '%s\\n' "$1" >> '${invocations}'`,
|
||||
`[ "$1" = -ilc ] || exit 64`,
|
||||
'export PATH="/rc/bin:$PATH"',
|
||||
"export FROM_RC=yes",
|
||||
"export MULTILINE='first",
|
||||
"second'",
|
||||
'export SAW_GUARD="${OPENCODE_RESOLVING_SHELL_ENVIRONMENT:-unset}"',
|
||||
"export SHLVL=1 PWD=/rc OLDPWD=/rc",
|
||||
'eval "$2"',
|
||||
].join("\n"),
|
||||
{ mode: 0o755 },
|
||||
)
|
||||
})
|
||||
|
||||
afterAll(() => fs.rm(root, { recursive: true, force: true }))
|
||||
|
||||
const skip = process.platform === "win32"
|
||||
const launchd = { SHELL: "", HOME: "", PATH: "/usr/bin:/bin:/usr/sbin:/sbin" }
|
||||
const resolve = (env: Record<string, string | undefined>) =>
|
||||
Effect.runPromise(
|
||||
ShellEnvironment.resolve({ ...launchd, ...env, SHELL: env.SHELL ?? shell, HOME: root }).pipe(
|
||||
Effect.provide(NodeServices.layer),
|
||||
),
|
||||
)
|
||||
|
||||
test.skipIf(skip)("adopts the login shell's exports over a launchd environment", async () => {
|
||||
const variables = await resolve({})
|
||||
expect(variables?.PATH).toBe("/rc/bin:/usr/bin:/bin:/usr/sbin:/sbin")
|
||||
expect(variables?.FROM_RC).toBe("yes")
|
||||
expect(variables?.MULTILINE).toBe("first\nsecond")
|
||||
expect(variables?.SAW_GUARD).toBe("1")
|
||||
for (const key of [ShellEnvironment.RESOLVING, "SHLVL", "PWD", "OLDPWD", "_"])
|
||||
expect(variables).not.toHaveProperty(key)
|
||||
})
|
||||
|
||||
test.skipIf(skip)("does not probe when the environment already came from a shell or a probe", async () => {
|
||||
const before = await fs.readFile(invocations, "utf8").catch(() => "")
|
||||
expect(await resolve({ SHLVL: "1" })).toBeUndefined()
|
||||
expect(await resolve({ [ShellEnvironment.RESOLVING]: "1" })).toBeUndefined()
|
||||
expect(await fs.readFile(invocations, "utf8").catch(() => "")).toBe(before)
|
||||
})
|
||||
|
||||
test.skipIf(skip)("falls back to the inherited environment when the shell cannot report", async () => {
|
||||
expect(await resolve({ SHELL: "/bin/false" })).toBeUndefined()
|
||||
expect(await resolve({ SHELL: path.join(root, "missing-shell") })).toBeUndefined()
|
||||
})
|
||||
@@ -31,7 +31,7 @@ function appAssetsPlugin(archive: string): Plugin {
|
||||
},
|
||||
load(id) {
|
||||
if (id !== "\0virtual:opencode-app-assets") return
|
||||
return `export default ${JSON.stringify(archive)}`
|
||||
return `export default ${archive}`
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -292,5 +292,5 @@ export default mainConfig({
|
||||
channel: process.env.OPENCODE_CHANNEL ?? "local",
|
||||
assetHash: "local",
|
||||
target: nodeTarget(process.platform, process.arch),
|
||||
appArchive: "",
|
||||
appArchive: "{}",
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -88,8 +88,11 @@ runtime.catalog() // structured tool descriptions
|
||||
runtime.execute(source) // Effect<CodeMode.Result, never, ToolServices>
|
||||
```
|
||||
|
||||
The Effect environment is inferred from the supplied tools. `onToolCallStart` observes admitted calls with decoded
|
||||
input; `onToolCallEnd` observes settled outcomes and duration. Both hooks return Effects and must not fail.
|
||||
The Effect environment is inferred from the supplied tools. `hooks` surround every call the program makes into the
|
||||
host: `tool.before`/`tool.after` receive `{ name, input }` with decoded input, `extension.before`/`extension.after`
|
||||
receive `{ extension, name, args }`. An `after` hook also receives how the call ended (`success` with its value,
|
||||
`failure` with its error, or `interrupted`). A failing `before` hook denies the call, and the program catches the
|
||||
failure as a thrown error.
|
||||
|
||||
### `Values`
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] The host boundary is `JSON.stringify` plus a short table. The program result and tool arguments cross as
|
||||
what `JSON.stringify` would serialize: `toJSON` is honored, functions and `undefined` properties vanish,
|
||||
`undefined` array elements and non-finite numbers become `null`, a cyclic value throws the same `TypeError`,
|
||||
and Map, RegExp, generators, and extension handles serialize as `{}`. A bare `undefined` result is `null`.
|
||||
and Map, RegExp, and generators serialize as `{}`. A bare `undefined` result is `null`.
|
||||
Tool results come back the way `JSON.parse(JSON.stringify(result))` would. The table, where a value cannot
|
||||
be JSON but what the program meant is clear: a promise is awaited (a rejection fails the program), a Set
|
||||
crosses as an array, a URLSearchParams as its query string, an Error as `{ name, message, ...own }`, a
|
||||
@@ -192,7 +192,7 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
- [x] Prefix and postfix `++` and `--`.
|
||||
- [x] Plain, arithmetic, bitwise, and logical assignment operators.
|
||||
- [x] Property deletion on plain data objects and arrays, including computed and optional forms; deleting an array index
|
||||
creates a hole without changing its length. Deleting a non-configurable property (`length`, `lastIndex`) or
|
||||
creates a hole without changing its length. Deleting a non-configurable property (`length`) or
|
||||
assigning a read-only one (`Math.PI`, `fn.name`) throws a `TypeError`, as in strict mode.
|
||||
- [ ] Operators, `switch` discriminants, template interpolation, and coercion helpers such as `String` and `isNaN`
|
||||
applied to functions and namespaces; JavaScript coerces them, the interpreter rejects non-data operands.
|
||||
@@ -394,7 +394,9 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
|
||||
`unicodeSets`, and `dotAll`.
|
||||
- [x] Captures, named groups, match `.index` and `.input`, and stateful global matching.
|
||||
- [x] Integration with supported String methods, including function replacers.
|
||||
- [x] Writable `lastIndex`.
|
||||
- [x] Writable `lastIndex`, shared by `exec`, `test`, and the String methods. It is a prototype accessor that stores
|
||||
a number, so `re.lastIndex = "12"` reads back `12`, `delete` is a no-op, and `hasOwnProperty("lastIndex")` is
|
||||
`false`.
|
||||
- [x] Match `indices` metadata for the `d` flag, including named groups on `exec`, `match`, and `matchAll` results.
|
||||
- [x] `RegExp.escape`.
|
||||
|
||||
@@ -452,29 +454,27 @@ with a hint to encode as text first (`TextDecoder`, `toBase64`, `toHex`).
|
||||
|
||||
## Extensions
|
||||
|
||||
Host classes and functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
|
||||
Host functions a host opts in through `Extension.make({ name, globals })` and `CodeMode.make({ extensions })`.
|
||||
Nothing is exposed unless a host provides it; extension calls are not tool calls.
|
||||
|
||||
- [x] Each global is a class or a function, exposed as-is: constructors with `new`, prototype methods, accessors,
|
||||
and statics (including through an exposed subclass, so `new this()` works), plus inheritance
|
||||
up to the nearest exposed ancestor. A global that shadows a built-in or another extension throws at `make`.
|
||||
- [x] Instances of exposed classes stay on the host; the program holds a handle whose only members are the class's.
|
||||
The same host instance is always the same handle within a run, so identity and `instanceof` hold. A handle
|
||||
serializes as `{}` like any object without enumerable properties, so the host object never crosses.
|
||||
- [x] Each global is a function, callable but not constructible, run with `this` undefined. A global that shadows
|
||||
a built-in or another extension throws at `make`.
|
||||
- [x] Every value crossing in either direction is converted, never shared: plain objects and arrays are copied,
|
||||
`Date`, `RegExp`, `URL`, `URLSearchParams`, `Map`, `Set`, and `Uint8Array` become fresh copies with their
|
||||
contents converted (a host `ArrayBuffer` comes in as a `Uint8Array`; other typed arrays cannot come out),
|
||||
errors cross as errors with their name and message, and a `__proto__` key is dropped. Functions, generators,
|
||||
un-awaited promises, and symbols cannot be passed in; an instance of an unexposed class, a symbol, or a BigInt
|
||||
cannot come out.
|
||||
un-awaited promises, and symbols cannot be passed in; a class instance, a symbol, or a BigInt cannot come out.
|
||||
- [x] A host function inside a result becomes a program function whose calls cross the same way, so a result can
|
||||
carry methods (`res.json()`) whose host closures keep the host state. Diagnostics name it by its path
|
||||
(`fetch.json`). Like any program function it vanishes at the data boundary.
|
||||
- [x] Each call to an extension global runs inside the host's `extension.before`/`extension.after` hooks as
|
||||
`{ extension, name, args }`, with the host's own error on failure; calls to functions inside results do not.
|
||||
- [x] A host `Promise` becomes a program promise. Whatever host code returns, resolves, throws, or rejects with
|
||||
crosses the same way, so `catch (e)` receives a copy of the thrown value (an `Error` of the matching type, or
|
||||
plain data). A getter must be synchronous.
|
||||
- [x] A prototype member runs only with a handle of its own class as `this`; a detached call, a plain object, or a
|
||||
handle of another class throws `TypeError: Illegal invocation`. Program edits to an exposed prototype affect
|
||||
that run only. Data properties on a class or prototype are not exposed, since a program write would change the
|
||||
host class itself; expose one through an accessor.
|
||||
plain data).
|
||||
- [ ] Program functions as arguments to extension code (callbacks such as `forEach`).
|
||||
- [ ] Host classes. Stateful host objects are expressed as closures; a declared method table would be the next
|
||||
step if `new X()` in a program is ever needed.
|
||||
|
||||
## Errors and diagnostics
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -7,7 +7,14 @@ import { type Services, type ToolDescription, ToolRuntime } from "./tool-runtime
|
||||
import type { Tools } from "./tools.js"
|
||||
|
||||
/** A tool call admitted during an execution. */
|
||||
export type { ToolCall, ToolCallEnded, ToolCallHooks, ToolCallStarted, ToolDescription } from "./tool-runtime.js"
|
||||
export type {
|
||||
CallResult,
|
||||
ExtensionInvocation,
|
||||
Hooks,
|
||||
ToolCall,
|
||||
ToolDescription,
|
||||
ToolInvocation,
|
||||
} from "./tool-runtime.js"
|
||||
/** Signature-construction helpers for host-owned catalog instructions. */
|
||||
export { searchSignature, toolExpression } from "./tool-runtime.js"
|
||||
|
||||
@@ -34,10 +41,12 @@ export type ResolvedExecutionLimits = {
|
||||
}
|
||||
|
||||
/** Configuration shared by `CodeMode.make` and `CodeMode.execute`. */
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = ToolRuntime.ToolCallHooks<Services<Provided>> & {
|
||||
export type Options<Provided extends Record<string, unknown> = {}> = {
|
||||
/** Explicit tools exposed to the program as `tools`. */
|
||||
tools?: Provided & Tools<Services<Provided>>
|
||||
/** Host classes and functions exposed as globals; see `Extension.make`. */
|
||||
/** Hooks around every tool and extension call the program makes; see `Hooks`. */
|
||||
hooks?: ToolRuntime.Hooks<Services<Provided>>
|
||||
/** Host functions exposed as globals; see `Extension.make`. */
|
||||
extensions?: ReadonlyArray<Extension>
|
||||
/** Resource limits enforced on each execution. */
|
||||
limits?: ExecutionLimits
|
||||
@@ -147,7 +156,10 @@ export const make = <const Provided extends Record<string, unknown> = {}>(
|
||||
}
|
||||
}
|
||||
return {
|
||||
catalog: prepared.catalog,
|
||||
execute: (code) => executeProgram(code, prepared, limits, options, (ctx) => extensionGlobals(ctx, extensions)),
|
||||
get catalog() {
|
||||
return prepared.catalog
|
||||
},
|
||||
execute: (code) =>
|
||||
executeProgram(code, prepared, limits, options.hooks ?? {}, (ctx) => extensionGlobals(ctx, extensions)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
export * as Extension from "./extension.js"
|
||||
|
||||
/**
|
||||
* Host classes and functions a program uses directly, like JavaScript. Values crossing in either direction are
|
||||
* converted, never shared: plain data is copied, instances of the classes stay on the host behind program-side
|
||||
* handles. Extension calls are not tool calls.
|
||||
* Host functions a program calls directly as globals. Values crossing in either direction are converted, never
|
||||
* shared: arguments come in as copies, results go out as copies, and a function inside a result is callable the
|
||||
* same way. Extension calls are not tool calls.
|
||||
*/
|
||||
export type Extension = {
|
||||
readonly name: string
|
||||
/** Each value is a class or a function; everything on a class is exposed, including statics and accessors. */
|
||||
readonly globals: Readonly<Record<string, Function>>
|
||||
}
|
||||
|
||||
export const make = (options: Extension): Extension => {
|
||||
for (const [name, value] of Object.entries(options.globals)) {
|
||||
if (typeof value !== "function") {
|
||||
throw new TypeError(`Extension "${options.name}" global "${name}" must be a class or a function.`)
|
||||
throw new TypeError(`Extension "${options.name}" global "${name}" must be a function.`)
|
||||
}
|
||||
}
|
||||
return { name: options.name, globals: { ...options.globals } }
|
||||
|
||||
@@ -16,7 +16,7 @@ export const executeProgram = <R>(
|
||||
code: string,
|
||||
prepared: ToolRuntime.Prepared<R>,
|
||||
limits: ResolvedExecutionLimits,
|
||||
hooks: ToolRuntime.ToolCallHooks<R>,
|
||||
hooks: ToolRuntime.Hooks<R>,
|
||||
globals?: (ctx: Interpreter<R>) => ReadonlyArray<readonly [string, unknown]>,
|
||||
): Effect.Effect<Result, never, R> => {
|
||||
if (code.trim().length === 0) {
|
||||
|
||||
@@ -1,25 +1,22 @@
|
||||
import { Effect } from "effect"
|
||||
import type { Extension } from "../extension.js"
|
||||
import { coerceToString } from "../stdlib/value.js"
|
||||
import { type ExtensionInvocation, hooked } from "../tool-runtime.js"
|
||||
import type { Interpreter } from "./interpreter.js"
|
||||
import { createErrorValue, isErrorType } from "./intrinsics.js"
|
||||
import { MAX_VALUE_DEPTH } from "./limits.js"
|
||||
import { Throw, typeError } from "./model.js"
|
||||
import { constructor, fn } from "./native.js"
|
||||
import { fn } from "./native.js"
|
||||
import {
|
||||
Callable,
|
||||
define,
|
||||
defineAccessor,
|
||||
entries,
|
||||
get,
|
||||
hidden,
|
||||
type Native,
|
||||
Arr,
|
||||
Bytes,
|
||||
DateObj,
|
||||
ErrorObj,
|
||||
GeneratorObj,
|
||||
Handle,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -30,31 +27,16 @@ import {
|
||||
} from "./objects.js"
|
||||
import { describeValue } from "./references.js"
|
||||
|
||||
type Class = Function & { readonly prototype: object }
|
||||
|
||||
const isClass = (value: unknown): value is Class =>
|
||||
typeof value === "function" && typeof value.prototype === "object" && value.prototype !== null
|
||||
|
||||
// Own keys the native function already carries.
|
||||
const ownFunctionKeys = new Set(["length", "name", "prototype"])
|
||||
const ownPrototypeKeys = new Set(["constructor"])
|
||||
|
||||
/**
|
||||
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data is
|
||||
* copied, built-in wrappers are copied, instances of exposed classes travel as handles, and a host Promise becomes
|
||||
* a program promise. Prototypes, constructors, and handle identity are all per run.
|
||||
* The global bindings of one run's extensions. Everything crossing the boundary is converted: plain data and
|
||||
* built-in wrappers are copied, a host function becomes a program function whose calls cross the same way, and a
|
||||
* host Promise becomes a program promise.
|
||||
*/
|
||||
export const extensionGlobals = <R>(
|
||||
ctx: Interpreter<R>,
|
||||
extensions: ReadonlyArray<Extension>,
|
||||
): ReadonlyArray<readonly [string, unknown]> => {
|
||||
const builtins = ctx.builtins
|
||||
const classes = new Set(extensions.flatMap((extension) => Object.values(extension.globals)).filter(isClass))
|
||||
// Host prototype object → this run's program prototype, so an instance wraps as its most-derived exposed class.
|
||||
const protoOf = new Map<object, Obj>()
|
||||
const exposed = new Map<Class, { ctor: Native<R>; proto: Obj }>()
|
||||
const classOf = new Map<unknown, Class>()
|
||||
const handles = new WeakMap<object, Handle>()
|
||||
|
||||
const toHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
@@ -62,7 +44,6 @@ export const extensionGlobals = <R>(
|
||||
if (isPrimitive(value)) return value
|
||||
throw typeError(`${label} contains ${describeValue(value)}, which cannot be passed to an extension.`)
|
||||
}
|
||||
if (value instanceof Handle) return value.instance
|
||||
if (value instanceof Bytes) return new Uint8Array(value.bytes)
|
||||
if (value instanceof DateObj) return new Date(value.time)
|
||||
if (value instanceof RegExpObj) return new RegExp(value.regex.source, value.regex.flags)
|
||||
@@ -83,7 +64,7 @@ export const extensionGlobals = <R>(
|
||||
const name = coerceToString(get(value, "name"))
|
||||
const message = get(value, "message")
|
||||
const text = message === undefined ? "" : coerceToString(message)
|
||||
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors[name] ?? Error)(text)
|
||||
return name === "AggregateError" ? new AggregateError([], text) : new (hostErrors.get(name) ?? Error)(text)
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} contains a circular value.`)
|
||||
seen.add(value)
|
||||
@@ -102,15 +83,8 @@ export const extensionGlobals = <R>(
|
||||
const fromHost = (value: unknown, label: string, depth = 0, seen = new Set<object>()): unknown => {
|
||||
if (depth > MAX_VALUE_DEPTH) throw typeError(`${label} exceeds the maximum value depth of ${MAX_VALUE_DEPTH}.`)
|
||||
if (isPrimitive(value)) return value
|
||||
if (typeof value === "function") return wrap(value, label)
|
||||
if (value !== null && typeof value === "object") {
|
||||
const existing = handles.get(value)
|
||||
if (existing !== undefined) return existing
|
||||
const proto = handlePrototype(value)
|
||||
if (proto !== undefined) {
|
||||
const handle = new Handle(proto, value)
|
||||
handles.set(value, handle)
|
||||
return handle
|
||||
}
|
||||
if (value instanceof Date) return new DateObj(builtins.Date, value.getTime())
|
||||
if (value instanceof RegExp) return new RegExpObj(builtins.RegExp, value.source, value.flags)
|
||||
if (value instanceof Uint8Array) return new Bytes(builtins.Uint8Array, new Uint8Array(value))
|
||||
@@ -122,28 +96,31 @@ export const extensionGlobals = <R>(
|
||||
if (value instanceof URLSearchParams) {
|
||||
return new URLSearchParamsObj(builtins.URLSearchParams, new URLSearchParams(value))
|
||||
}
|
||||
const next = (item: unknown) => fromHost(item, label, depth + 1, seen)
|
||||
const next = (item: unknown, path: string) => fromHost(item, path, depth + 1, seen)
|
||||
if (value instanceof Map) {
|
||||
const wrapped = new MapObj(builtins.Map)
|
||||
for (const [key, item] of value) wrapped.map.set(next(key), next(item))
|
||||
for (const [key, item] of value) wrapped.map.set(next(key, label), next(item, label))
|
||||
return wrapped
|
||||
}
|
||||
if (value instanceof Set) {
|
||||
const wrapped = new SetObj(builtins.Set)
|
||||
for (const item of value) wrapped.set.add(next(item))
|
||||
for (const item of value) wrapped.set.add(next(item, label))
|
||||
return wrapped
|
||||
}
|
||||
if (seen.has(value)) throw typeError(`${label} produced a circular value.`)
|
||||
seen.add(value)
|
||||
if (Array.isArray(value)) {
|
||||
const copied = new Arr(builtins.Array, value.map(next))
|
||||
const copied = new Arr(
|
||||
builtins.Array,
|
||||
value.map((item, index) => next(item, `${label}[${index}]`)),
|
||||
)
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype === Object.prototype || prototype === null) {
|
||||
const copied = new Obj(builtins.Object)
|
||||
for (const [key, item] of Object.entries(value)) define(copied, key, next(item))
|
||||
for (const [key, item] of Object.entries(value)) define(copied, key, next(item, `${label}.${key}`))
|
||||
seen.delete(value)
|
||||
return copied
|
||||
}
|
||||
@@ -151,153 +128,46 @@ export const extensionGlobals = <R>(
|
||||
throw typeError(`${label} produced ${describeHost(value)}, which the program cannot hold.`)
|
||||
}
|
||||
|
||||
const handlePrototype = (instance: object): Obj | undefined => {
|
||||
for (let level = Object.getPrototypeOf(instance); level !== null; level = Object.getPrototypeOf(level)) {
|
||||
const proto = protoOf.get(level)
|
||||
if (proto !== undefined) return proto
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// Runs host code with already-converted inputs. Whatever it returns, resolves, throws, or rejects with crosses
|
||||
// the same way, so the program catches what the author threw.
|
||||
const invoke = (run: () => unknown, label: string): Effect.Effect<unknown, unknown, R> => {
|
||||
const thrown = (reason: unknown) => new Throw(fromHost(reason, label))
|
||||
let result: unknown
|
||||
try {
|
||||
result = run()
|
||||
} catch (reason) {
|
||||
return Effect.fail(thrown(reason))
|
||||
}
|
||||
if (!(result instanceof Promise)) return Effect.succeed(fromHost(result, label))
|
||||
return ctx.pending.create(
|
||||
Effect.map(Effect.tryPromise({ try: () => result, catch: thrown }), (settled) => fromHost(settled, label)),
|
||||
)
|
||||
}
|
||||
|
||||
const args = (values: Array<unknown>, label: string): Array<unknown> =>
|
||||
values.map((value, index) => toHost(value, `Argument ${index + 1} to ${label}`))
|
||||
|
||||
// Own members of each level from `from` up to (excluding) `root`, child first, as JS resolves them.
|
||||
const members = (
|
||||
target: Obj,
|
||||
from: object,
|
||||
root: object,
|
||||
skip: ReadonlySet<string>,
|
||||
label: string,
|
||||
receiver: (thisValue: unknown, member: string) => unknown,
|
||||
): void => {
|
||||
for (let level: object | null = from; level !== null && level !== root; level = Object.getPrototypeOf(level)) {
|
||||
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(level))) {
|
||||
if (skip.has(key) || target.props.has(key)) continue
|
||||
const name = `${label}.${key}`
|
||||
if (typeof descriptor.value === "function") {
|
||||
const method: Function = descriptor.value
|
||||
const impl = (thisValue: unknown, values: Array<unknown>) => {
|
||||
const self = receiver(thisValue, name)
|
||||
const converted = args(values, name)
|
||||
return invoke(() => method.apply(self, converted), name)
|
||||
}
|
||||
define(target, key, fn<R>(builtins, key, method.length, impl), hidden)
|
||||
continue
|
||||
}
|
||||
// Data properties stay host-side: a program write to one would change the host class itself.
|
||||
if ("value" in descriptor) continue
|
||||
const get = descriptor.get
|
||||
const set = descriptor.set
|
||||
defineAccessor(
|
||||
target,
|
||||
key,
|
||||
get === undefined
|
||||
? undefined
|
||||
: (thisValue) => {
|
||||
const value = get.call(receiver(thisValue, name))
|
||||
if (value instanceof Promise)
|
||||
throw typeError(`${name} returned a Promise; a getter must be synchronous.`)
|
||||
return fromHost(value, name)
|
||||
},
|
||||
set === undefined
|
||||
? undefined
|
||||
: (thisValue, value) => {
|
||||
set.call(receiver(thisValue, name), toHost(value, `${name} value`))
|
||||
},
|
||||
// A host function as a program function. Arguments cross in; a global's call runs inside the host's extension
|
||||
// hooks, which see the host's own error on failure; then whatever came back, or was thrown, crosses out so the
|
||||
// program catches a copy. Functions inside results are part of a value's API and skip the hooks.
|
||||
const wrap = (value: Function, label: string, describe?: (args: ReadonlyArray<unknown>) => ExtensionInvocation) =>
|
||||
fn<R>(builtins, value.name, value.length, (_, values) => {
|
||||
const args = values.map((item, index) => toHost(item, `Argument ${index + 1} to ${label}`))
|
||||
const hooks = ctx.tools.hooks
|
||||
const settle = (run: Effect.Effect<unknown, unknown, R>) =>
|
||||
(describe === undefined
|
||||
? run
|
||||
: hooked(describe(args), hooks["extension.before"], hooks["extension.after"], run)
|
||||
).pipe(
|
||||
Effect.mapError((reason) => new Throw(fromHost(reason, label))),
|
||||
Effect.map((settled) => fromHost(settled, label)),
|
||||
)
|
||||
let result: unknown
|
||||
try {
|
||||
result = value.apply(undefined, args)
|
||||
} catch (reason) {
|
||||
return settle(Effect.fail(reason))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const expose = (cls: Class): { ctor: Native<R>; proto: Obj } => {
|
||||
const existing = exposed.get(cls)
|
||||
if (existing !== undefined) return existing
|
||||
const ancestor = exposedAncestor(cls)
|
||||
const base = ancestor === undefined ? undefined : expose(ancestor)
|
||||
const proto = new Obj(base === undefined ? builtins.Object : base.proto)
|
||||
protoOf.set(cls.prototype, proto)
|
||||
const name = cls.name
|
||||
const ctor = constructor<R>(builtins, proto, {
|
||||
name,
|
||||
length: cls.length,
|
||||
call: (_, values) => {
|
||||
const converted = args(values, name)
|
||||
return invoke(() => cls.apply(undefined, converted), name)
|
||||
},
|
||||
construct: (values) => {
|
||||
const label = `new ${name}`
|
||||
const construct = cls as new (...values: Array<unknown>) => object
|
||||
const converted = args(values, label)
|
||||
return invoke(() => new construct(...converted), label)
|
||||
},
|
||||
if (!(result instanceof Promise)) return settle(Effect.succeed(result))
|
||||
return ctx.pending.create(settle(Effect.tryPromise({ try: () => result, catch: (reason) => reason })))
|
||||
})
|
||||
if (base !== undefined) ctor.proto = base.ctor
|
||||
const entry = { ctor, proto }
|
||||
exposed.set(cls, entry)
|
||||
classOf.set(ctor, cls)
|
||||
// A static called through an exposed subclass sees that subclass as `this`, like JS.
|
||||
members(ctor, cls, ancestor ?? Function.prototype, ownFunctionKeys, name, (thisValue) => {
|
||||
const called = classOf.get(thisValue)
|
||||
return called !== undefined && (called === cls || called.prototype instanceof cls) ? called : cls
|
||||
})
|
||||
members(
|
||||
proto,
|
||||
cls.prototype,
|
||||
ancestor?.prototype ?? Object.prototype,
|
||||
ownPrototypeKeys,
|
||||
`${name}.prototype`,
|
||||
(thisValue, member) => {
|
||||
if (thisValue instanceof Handle && thisValue.instance instanceof cls) return thisValue.instance
|
||||
throw typeError(`Illegal invocation: ${member} called on ${describeValue(thisValue)}.`)
|
||||
},
|
||||
)
|
||||
return entry
|
||||
}
|
||||
|
||||
const exposedAncestor = (cls: Class): Class | undefined => {
|
||||
for (let level = Object.getPrototypeOf(cls); isClass(level); level = Object.getPrototypeOf(level)) {
|
||||
if (classes.has(level)) return level
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
return extensions.flatMap((extension) =>
|
||||
Object.entries(extension.globals).map(([name, value]) => {
|
||||
if (isClass(value)) return [name, expose(value).ctor] as const
|
||||
const impl = (_: unknown, values: Array<unknown>) => {
|
||||
const converted = args(values, name)
|
||||
return invoke(() => value.apply(undefined, converted), name)
|
||||
}
|
||||
return [name, fn<R>(builtins, name, value.length, impl)] as const
|
||||
}),
|
||||
Object.entries(extension.globals).map(
|
||||
([name, value]) => [name, wrap(value, name, (args) => ({ extension: extension.name, name, args }))] as const,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
const hostErrors: Record<string, ErrorConstructor | undefined> = {
|
||||
TypeError,
|
||||
RangeError,
|
||||
SyntaxError,
|
||||
ReferenceError,
|
||||
EvalError,
|
||||
URIError,
|
||||
}
|
||||
const hostErrors = new Map<string, ErrorConstructor>([
|
||||
["TypeError", TypeError],
|
||||
["RangeError", RangeError],
|
||||
["SyntaxError", SyntaxError],
|
||||
["ReferenceError", ReferenceError],
|
||||
["EvalError", EvalError],
|
||||
["URIError", URIError],
|
||||
])
|
||||
|
||||
// The primitives the interpreter operates on; symbols and BigInts are not among them.
|
||||
const isPrimitive = (value: unknown): boolean =>
|
||||
@@ -308,7 +178,6 @@ const isPrimitive = (value: unknown): boolean =>
|
||||
typeof value === "boolean"
|
||||
|
||||
const describeHost = (value: unknown): string => {
|
||||
if (typeof value === "function") return "a function"
|
||||
if (typeof value !== "object" || value === null) return `a ${typeof value}`
|
||||
const name = (value as { constructor?: { name?: string } }).constructor?.name
|
||||
return name === undefined || name === "" ? "an object" : `a ${name}`
|
||||
|
||||
@@ -136,7 +136,6 @@ export class RegExpObj extends Obj {
|
||||
constructor(proto: Obj, pattern: string, flags: string) {
|
||||
super(proto)
|
||||
this.regex = new RegExp(pattern, flags)
|
||||
define(this, "lastIndex", 0, { writable: true, enumerable: false, configurable: false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,16 +178,6 @@ export class Bytes extends Obj {
|
||||
}
|
||||
}
|
||||
|
||||
/** An instance of an extension class: the host object lives in a field no property path reaches. */
|
||||
export class Handle extends Obj {
|
||||
constructor(
|
||||
proto: Obj,
|
||||
readonly instance: object,
|
||||
) {
|
||||
super(proto)
|
||||
}
|
||||
}
|
||||
|
||||
/** Built-in objects that wrap a host value; data-like, but never plain data. */
|
||||
export const isWrapper = (
|
||||
value: unknown,
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
Bytes,
|
||||
DateObj,
|
||||
GeneratorObj,
|
||||
Handle,
|
||||
MapObj,
|
||||
Obj,
|
||||
PromiseObj,
|
||||
@@ -23,7 +22,6 @@ import {
|
||||
export const isRuntimeReference = (value: unknown): boolean =>
|
||||
value instanceof Callable ||
|
||||
value instanceof GeneratorObj ||
|
||||
value instanceof Handle ||
|
||||
value instanceof ToolReference ||
|
||||
value instanceof PromiseObj ||
|
||||
isWrapper(value)
|
||||
@@ -89,7 +87,6 @@ export const describeValue = (value: unknown): string => {
|
||||
if (value instanceof URLSearchParamsObj) return "a URLSearchParams"
|
||||
if (value instanceof Bytes) return "a Uint8Array"
|
||||
if (value instanceof GeneratorObj) return "a generator"
|
||||
if (value instanceof Handle) return `a ${value.instance.constructor.name}`
|
||||
if (isRuntimeReference(value)) return "a function"
|
||||
if (typeof value === "object") return "a data object"
|
||||
return `a ${typeof value}`
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Effect } from "effect"
|
||||
import type { Builtins } from "../interpreter/intrinsics.js"
|
||||
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
|
||||
import { syntaxError, typeError } from "../interpreter/model.js"
|
||||
import { define, defineAccessor, getOwn, Arr, Obj, RegExpObj, record, set } from "../interpreter/objects.js"
|
||||
import { define, defineAccessor, Arr, Obj, RegExpObj, record } from "../interpreter/objects.js"
|
||||
import type { Interpreter } from "../interpreter/interpreter.js"
|
||||
import { coerceToNumber, coerceToString } from "./value.js"
|
||||
|
||||
@@ -75,12 +75,6 @@ export const constructRegExp = (builtins: Builtins, args: Array<unknown>, proto:
|
||||
}
|
||||
}
|
||||
|
||||
const toLength = (value: unknown): number => {
|
||||
const number = coerceToNumber(value)
|
||||
if (Number.isNaN(number) || number <= 0) return 0
|
||||
return Math.min(Math.floor(number), Number.MAX_SAFE_INTEGER)
|
||||
}
|
||||
|
||||
// RegExp constructs identically with or without new, like JS.
|
||||
export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const builtins = ctx.builtins
|
||||
@@ -105,18 +99,22 @@ export const regexpGlobal = <R>(ctx: Interpreter<R>) => {
|
||||
const self = (thisValue: unknown, name: string) => receiver(RegExpObj, thisValue, `RegExp.prototype.${name}`)
|
||||
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
|
||||
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
|
||||
// The host regex holds the only lastIndex, so exec/test and the String methods share one counter.
|
||||
defineAccessor(
|
||||
proto,
|
||||
"lastIndex",
|
||||
(thisValue) => self(thisValue, "lastIndex").regex.lastIndex,
|
||||
(thisValue, value) => {
|
||||
self(thisValue, "lastIndex").regex.lastIndex = coerceToNumber(value)
|
||||
},
|
||||
)
|
||||
for (const name of flagProperties) defineAccessor(proto, name, (thisValue) => self(thisValue, name).regex[name])
|
||||
// exec/test run the host regex from the program-visible lastIndex and write it back only when g or y is set.
|
||||
const run = (name: "exec" | "test"): Method => [
|
||||
name,
|
||||
1,
|
||||
(thisValue, args) => {
|
||||
const value = self(thisValue, name)
|
||||
const input = coerceToString(args[0])
|
||||
const stateful = value.regex.global || value.regex.sticky
|
||||
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
|
||||
const matched = value.regex.exec(input)
|
||||
if (stateful) set(value, "lastIndex", value.regex.lastIndex)
|
||||
const matched = value.regex.exec(coerceToString(args[0]))
|
||||
if (name === "test") return matched !== null
|
||||
return matched === null ? null : matchToValue(builtins, matched)
|
||||
},
|
||||
|
||||
@@ -36,26 +36,48 @@ export type ToolCall = {
|
||||
readonly name: string
|
||||
}
|
||||
|
||||
export type ToolCallStarted = {
|
||||
readonly index: number
|
||||
/** A tool call the program is making, with its decoded input. */
|
||||
export type ToolInvocation = { readonly name: string; readonly input: unknown }
|
||||
|
||||
/** A call the program is making to an extension global, with its arguments. */
|
||||
export type ExtensionInvocation = {
|
||||
readonly extension: string
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly args: ReadonlyArray<unknown>
|
||||
}
|
||||
|
||||
export type ToolCallEnded = {
|
||||
readonly index: number
|
||||
readonly name: string
|
||||
readonly input: unknown
|
||||
readonly durationMs: number
|
||||
readonly outcome: "success" | "failure" | "interrupted"
|
||||
readonly message?: string
|
||||
/** How a call ended; `after` hooks observe it and cannot change it. */
|
||||
export type CallResult =
|
||||
| { readonly status: "success"; readonly value: unknown }
|
||||
| { readonly status: "failure"; readonly error: unknown }
|
||||
| { readonly status: "interrupted" }
|
||||
|
||||
/** Hooks around every call the program makes into the host. A failing `before` denies the call. */
|
||||
export type Hooks<R = never> = {
|
||||
readonly "tool.before"?: ((call: ToolInvocation) => Effect.Effect<void, unknown, R>) | undefined
|
||||
readonly "tool.after"?: ((call: ToolInvocation, result: CallResult) => Effect.Effect<void, never, R>) | undefined
|
||||
readonly "extension.before"?: ((call: ExtensionInvocation) => Effect.Effect<void, unknown, R>) | undefined
|
||||
readonly "extension.after"?:
|
||||
| ((call: ExtensionInvocation, result: CallResult) => Effect.Effect<void, never, R>)
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type ToolCallHooks<R = never> = {
|
||||
/** Observes decoded tool input immediately before tool execution. */
|
||||
readonly onToolCallStart?: ((call: ToolCallStarted) => Effect.Effect<void, never, R>) | undefined
|
||||
/** Observes each admitted tool call as it succeeds, fails, or is interrupted. */
|
||||
readonly onToolCallEnd?: ((call: ToolCallEnded) => Effect.Effect<void, never, R>) | undefined
|
||||
/** Runs `before`, then `run`, then `after` with how it ended, including when interrupted. */
|
||||
export const hooked = <Call, A, R>(
|
||||
call: Call,
|
||||
before: ((call: Call) => Effect.Effect<void, unknown, R>) | undefined,
|
||||
after: ((call: Call, result: CallResult) => Effect.Effect<void, never, R>) | undefined,
|
||||
run: Effect.Effect<A, unknown, R>,
|
||||
): Effect.Effect<A, unknown, R> => {
|
||||
const observed =
|
||||
after === undefined
|
||||
? run
|
||||
: Effect.onExit(run, (exit) => {
|
||||
if (Exit.isSuccess(exit)) return after(call, { status: "success", value: exit.value })
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return after(call, { status: "interrupted" })
|
||||
return after(call, { status: "failure", error: Cause.squash(exit.cause) })
|
||||
})
|
||||
return before === undefined ? observed : Effect.andThen(before(call), observed)
|
||||
}
|
||||
|
||||
export type ToolDescription = {
|
||||
@@ -144,15 +166,24 @@ const flattenTools = <R>(
|
||||
]
|
||||
}
|
||||
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => ({
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
signature: isEmptyInput(visible.tool)
|
||||
? `${toolExpression(visible.path)}(): Promise<${outputTypeScript(visible.tool, true)}>`
|
||||
: `${toolExpression(visible.path)}(input: ${inputTypeScript(visible.tool, true)}): Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
})
|
||||
const describeTool = <R>(visible: VisibleTool<R>): ToolDescription => {
|
||||
let signature: string | undefined
|
||||
return {
|
||||
path: visible.path,
|
||||
description: visible.tool.description,
|
||||
get signature() {
|
||||
// Search ranks paths and descriptions first; only returned matches need their schemas rendered.
|
||||
// Joining the final fragments avoids retaining the rendering's intermediate string ropes in JSC.
|
||||
return (signature ??= [
|
||||
toolExpression(visible.path),
|
||||
isEmptyInput(visible.tool) ? "()" : `(input: ${inputTypeScript(visible.tool, true)})`,
|
||||
`: Promise<${outputTypeScript(visible.tool, true)}>`,
|
||||
].join(""))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Tools indexed once per runtime: the lookup trie plus the model-facing catalog and search index. */
|
||||
/** Tools indexed once per runtime, with discovery materialized on demand. */
|
||||
export type Prepared<R = never> = {
|
||||
readonly root: ToolNode<R>
|
||||
readonly catalog: ReadonlyArray<ToolDescription>
|
||||
@@ -264,12 +295,20 @@ const toSearchEntry = <R>(visible: VisibleTool<R>): SearchEntry => ({
|
||||
|
||||
export const prepare = <R>(tools: Tools<R>): Prepared<R> => {
|
||||
const root = toolTrie(tools)
|
||||
// Discovery bytes are durable instructions, so order only after canonical-path collisions settle.
|
||||
const visible = flattenTools(root).sort((left, right) => compareText(left.path, right.path))
|
||||
let searchIndex: ReadonlyArray<SearchEntry> | undefined
|
||||
let catalog: ReadonlyArray<ToolDescription> | undefined
|
||||
return {
|
||||
root,
|
||||
catalog: visible.map(describeTool),
|
||||
searchIndex: visible.map(toSearchEntry),
|
||||
get catalog() {
|
||||
return (catalog ??= this.searchIndex.map((entry) => entry.description))
|
||||
},
|
||||
get searchIndex() {
|
||||
// Executing known tools only needs the trie. Render discovery when it is actually read,
|
||||
// ordering after canonical-path collisions settle so instruction bytes stay deterministic.
|
||||
return (searchIndex ??= flattenTools(root)
|
||||
.sort((left, right) => compareText(left.path, right.path))
|
||||
.map(toSearchEntry))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -316,6 +355,7 @@ export class ToolRuntimeError extends Error {
|
||||
/** The tool bridge of one execution. Arguments arrive and results leave as JSON; program values never enter. */
|
||||
export type ToolRuntime<R = never> = {
|
||||
readonly calls: Array<ToolCall>
|
||||
readonly hooks: Hooks<R>
|
||||
readonly execute: (
|
||||
path: ReadonlyArray<string>,
|
||||
args: Array<Json | undefined>,
|
||||
@@ -328,27 +368,10 @@ export type ToolRuntime<R = never> = {
|
||||
export const make = <R>(
|
||||
prepared: Prepared<R>,
|
||||
maxToolCalls: number | undefined,
|
||||
hooks?: ToolCallHooks<R>,
|
||||
hooks: Hooks<R> = {},
|
||||
): ToolRuntime<R> => {
|
||||
const calls: Array<ToolCall> = []
|
||||
const root = prepared.root
|
||||
const searchTool = makeSearchTool(prepared.searchIndex)
|
||||
|
||||
const observeEnd = <A, E>(effect: Effect.Effect<A, E, R>, call: ToolCallStarted): Effect.Effect<A, E, R> => {
|
||||
const onEnd = hooks?.onToolCallEnd
|
||||
if (onEnd === undefined) return effect
|
||||
const startedAt = Date.now()
|
||||
return effect.pipe(
|
||||
Effect.onExit((exit) => {
|
||||
const durationMs = Date.now() - startedAt
|
||||
if (Exit.isSuccess(exit)) return onEnd({ ...call, durationMs, outcome: "success" })
|
||||
if (Cause.hasInterruptsOnly(exit.cause)) return onEnd({ ...call, durationMs, outcome: "interrupted" })
|
||||
const error = Cause.squash(exit.cause)
|
||||
const message = error instanceof Error ? error.message : Cause.pretty(exit.cause)
|
||||
return onEnd({ ...call, durationMs, outcome: "failure", message })
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const recordCall = (call: ToolCall): void => {
|
||||
if (maxToolCalls !== undefined && calls.length >= maxToolCalls) {
|
||||
@@ -371,14 +394,12 @@ export const make = <R>(
|
||||
name === "search" ? [] : ["The signature may have changed. Use search to get the current signature."],
|
||||
),
|
||||
})
|
||||
const index = yield* Effect.sync(() => {
|
||||
recordCall({ name })
|
||||
return calls.length - 1
|
||||
})
|
||||
const call = { index, name, input }
|
||||
return yield* observeEnd(
|
||||
yield* Effect.sync(() => recordCall({ name }))
|
||||
return yield* hooked(
|
||||
{ name, input },
|
||||
hooks["tool.before"],
|
||||
hooks["tool.after"],
|
||||
Effect.gen(function* () {
|
||||
if (hooks?.onToolCallStart !== undefined) yield* hooks.onToolCallStart(call)
|
||||
const raw = yield* Effect.suspend(() => tool.execute(input)).pipe(
|
||||
Effect.catchCause((cause) => {
|
||||
if (Cause.hasInterruptsOnly(cause)) return Effect.interrupt
|
||||
@@ -400,16 +421,22 @@ export const make = <R>(
|
||||
catch: (cause) => new ToolRuntimeError("InvalidToolOutput", `Invalid output from tool '${name}': ${cause}`),
|
||||
})
|
||||
}),
|
||||
call,
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
calls,
|
||||
hooks,
|
||||
keys: (path) => namespaceKeys(root, path),
|
||||
search: (args) => Effect.suspend(() => executeTool("search", searchTool, args)),
|
||||
search: (args) => Effect.suspend(() => executeTool("search", makeSearchTool(prepared.searchIndex), args)),
|
||||
execute: (path, args) =>
|
||||
Effect.suspend(() => executeTool(canonicalSegments(path).join("."), resolve(root, path), args)),
|
||||
Effect.suspend(() => {
|
||||
const segments = canonicalSegments(path)
|
||||
// Models often write `tools.search(...)` for the bare `search(...)`; honor it unless a tool owns that path.
|
||||
if (segments.length === 1 && segments[0] === "search" && lookup(root, segments) === undefined)
|
||||
return executeTool("search", makeSearchTool(prepared.searchIndex), args)
|
||||
return executeTool(segments.join("."), resolve(root, path), args)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Cause, Effect, Schema } from "effect"
|
||||
import { CodeMode, Tool, toolError } from "../src/index.js"
|
||||
import { CodeMode, Extension, Tool, toolError } from "../src/index.js"
|
||||
|
||||
const run = (tool: Tool.Tool<never>) =>
|
||||
Effect.runPromise(CodeMode.make({ tools: { host: { call: tool } } }).execute("return await tools.host.call({})"))
|
||||
@@ -160,77 +160,154 @@ describe("CodeMode host failure boundary", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("CodeMode tool-call observation", () => {
|
||||
test("reports the tools actually invoked with decoded input", async () => {
|
||||
const calls: Array<unknown> = []
|
||||
describe("CodeMode call hooks", () => {
|
||||
const ended = (result: CodeMode.CallResult) => {
|
||||
if (result.status !== "failure") return result.status
|
||||
return `failure:${result.error instanceof Error ? result.error.message : String(result.error)}`
|
||||
}
|
||||
// Every hook logs its event, so the order across tools and extensions is visible.
|
||||
const observe = (events: Array<string>): CodeMode.Hooks => ({
|
||||
"tool.before": (call) => Effect.sync(() => void events.push(`tool.before ${JSON.stringify(call)}`)),
|
||||
"tool.after": (call, result) => Effect.sync(() => void events.push(`tool.after ${call.name} ${ended(result)}`)),
|
||||
"extension.before": (call) => Effect.sync(() => void events.push(`extension.before ${JSON.stringify(call)}`)),
|
||||
"extension.after": (call, result) =>
|
||||
Effect.sync(() => void events.push(`extension.after ${call.name} ${ended(result)}`)),
|
||||
})
|
||||
|
||||
test("surround the tools actually invoked, with decoded input", async () => {
|
||||
const events: Array<string> = []
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) => Effect.succeed(query),
|
||||
execute: ({ query }) => (query === "boom" ? Effect.fail(toolError("Lookup refused")) : Effect.succeed(query)),
|
||||
})
|
||||
const runtime = CodeMode.make({ tools: { context: { lookup } }, hooks: observe(events) })
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) => Effect.sync(() => calls.push(call)),
|
||||
}).execute(`
|
||||
const success = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
if (false) await tools.context.lookup({ query: "not called" })
|
||||
return await tools.context.lookup({ query: "deployment failure" })
|
||||
`),
|
||||
)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
expect(calls).toStrictEqual([{ index: 0, name: "context.lookup", input: { query: "deployment failure" } }])
|
||||
})
|
||||
|
||||
test("observes settled calls with outcome and duration", async () => {
|
||||
const events: Array<{ phase: string; index: number; name: string; outcome?: string; message?: string }> = []
|
||||
const lookup = Tool.make({
|
||||
description: "Look up a value",
|
||||
input: Schema.Struct({ query: Schema.String }),
|
||||
output: Schema.String,
|
||||
execute: ({ query }) =>
|
||||
query === "boom"
|
||||
? Effect.fail(toolError("Lookup refused"))
|
||||
: query === "defect"
|
||||
? Effect.die("broken")
|
||||
: Effect.succeed(query),
|
||||
})
|
||||
|
||||
const runtime = CodeMode.make({
|
||||
tools: { context: { lookup } },
|
||||
onToolCallStart: (call) =>
|
||||
Effect.sync(() => {
|
||||
events.push({ phase: "start", index: call.index, name: call.name })
|
||||
}),
|
||||
onToolCallEnd: (call) =>
|
||||
Effect.sync(() => {
|
||||
expect(call.durationMs).toBeGreaterThanOrEqual(0)
|
||||
events.push({
|
||||
phase: "end",
|
||||
index: call.index,
|
||||
name: call.name,
|
||||
outcome: call.outcome,
|
||||
...(call.message === undefined ? {} : { message: call.message }),
|
||||
})
|
||||
}),
|
||||
})
|
||||
|
||||
const success = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "ok" })`))
|
||||
expect(success.ok).toBe(true)
|
||||
expect(success).toMatchObject({ ok: true, value: "deployment failure" })
|
||||
const failure = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "boom" })`))
|
||||
expect(failure.ok).toBe(false)
|
||||
const defect = await Effect.runPromise(runtime.execute(`return await tools.context.lookup({ query: "defect" })`))
|
||||
expect(defect.ok).toBe(false)
|
||||
expect(events).toEqual([
|
||||
'tool.before {"name":"context.lookup","input":{"query":"deployment failure"}}',
|
||||
"tool.after context.lookup success",
|
||||
'tool.before {"name":"context.lookup","input":{"query":"boom"}}',
|
||||
"tool.after context.lookup failure:Lookup refused",
|
||||
])
|
||||
})
|
||||
|
||||
expect(events).toStrictEqual([
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "success" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "Lookup refused" },
|
||||
{ phase: "start", index: 0, name: "context.lookup" },
|
||||
{ phase: "end", index: 0, name: "context.lookup", outcome: "failure", message: "broken" },
|
||||
test("surround each call to an extension global, with its arguments, but not functions inside results", async () => {
|
||||
const events: Array<string> = []
|
||||
const runtime = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "web",
|
||||
globals: {
|
||||
fetch: async (url: string, init?: { method?: string }) => ({
|
||||
url,
|
||||
method: init?.method ?? "GET",
|
||||
json: () => 1,
|
||||
}),
|
||||
fail: () => {
|
||||
throw new RangeError("nope")
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
hooks: observe(events),
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const res = await fetch("https://a.test/", { method: "POST" })
|
||||
res.json()
|
||||
try { fail() } catch {}
|
||||
return res.url
|
||||
`),
|
||||
)
|
||||
expect(result).toMatchObject({ ok: true, value: "https://a.test/", toolCalls: [] })
|
||||
expect(events).toEqual([
|
||||
'extension.before {"extension":"web","name":"fetch","args":["https://a.test/",{"method":"POST"}]}',
|
||||
"extension.after fetch success",
|
||||
'extension.before {"extension":"web","name":"fail","args":[]}',
|
||||
"extension.after fail failure:nope",
|
||||
])
|
||||
})
|
||||
|
||||
test("a failing before hook denies the call; the program catches the host's error", async () => {
|
||||
const runtime = CodeMode.make({
|
||||
tools: {
|
||||
lookup: Tool.make({
|
||||
description: "Look up",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
}),
|
||||
},
|
||||
extensions: [Extension.make({ name: "web", globals: { fetch: async (url: string) => url } })],
|
||||
hooks: { "extension.before": (call) => Effect.fail(new Error(`${call.name} is not allowed`)) },
|
||||
})
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
runtime.execute(`
|
||||
const denied = await fetch("https://a.test/").catch((e) => [e instanceof Error, e.message])
|
||||
return [denied, await tools.lookup({})]
|
||||
`),
|
||||
),
|
||||
).toMatchObject({ ok: true, value: [[true, "fetch is not allowed"], "ok"] })
|
||||
expect(await Effect.runPromise(runtime.execute(`await fetch("https://a.test/")`))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "ExecutionFailure", message: "Error: fetch is not allowed" },
|
||||
})
|
||||
const refused = CodeMode.make({
|
||||
tools: {
|
||||
lookup: Tool.make({
|
||||
description: "Look up",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("ok"),
|
||||
}),
|
||||
},
|
||||
hooks: { "tool.before": () => Effect.fail(toolError("lookup is not allowed")) },
|
||||
})
|
||||
expect(
|
||||
await Effect.runPromise(
|
||||
refused.execute(`try { await tools.lookup({}) } catch (e) { return [e.name, e.message] }`),
|
||||
),
|
||||
).toMatchObject({ ok: true, value: ["Error", "lookup is not allowed"], toolCalls: [{ name: "lookup" }] })
|
||||
expect(await Effect.runPromise(refused.execute(`await tools.lookup({})`))).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "ToolFailure", message: "lookup is not allowed" },
|
||||
})
|
||||
})
|
||||
|
||||
test("see a tool defect as the failure the program gets, and an interrupted extension call", async () => {
|
||||
const events: Array<string> = []
|
||||
const broken = Tool.make({
|
||||
description: "Broken",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.die("broken"),
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { broken },
|
||||
extensions: [Extension.make({ name: "slow", globals: { forever: () => new Promise(() => {}) } })],
|
||||
hooks: observe(events),
|
||||
})
|
||||
expect(await Effect.runPromise(runtime.execute(`await tools.broken({})`))).toMatchObject({ ok: false })
|
||||
expect(await Effect.runPromise(runtime.execute(`forever(); return "done"`))).toMatchObject({
|
||||
ok: true,
|
||||
value: "done",
|
||||
})
|
||||
expect(events).toEqual([
|
||||
'tool.before {"name":"broken","input":{}}',
|
||||
"tool.after broken failure:broken",
|
||||
'extension.before {"extension":"slow","name":"forever","args":[]}',
|
||||
"extension.after forever interrupted",
|
||||
])
|
||||
})
|
||||
|
||||
@@ -243,15 +320,11 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.interrupt,
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
|
||||
test("observes running calls interrupted during completion", async () => {
|
||||
@@ -263,39 +336,17 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.sync(() => events.push("start")),
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(`end:${call.outcome}`)),
|
||||
}).execute('tools.host.call({}); return "done"'),
|
||||
CodeMode.make({ tools: { host: { call } }, hooks: observe(events) }).execute(
|
||||
'tools.host.call({}); return "done"',
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: true, value: "done" })
|
||||
expect(events).toEqual(["start", "end:interrupted"])
|
||||
})
|
||||
|
||||
test("ends calls interrupted during start observation", async () => {
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Unused",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.String,
|
||||
execute: () => Effect.succeed("unused"),
|
||||
})
|
||||
const exit = await Effect.runPromiseExit(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
onToolCallStart: () => Effect.interrupt,
|
||||
onToolCallEnd: (call) => Effect.sync(() => events.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
expect(events).toEqual(["interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
|
||||
test("observes calls interrupted by the execution timeout", async () => {
|
||||
const outcomes: Array<string> = []
|
||||
const events: Array<string> = []
|
||||
const call = Tool.make({
|
||||
description: "Pending",
|
||||
input: Schema.Struct({}),
|
||||
@@ -303,15 +354,13 @@ describe("CodeMode tool-call observation", () => {
|
||||
execute: () => Effect.never,
|
||||
})
|
||||
const result = await Effect.runPromise(
|
||||
CodeMode.make({
|
||||
tools: { host: { call } },
|
||||
limits: { timeoutMs: 10 },
|
||||
onToolCallEnd: (call) => Effect.sync(() => outcomes.push(call.outcome)),
|
||||
}).execute("return await tools.host.call({})"),
|
||||
CodeMode.make({ tools: { host: { call } }, limits: { timeoutMs: 10 }, hooks: observe(events) }).execute(
|
||||
"return await tools.host.call({})",
|
||||
),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "TimeoutExceeded" } })
|
||||
expect(outcomes).toEqual(["interrupted"])
|
||||
expect(events).toEqual(['tool.before {"name":"host.call","input":{}}', "tool.after host.call interrupted"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -840,20 +889,17 @@ describe("CodeMode public contract", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("search is a counted tool call: it burns maxToolCalls and fires the hooks", async () => {
|
||||
const started: Array<string> = []
|
||||
const ended: Array<string> = []
|
||||
test("search is a counted tool call: it burns maxToolCalls and is observed", async () => {
|
||||
const observed: Array<CodeMode.ToolInvocation> = []
|
||||
const limited = CodeMode.make({
|
||||
tools,
|
||||
limits: { maxToolCalls: 1 },
|
||||
onToolCallStart: (call) => Effect.sync(() => void started.push(call.name)),
|
||||
onToolCallEnd: (call) => Effect.sync(() => void ended.push(`${call.name}:${call.outcome}`)),
|
||||
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call)) },
|
||||
})
|
||||
const result = await Effect.runPromise(limited.execute(`search({}); return search({})`))
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.error.kind).toBe("ToolCallLimitExceeded")
|
||||
expect(started).toEqual(["search"])
|
||||
expect(ended).toEqual(["search:success"])
|
||||
expect(observed).toEqual([{ name: "search", input: {} }])
|
||||
})
|
||||
|
||||
test("search is an opaque, shadowable global like other built-ins", async () => {
|
||||
@@ -1089,7 +1135,7 @@ describe("CodeMode public contract", () => {
|
||||
})
|
||||
const runtime = CodeMode.make({
|
||||
tools: { math: { double: transformed } },
|
||||
onToolCallStart: (call) => Effect.sync(() => observed.push(call.input)),
|
||||
hooks: { "tool.before": (call) => Effect.sync(() => void observed.push(call.input)) },
|
||||
})
|
||||
|
||||
const success = await Effect.runPromise(runtime.execute(`return await tools.math.double({ value: "21" })`))
|
||||
|
||||
@@ -2,67 +2,12 @@ import { describe, expect, test } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { CodeMode, Extension, Tool } from "../src/index.js"
|
||||
|
||||
class Bag {
|
||||
static made = 0
|
||||
static of(...items: Array<string>) {
|
||||
return new this(items)
|
||||
}
|
||||
constructor(readonly items: Array<string> = []) {
|
||||
Bag.made++
|
||||
}
|
||||
get size() {
|
||||
return this.items.length
|
||||
}
|
||||
set size(length: number) {
|
||||
this.items.length = length
|
||||
}
|
||||
add(item: string) {
|
||||
this.items.push(item)
|
||||
return this
|
||||
}
|
||||
toArray() {
|
||||
return [...this.items]
|
||||
}
|
||||
pair() {
|
||||
return { self: this, list: [this, new Bag()] }
|
||||
}
|
||||
async later<T>(value: T) {
|
||||
return value
|
||||
}
|
||||
async reject(reason: unknown) {
|
||||
throw reason
|
||||
}
|
||||
fail() {
|
||||
throw new RangeError("boom")
|
||||
}
|
||||
get lazy() {
|
||||
return Promise.resolve(1)
|
||||
}
|
||||
detached() {
|
||||
return new Other()
|
||||
}
|
||||
}
|
||||
class Other {}
|
||||
class Big extends Bag {
|
||||
double() {
|
||||
return this.items.length * 2
|
||||
}
|
||||
}
|
||||
class Vault {
|
||||
secrets = new Map<string, string>()
|
||||
set(key: string, value: string) {
|
||||
this.secrets.set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
const held: Array<unknown> = []
|
||||
const config = { retries: 3, nested: { deep: true } }
|
||||
const requests: Array<unknown> = []
|
||||
const extension = Extension.make({
|
||||
name: "bag",
|
||||
name: "web",
|
||||
globals: {
|
||||
Bag,
|
||||
Big,
|
||||
Vault,
|
||||
keep: (value: unknown) => {
|
||||
held.push(value)
|
||||
return value
|
||||
@@ -70,6 +15,19 @@ const extension = Extension.make({
|
||||
settings: () => config,
|
||||
later: async (value: number) => value + 1,
|
||||
first: (map: Map<unknown, unknown>) => map.get("k"),
|
||||
fetch: async (url: string, init?: { method?: string }) => {
|
||||
requests.push([url, init])
|
||||
const bytes = new TextEncoder().encode(`{"url":"${url}"}`)
|
||||
return {
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: { get: (name: string) => (name === "content-type" ? "application/json" : null) },
|
||||
text: () => new TextDecoder().decode(bytes),
|
||||
json: () => JSON.parse(new TextDecoder().decode(bytes)),
|
||||
bytes: () => bytes,
|
||||
handlers: [(step: number) => step + 1],
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -87,66 +45,61 @@ const failure = async (code: string, target = runtime) => {
|
||||
return result.error
|
||||
}
|
||||
|
||||
describe("extension classes behave like JS", () => {
|
||||
test("construct, call methods, read and write accessors", async () => {
|
||||
expect(await value(`const b = new Bag(["a"]); b.add("b"); return [b.size, b.toArray()]`)).toEqual([2, ["a", "b"]])
|
||||
expect(await value(`const b = new Bag(["a", "b"]); b.size = 1; return b.toArray()`)).toEqual(["a"])
|
||||
})
|
||||
|
||||
test("instanceof, constructor, typeof, and prototype identity", async () => {
|
||||
expect(
|
||||
await value(
|
||||
`const b = new Bag(); return [b instanceof Bag, b.constructor === Bag, typeof Bag, Bag.prototype.constructor === Bag]`,
|
||||
),
|
||||
).toEqual([true, true, "function", true])
|
||||
})
|
||||
|
||||
test("statics, including `new this()` through an exposed subclass", async () => {
|
||||
expect(await value(`return [Bag.of("x", "y").toArray(), Big.of("q") instanceof Big, Big.of === Bag.of]`)).toEqual([
|
||||
["x", "y"],
|
||||
true,
|
||||
true,
|
||||
])
|
||||
})
|
||||
|
||||
test("data properties are invisible, so a program write never reaches the host class", async () => {
|
||||
Bag.made = 0
|
||||
expect(await value(`Bag.made = 999; return Bag.made`)).toBe(999)
|
||||
expect(Bag.made).toBe(0)
|
||||
expect(await value(`return [Bag.made, new Bag(["a"]).items]`)).toEqual([null, null])
|
||||
})
|
||||
|
||||
test("inheritance chains to the exposed ancestor", async () => {
|
||||
expect(
|
||||
await value(`const b = new Big(["a"]); return [b.double(), b.add("b").size, b instanceof Bag, b instanceof Big]`),
|
||||
).toEqual([2, 2, true, true])
|
||||
})
|
||||
|
||||
test("calling a class without new throws the host TypeError", async () => {
|
||||
const error = await failure(`Bag()`)
|
||||
expect(error.message).toStartWith("TypeError: ")
|
||||
expect(error.message).toContain("new")
|
||||
})
|
||||
|
||||
test("a function global is callable, awaitable, and not constructible", async () => {
|
||||
describe("extension functions", () => {
|
||||
test("a global is callable, awaitable, and not constructible", async () => {
|
||||
expect(await value(`return await later(1)`)).toBe(2)
|
||||
expect(await value(`return [typeof later, later.name, later.length]`)).toEqual(["function", "later", 1])
|
||||
expect((await failure(`new later()`)).message).toContain("new later(...) is not supported")
|
||||
})
|
||||
|
||||
test("a program can patch a prototype for its own run only", async () => {
|
||||
expect(await value(`Bag.prototype.add = () => "patched"; return new Bag().add("x")`)).toBe("patched")
|
||||
expect(await value(`return new Bag().add("x").toArray()`)).toEqual(["x"])
|
||||
test("a function inside a result is callable and crosses the same way", async () => {
|
||||
requests.length = 0
|
||||
expect(
|
||||
await value(
|
||||
`const res = await fetch("https://a.test/", { method: "GET" }); return [res.status, res.ok, res.headers.get("content-type"), res.text(), res.json(), [...res.bytes()].length, typeof res.json, res.json.name, res.handlers[0](1)]`,
|
||||
),
|
||||
).toEqual([
|
||||
200,
|
||||
true,
|
||||
"application/json",
|
||||
'{"url":"https://a.test/"}',
|
||||
{ url: "https://a.test/" },
|
||||
25,
|
||||
"function",
|
||||
"json",
|
||||
2,
|
||||
])
|
||||
expect(requests).toEqual([["https://a.test/", { method: "GET" }]])
|
||||
})
|
||||
|
||||
test("a function inside a result is named by its path in diagnostics", async () => {
|
||||
expect((await failure(`const res = await fetch("https://a.test/"); res.handlers[0](() => 1)`)).message).toContain(
|
||||
"Argument 1 to fetch.handlers[0] contains a function",
|
||||
)
|
||||
const target = CodeMode.make({
|
||||
extensions: [Extension.make({ name: "odd", globals: { make: () => ({ sym: () => Symbol("s") }) } })],
|
||||
})
|
||||
expect((await failure(`make().sym()`, target)).message).toContain("make.sym produced a symbol")
|
||||
})
|
||||
|
||||
test("a function is invisible to the data boundary like any program function", async () => {
|
||||
expect(await value(`return await fetch("https://a.test/")`)).toEqual({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: {},
|
||||
handlers: [null],
|
||||
})
|
||||
expect(await value(`return JSON.stringify((await fetch("https://a.test/")).headers)`)).toBe("{}")
|
||||
})
|
||||
|
||||
test("a class global is only a function; calling it throws the host TypeError", async () => {
|
||||
const target = CodeMode.make({ extensions: [Extension.make({ name: "cls", globals: { Bag: class Bag {} } })] })
|
||||
expect((await failure(`Bag()`, target)).message).toContain("without")
|
||||
expect((await failure(`new Bag()`, target)).message).toContain("new Bag(...) is not supported")
|
||||
})
|
||||
})
|
||||
|
||||
describe("values are converted at the boundary, never shared", () => {
|
||||
test("the same host instance is the same handle", async () => {
|
||||
expect(
|
||||
await value(`const b = new Bag(); const p = b.pair(); return [p.self === b, p.list[0] === b, keep(b) === b]`),
|
||||
).toEqual([true, true, true])
|
||||
expect(await value(`const p = new Bag().pair(); return p.list[1] instanceof Bag`)).toBe(true)
|
||||
})
|
||||
|
||||
test("plain data passed in is a copy the program cannot change afterwards", async () => {
|
||||
held.length = 0
|
||||
await value(
|
||||
@@ -163,8 +116,12 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect(config).toEqual({ retries: 3, nested: { deep: true } })
|
||||
})
|
||||
|
||||
test("Map and Set contents are converted element-wise, so handles unwrap inside them", async () => {
|
||||
expect(await value(`const b = new Bag(); return first(new Map([["k", b]])) === b`)).toBe(true)
|
||||
test("the same host value returned twice is two program values", async () => {
|
||||
expect(await value(`return settings() === settings()`)).toBe(false)
|
||||
expect(await value(`return keep(settings()) === settings()`)).toBe(false)
|
||||
})
|
||||
|
||||
test("Map and Set contents are converted element-wise", async () => {
|
||||
expect(await value(`return first(new Map([["k", { z: 1 }]]))`)).toEqual({ z: 1 })
|
||||
held.length = 0
|
||||
await value(`const inner = { z: 1 }; keep(new Set([inner])); inner.z = 2`)
|
||||
@@ -214,6 +171,13 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect(Object.keys(held[0] as object)).toEqual([])
|
||||
})
|
||||
|
||||
test("an Error with an unknown name crosses as a plain Error", async () => {
|
||||
held.length = 0
|
||||
await value(`const e = new Error("x"); e.name = "constructor"; keep(e); e.name = "__proto__"; keep(e)`)
|
||||
expect(held[0]).toBeInstanceOf(Error)
|
||||
expect(held[1]).toBeInstanceOf(Error)
|
||||
})
|
||||
|
||||
test("functions, promises, and symbols cannot be passed in", async () => {
|
||||
expect((await failure(`keep(() => 1)`)).message).toContain("Argument 1 to keep contains a function")
|
||||
expect((await failure(`keep(later(1))`)).message).toContain("un-awaited Promise")
|
||||
@@ -228,82 +192,55 @@ describe("values are converted at the boundary, never shared", () => {
|
||||
expect((await failure(`big()`, target)).message).toContain("big produced a bigint")
|
||||
})
|
||||
|
||||
test("an instance of an unexposed class cannot come out", async () => {
|
||||
expect((await failure(`new Bag().detached()`)).message).toContain("produced a Other, which the program cannot hold")
|
||||
})
|
||||
|
||||
test("a getter must be synchronous", async () => {
|
||||
expect((await failure(`new Bag().lazy`)).message).toContain("Bag.prototype.lazy returned a Promise")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the host object behind a handle is unreachable", () => {
|
||||
test("enumeration, spread, and JSON see no own properties", async () => {
|
||||
expect(
|
||||
await value(`const b = new Bag(["a"]); return [Object.keys(b), Object.entries({ ...b }), String(b)]`),
|
||||
).toEqual([[], [], "[object Object]"])
|
||||
})
|
||||
|
||||
test("a handle serializes as {} when returned, stringified, or handed to a tool", async () => {
|
||||
expect(await value(`return new Bag()`)).toEqual({})
|
||||
expect(await value(`return JSON.stringify(new Bag())`)).toBe("{}")
|
||||
const tools = CodeMode.make({
|
||||
extensions: [extension],
|
||||
tools: {
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ v: Schema.Unknown }),
|
||||
output: Schema.Unknown,
|
||||
execute: (input) => Effect.succeed(input.v),
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(await value(`return await tools.echo({ v: new Bag() })`, tools)).toEqual({})
|
||||
})
|
||||
|
||||
test("a method only runs on a handle of its own class", async () => {
|
||||
expect((await failure(`const add = new Bag().add; add("x")`)).message).toContain(
|
||||
"Illegal invocation: Bag.prototype.add called on undefined",
|
||||
)
|
||||
expect((await failure(`const o = { add: Bag.prototype.add }; o.add("x")`)).message).toContain(
|
||||
"called on a data object",
|
||||
)
|
||||
const vault = new Vault()
|
||||
test("a value that cannot come out carries a location like any built-in error, sync or async", async () => {
|
||||
const target = CodeMode.make({
|
||||
extensions: [Extension.make({ name: "vault", globals: { Bag, Vault, vault: () => vault } })],
|
||||
extensions: [
|
||||
Extension.make({ name: "odd", globals: { sym: () => Symbol("s"), later: async () => Symbol("s") } }),
|
||||
],
|
||||
})
|
||||
expect((await failure(`const v = vault(); v.add = Bag.prototype.add; v.add("x")`, target)).message).toContain(
|
||||
"Illegal invocation: Bag.prototype.add called on a Vault",
|
||||
)
|
||||
expect(vault.secrets.size).toBe(0)
|
||||
expect((await failure(`sym()`, target)).location).toEqual((await failure(`JSON.parse("{")`, target)).location)
|
||||
expect((await failure(`await later()`, target)).location).toBeDefined()
|
||||
})
|
||||
|
||||
test("reading an accessor off the prototype itself is an illegal invocation", async () => {
|
||||
expect((await failure(`Bag.prototype.size`)).message).toContain("Illegal invocation")
|
||||
test("a class instance cannot come out", async () => {
|
||||
class Other {}
|
||||
const target = CodeMode.make({
|
||||
extensions: [Extension.make({ name: "odd", globals: { detached: () => new Other() } })],
|
||||
})
|
||||
expect((await failure(`detached()`, target)).message).toContain("produced a Other, which the program cannot hold")
|
||||
})
|
||||
})
|
||||
|
||||
describe("host errors", () => {
|
||||
test("a synchronous throw becomes the matching program error", async () => {
|
||||
expect(await value(`try { new Bag().fail() } catch (e) { return [e instanceof RangeError, e.message] }`)).toEqual([
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "odd",
|
||||
globals: {
|
||||
fail: () => {
|
||||
throw new RangeError("boom")
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
expect(await value(`try { fail() } catch (e) { return [e instanceof RangeError, e.message] }`, target)).toEqual([
|
||||
true,
|
||||
"boom",
|
||||
])
|
||||
})
|
||||
|
||||
test("a thrown or rejected value crosses like a return, so the program catches what was thrown", async () => {
|
||||
expect(
|
||||
await value(
|
||||
`try { await new Bag().reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
|
||||
),
|
||||
).toEqual([true, "bad"])
|
||||
expect(await value(`try { await new Bag().reject("plain") } catch (e) { return e }`)).toBe("plain")
|
||||
const reason = { status: 404, nested: { a: 1 } }
|
||||
const target = CodeMode.make({
|
||||
extensions: [
|
||||
Extension.make({
|
||||
name: "api",
|
||||
globals: {
|
||||
reject: async (reason: unknown) => {
|
||||
throw reason
|
||||
},
|
||||
get: async () => Promise.reject(reason),
|
||||
boom: () => {
|
||||
throw reason
|
||||
@@ -312,6 +249,13 @@ describe("host errors", () => {
|
||||
}),
|
||||
],
|
||||
})
|
||||
expect(
|
||||
await value(
|
||||
`try { await reject(new TypeError("bad")) } catch (e) { return [e instanceof TypeError, e.message] }`,
|
||||
target,
|
||||
),
|
||||
).toEqual([true, "bad"])
|
||||
expect(await value(`try { await reject("plain") } catch (e) { return e }`, target)).toBe("plain")
|
||||
expect(await value(`try { await get() } catch (e) { e.status = 0; return e }`, target)).toEqual({
|
||||
status: 0,
|
||||
nested: { a: 1 },
|
||||
@@ -325,23 +269,45 @@ describe("host errors", () => {
|
||||
describe("configuration", () => {
|
||||
test("extension calls are not tool calls", async () => {
|
||||
const limited = CodeMode.make({ extensions: [extension], limits: { maxToolCalls: 0 } })
|
||||
const result = await Effect.runPromise(limited.execute(`new Bag().add("x"); return await later(1)`))
|
||||
const result = await Effect.runPromise(
|
||||
limited.execute(`(await fetch("https://a.test/")).json(); return await later(1)`),
|
||||
)
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.toolCalls).toEqual([])
|
||||
})
|
||||
|
||||
test("a global must be a class or a function", () => {
|
||||
test("a result handed to a tool is plain data", async () => {
|
||||
const tools = CodeMode.make({
|
||||
extensions: [extension],
|
||||
tools: {
|
||||
echo: Tool.make({
|
||||
description: "Echo",
|
||||
input: Schema.Struct({ v: Schema.Unknown }),
|
||||
output: Schema.Unknown,
|
||||
execute: (input) => Effect.succeed(input.v),
|
||||
}),
|
||||
},
|
||||
})
|
||||
expect(await value(`return await tools.echo({ v: await fetch("https://a.test/") })`, tools)).toEqual({
|
||||
status: 200,
|
||||
ok: true,
|
||||
headers: {},
|
||||
handlers: [null],
|
||||
})
|
||||
})
|
||||
|
||||
test("a global must be a function", () => {
|
||||
expect(() => Extension.make({ name: "bad", globals: { n: 1 as never } })).toThrow(
|
||||
'Extension "bad" global "n" must be a class or a function.',
|
||||
'Extension "bad" global "n" must be a function.',
|
||||
)
|
||||
})
|
||||
|
||||
test("a global may not shadow a built-in or another extension", () => {
|
||||
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: class {} } })] })).toThrow(
|
||||
expect(() => CodeMode.make({ extensions: [Extension.make({ name: "web", globals: { URL: () => 1 } })] })).toThrow(
|
||||
'Extension "web" global "URL" is already defined.',
|
||||
)
|
||||
expect(() =>
|
||||
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { Bag: class {} } })] }),
|
||||
).toThrow('Extension "again" global "Bag" is already defined.')
|
||||
CodeMode.make({ extensions: [extension, Extension.make({ name: "again", globals: { fetch: () => 1 } })] }),
|
||||
).toThrow('Extension "again" global "fetch" is already defined.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -237,7 +237,7 @@ describe("RegExp", () => {
|
||||
).toEqual(["1", "22"])
|
||||
})
|
||||
|
||||
test("lastIndex is writable and exec coerces its stored value", async () => {
|
||||
test("lastIndex is writable and stores a number", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const pattern = /(?:ab|cd)\\d?/g
|
||||
@@ -247,40 +247,112 @@ describe("RegExp", () => {
|
||||
pattern.lastIndex = 0
|
||||
return [stored, match[0], match.index, pattern.lastIndex]
|
||||
`),
|
||||
).toEqual([["12", "string"], "ab4", 17, 0])
|
||||
expect((await error(`delete /a/.lastIndex`)).message).toContain("Cannot delete property 'lastIndex'")
|
||||
).toEqual([[12, "number"], "ab4", 17, 0])
|
||||
// lastIndex is a prototype accessor, so delete is a no-op rather than a TypeError.
|
||||
expect(await value(`const re = /a/; return [delete re.lastIndex, re.lastIndex]`)).toEqual([true, 0])
|
||||
})
|
||||
|
||||
test("exec coerces CodeMode data objects assigned to lastIndex", async () => {
|
||||
test("a non-numeric lastIndex runs from 0; non-global exec and test leave it alone", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const pattern = /a/g
|
||||
pattern.lastIndex = {}
|
||||
const stored = pattern.lastIndex
|
||||
const match = pattern.exec("ba")
|
||||
pattern.lastIndex = 10
|
||||
const missed = pattern.exec("a")
|
||||
return [stored, match.index, pattern.lastIndex, missed]
|
||||
const plain = /a/
|
||||
plain.lastIndex = 5
|
||||
return [match.index, pattern.lastIndex, missed, plain.exec("ba").index, plain.test("ba"), plain.lastIndex]
|
||||
`),
|
||||
).toEqual([{}, 1, 0, null])
|
||||
).toEqual([1, 0, null, 1, true, 5])
|
||||
})
|
||||
|
||||
test("non-global exec and test coerce and preserve lastIndex", async () => {
|
||||
test("String methods read and update lastIndex like exec", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const execPattern = /a/
|
||||
const execIndex = {}
|
||||
execPattern.lastIndex = execIndex
|
||||
const match = execPattern.exec("ba")
|
||||
|
||||
const testPattern = /a/
|
||||
const testIndex = {}
|
||||
testPattern.lastIndex = testIndex
|
||||
const matched = testPattern.test("ba")
|
||||
|
||||
return [match.index, execPattern.lastIndex === execIndex, matched, testPattern.lastIndex === testIndex]
|
||||
const re = /a/y
|
||||
re.exec("aa")
|
||||
re.lastIndex = 0
|
||||
return ["aa".replace(re, "b"), re.lastIndex]
|
||||
`),
|
||||
).toEqual([1, true, true, true])
|
||||
).toEqual(["ba", 1])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.exec("aaa")
|
||||
return ["aaa".match(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([["a", "a", "a"], 0])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["aaa".replace(re, () => "b"), re.lastIndex]
|
||||
`),
|
||||
).toEqual(["bbb", 0])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["aaa".replaceAll(re, "b"), re.lastIndex]
|
||||
`),
|
||||
).toEqual(["bbb", 0])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/y
|
||||
re.lastIndex = 1
|
||||
const m = "baa".match(re)
|
||||
return [m.index, re.lastIndex]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
})
|
||||
|
||||
test("split, search, and matchAll leave lastIndex unchanged like JS", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/y
|
||||
re.lastIndex = 2
|
||||
return ["banana".split(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([["b", "n", "n", ""], 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["banana".search(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([1, 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/y
|
||||
re.lastIndex = 2
|
||||
return ["banana".search(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual([-1, 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/g
|
||||
re.lastIndex = 2
|
||||
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
|
||||
`),
|
||||
).toEqual([[3, 5], 2])
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/gy
|
||||
re.lastIndex = 1
|
||||
return ["banana".matchAll(re).map((m) => m.index), re.lastIndex]
|
||||
`),
|
||||
).toEqual([[1], 1])
|
||||
})
|
||||
|
||||
test("String methods leave lastIndex untouched without g or y", async () => {
|
||||
expect(
|
||||
await value(`
|
||||
const re = /a/
|
||||
re.lastIndex = 5
|
||||
return ["aaa".replace(re, "b"), "aaa".match(re).index, "aaa".split(re), "aaa".search(re), re.lastIndex]
|
||||
`),
|
||||
).toEqual(["baa", 0, ["", "", "", ""], 0, 5])
|
||||
})
|
||||
|
||||
test("an unmatched string pattern returns null", async () => {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.3",
|
||||
"version": "2.0.5",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
@@ -111,6 +111,7 @@
|
||||
"@ff-labs/fff-node": "0.10.5",
|
||||
"@lydell/node-pty": "catalog:",
|
||||
"@modelcontextprotocol/client": "2.0.0",
|
||||
"@modelcontextprotocol/core": "2.0.0",
|
||||
"@opencode-ai/pty": "0.1.13",
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/codemode": "workspace:*",
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
import { Effect, Ref, Schema, Semaphore } from "effect"
|
||||
import { definition, normalizedName } from "../tool/runtime.js"
|
||||
import { CodeModeCatalog } from "./catalog.js"
|
||||
import { CodeModeWeb } from "./web.js"
|
||||
|
||||
const ExecuteFile = Schema.Struct({
|
||||
data: Schema.String,
|
||||
@@ -59,8 +60,8 @@ export type Inventory = {
|
||||
|
||||
// Invariant model-facing guidance; the changing tool catalog is delivered through Instructions.
|
||||
const description = [
|
||||
"Run JavaScript in a confined Code Mode runtime to orchestrate tool calls and compose their results.",
|
||||
"Imports, direct filesystem access, and timers are unavailable. Do not use `fetch`; all external access goes through `tools`.",
|
||||
"Run JavaScript in a confined Code Mode runtime to script tool calls and HTTP requests and compose their results.",
|
||||
"`fetch` is available for HTTP requests. Imports, direct filesystem access, and timers are unavailable; all other external access goes through `tools`.",
|
||||
"Within `{ code }`, the only callable tools are those explicitly listed in the Code Mode catalog instructions or returned by the `search` function. Inside `{ code }`, ignore tools shown outside the Code Mode catalog. They are not available in the Code Mode runtime.",
|
||||
'Call tools through `tools` using only exact paths and signatures from the catalog. Do not infer or normalize tool names; preserve bracket notation such as `tools.<namespace>["tool-name"](input)`.',
|
||||
"Prefer an explicit `return`; if omitted, the final top-level expression becomes the result.",
|
||||
@@ -82,9 +83,9 @@ export const create = (
|
||||
const files = yield* Ref.make<Array<CollectedFiles>>([])
|
||||
const calls = yield* Ref.make<Array<ExecuteCall>>([])
|
||||
const lock = Semaphore.makeUnsafe(1)
|
||||
const updateCalls = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
const record = (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) =>
|
||||
lock.withPermit(
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.flatMap((toolCalls) => context.progress({ toolCalls }))),
|
||||
Ref.updateAndGet(calls, update).pipe(Effect.tap((toolCalls) => context.progress({ toolCalls }))),
|
||||
)
|
||||
const result = yield* runtime(
|
||||
inventory,
|
||||
@@ -103,27 +104,7 @@ export const create = (
|
||||
const text = content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n")
|
||||
return text === "" ? null : text
|
||||
}),
|
||||
{
|
||||
onToolCallStart: ({ index, name, input }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { tool: name, status: "running", ...(shown ? { input: shown } : {}) }
|
||||
return next
|
||||
})
|
||||
},
|
||||
onToolCallEnd: ({ index, name, input, outcome }) => {
|
||||
const shown = displayInput(input)
|
||||
return updateCalls((items) => {
|
||||
const next = [...items]
|
||||
next[index] = {
|
||||
...(items[index] ?? { tool: name, ...(shown ? { input: shown } : {}) }),
|
||||
status: outcome === "success" ? "completed" : "error",
|
||||
}
|
||||
return next
|
||||
})
|
||||
},
|
||||
},
|
||||
progressHooks(record),
|
||||
).execute(code)
|
||||
const toolCalls = yield* Ref.get(calls)
|
||||
const collected = (yield* Ref.get(files))
|
||||
@@ -158,6 +139,42 @@ export const create = (
|
||||
} satisfies Info
|
||||
}
|
||||
|
||||
// Rows appear in start order; the same call object arrives at both hooks, so a call finds its row again.
|
||||
function progressHooks(record: (update: (items: Array<ExecuteCall>) => Array<ExecuteCall>) => Effect.Effect<unknown>) {
|
||||
const rows = new WeakMap<object, number>()
|
||||
const start = (call: object, entry: ExecuteCall) =>
|
||||
record((items) => {
|
||||
rows.set(call, items.length)
|
||||
return [...items, entry]
|
||||
})
|
||||
const settle = (call: object, result: CodeMode.CallResult) => {
|
||||
const index = rows.get(call)
|
||||
if (index === undefined) return Effect.void
|
||||
return record((items) => {
|
||||
const next = [...items]
|
||||
next[index] = { ...items[index], status: result.status === "success" ? "completed" : "error" }
|
||||
return next
|
||||
})
|
||||
}
|
||||
return {
|
||||
"tool.before": (call) => {
|
||||
const shown = displayInput(call.input)
|
||||
return start(call, { tool: call.name, status: "running", ...(shown ? { input: shown } : {}) })
|
||||
},
|
||||
"tool.after": settle,
|
||||
// Only listed extension functions get a row; anything else stays out of the TUI.
|
||||
"extension.before": (call) => {
|
||||
switch (call.name) {
|
||||
case "fetch":
|
||||
return start(call, { tool: call.name, status: "running", input: CodeModeWeb.display(call.args) })
|
||||
default:
|
||||
return Effect.void
|
||||
}
|
||||
},
|
||||
"extension.after": settle,
|
||||
} satisfies CodeMode.Hooks
|
||||
}
|
||||
|
||||
export const catalog = (inventory: Inventory) => {
|
||||
const pinned = new Set(
|
||||
Array.from(inventory.tools.values())
|
||||
@@ -204,7 +221,7 @@ function renderCatalog(root: CatalogNode): ReadonlyArray<CodeModeCatalog.Tool |
|
||||
function runtime(
|
||||
inventory: Inventory,
|
||||
executeTool: (name: string, tool: Info, input: unknown) => Effect.Effect<unknown, unknown>,
|
||||
hooks?: CodeMode.ToolCallHooks,
|
||||
hooks?: CodeMode.Hooks,
|
||||
) {
|
||||
// A path may carry namespace metadata, a callable tool, child tools, or all three.
|
||||
const root: ToolNode = { children: new Map() }
|
||||
@@ -219,7 +236,7 @@ function runtime(
|
||||
})
|
||||
}
|
||||
const tools = renderTools(root)
|
||||
return CodeMode.make<typeof tools>({ tools, ...hooks })
|
||||
return CodeMode.make<typeof tools>({ tools, extensions: [CodeModeWeb.extension], hooks })
|
||||
}
|
||||
|
||||
function getNode<T>(root: Node<T>, path: string) {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
export * as CodeModeWeb from "./web.js"
|
||||
|
||||
import { Extension } from "@opencode/codemode"
|
||||
|
||||
const TIMEOUT_MS = 30_000
|
||||
|
||||
type Init = {
|
||||
readonly method?: string
|
||||
readonly headers?: Record<string, string> | Array<[string, string]>
|
||||
readonly body?: string | Uint8Array<ArrayBuffer> | URLSearchParams
|
||||
}
|
||||
|
||||
const fetch = async (input: string | URL, init: Init = {}) => {
|
||||
const response = await globalThis.fetch(input, {
|
||||
method: init.method,
|
||||
headers: init.headers,
|
||||
body: init.body,
|
||||
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||
})
|
||||
const bytes = await response.bytes()
|
||||
const headers = Object.fromEntries(response.headers)
|
||||
const text = () => new TextDecoder().decode(bytes)
|
||||
return {
|
||||
url: response.url,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
ok: response.ok,
|
||||
redirected: response.redirected,
|
||||
headers: {
|
||||
get: (name: string) => headers[name.toLowerCase()] ?? null,
|
||||
has: (name: string) => name.toLowerCase() in headers,
|
||||
entries: () => Object.entries(headers),
|
||||
},
|
||||
text: async () => text(),
|
||||
json: async () => JSON.parse(text()) as unknown,
|
||||
bytes: async () => bytes,
|
||||
}
|
||||
}
|
||||
|
||||
export const extension = Extension.make({ name: "web", globals: { fetch } })
|
||||
|
||||
/** What to show for a fetch call: its method and URL. */
|
||||
export const display = (args: ReadonlyArray<unknown>) => {
|
||||
const [input, init] = args as [string | URL, Init | undefined]
|
||||
return { method: init?.method?.toUpperCase() ?? "GET", url: String(input) }
|
||||
}
|
||||
@@ -111,7 +111,13 @@ export class CodeRequiredError extends Schema.TaggedError<CodeRequiredError>()("
|
||||
|
||||
export class AuthorizationError extends Schema.TaggedError<AuthorizationError>()("Integration.Authorization", {
|
||||
cause: Schema.Defect(),
|
||||
}) {}
|
||||
}) {
|
||||
override get message() {
|
||||
const cause = this.cause
|
||||
if (cause instanceof Error && cause.message) return cause.message
|
||||
return "Authorization failed"
|
||||
}
|
||||
}
|
||||
|
||||
export class AttemptNotFoundError extends Schema.TaggedError<AttemptNotFoundError>()("Integration.AttemptNotFound", {
|
||||
integrationID: ID,
|
||||
|
||||
@@ -142,9 +142,14 @@ function snapshot(job: Active): Info {
|
||||
}
|
||||
}
|
||||
|
||||
function errorText(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
function errorText(cause: Cause.Cause<unknown>) {
|
||||
const render = (error: Error): string => {
|
||||
const message = error.message || error.name || "Unknown error"
|
||||
if (!(error.cause instanceof Error)) return message
|
||||
const detail = render(error.cause)
|
||||
return detail === message || detail.startsWith(`${message}\n`) ? detail : `${message}\nCaused by: ${detail}`
|
||||
}
|
||||
return Cause.prettyErrors(cause).map(render).join("\n") || "Unknown error"
|
||||
}
|
||||
|
||||
function incrementSession(input: Map<SessionSchema.ID, number>, sessionID: SessionSchema.ID) {
|
||||
@@ -205,7 +210,7 @@ export const make = Effect.gen(function* () {
|
||||
status,
|
||||
completed_at,
|
||||
...(Exit.isSuccess(exit) ? { output: exit.value } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(Cause.squash(exit.cause)) } : {}),
|
||||
...(Exit.isFailure(exit) ? { error: errorText(exit.cause) } : {}),
|
||||
},
|
||||
}
|
||||
if (status !== "cancelled") yield* persistBackground(next)
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
discoverOAuthServerInfo,
|
||||
extractWWWAuthenticateParams,
|
||||
parseErrorResponse,
|
||||
resourceUrlFromServerUrl,
|
||||
UnauthorizedError,
|
||||
type FetchLike,
|
||||
type OAuthClientProvider,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type StoredOAuthClientInformation,
|
||||
type StoredOAuthTokens,
|
||||
} from "@modelcontextprotocol/client"
|
||||
import { OAuthMetadataSchema, OpenIdProviderDiscoveryMetadataSchema } from "@modelcontextprotocol/core"
|
||||
import { Cause, Deferred, Effect } from "effect"
|
||||
import { ConfigMCP } from "@opencode/schema/config/mcp"
|
||||
import { Credential } from "../credential.js"
|
||||
@@ -99,6 +101,25 @@ export const loggedFetch = (fields: { readonly server: string; readonly director
|
||||
return request
|
||||
})
|
||||
|
||||
// A configured authorization server document stands in for RFC 9728 discovery: the SDK reuses this
|
||||
// state instead of probing the resource server, whose well-known path may not exist.
|
||||
export const configuredDiscovery = async (input: {
|
||||
readonly config: typeof ConfigMCP.Remote.Type
|
||||
readonly fetchFn: FetchLike
|
||||
}): Promise<OAuthDiscoveryState | undefined> => {
|
||||
const url = input.config.oauth ? input.config.oauth.auth_server_metadata_url : undefined
|
||||
if (!url) return undefined
|
||||
const response = await input.fetchFn(url, { headers: { accept: "application/json" } })
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status} trying to load OAuth authorization server metadata`)
|
||||
const body = await response.json()
|
||||
const metadata = OAuthMetadataSchema.safeParse(body).data ?? OpenIdProviderDiscoveryMetadataSchema.parse(body)
|
||||
return {
|
||||
authorizationServerUrl: metadata.issuer,
|
||||
authorizationServerMetadata: metadata,
|
||||
resourceMetadata: { resource: resourceUrlFromServerUrl(input.config.url).toString() },
|
||||
}
|
||||
}
|
||||
|
||||
export interface Store {
|
||||
readonly tokens: () => Promise<StoredOAuthTokens | undefined>
|
||||
readonly saveTokens: (tokens: StoredOAuthTokens) => Promise<void>
|
||||
@@ -134,7 +155,10 @@ export const provider = (options: Options): OAuthClientProvider => {
|
||||
let discovery: OAuthDiscoveryState | undefined = options.discovery
|
||||
return {
|
||||
redirectUrl,
|
||||
discoveryState: () => discovery,
|
||||
discoveryState: async () => {
|
||||
discovery ??= await configuredDiscovery({ config: options.config, fetchFn: send })
|
||||
return discovery
|
||||
},
|
||||
saveDiscoveryState: (state) => {
|
||||
discovery = state
|
||||
},
|
||||
@@ -144,7 +168,12 @@ export const provider = (options: Options): OAuthClientProvider => {
|
||||
if (!resource) return identity
|
||||
if (!checkResourceAllowed({ requestedResource: identity, configuredResource: resource }))
|
||||
throw new Error(`Protected resource ${resource} does not cover ${identity}`)
|
||||
return new URL(resource)
|
||||
const canonical = new URL(resource)
|
||||
// The transport dials the configured URL with extra query parameters and some servers echo
|
||||
// that back as the resource. Query is transport detail, not identity: the token stays bound
|
||||
// to the configured URL so a later refresh names the same resource the login did.
|
||||
if (canonical.origin === identity.origin && canonical.pathname === identity.pathname) return identity
|
||||
return canonical
|
||||
},
|
||||
...(options.clientMetadataUrl ? { clientMetadataUrl: options.clientMetadataUrl } : {}),
|
||||
...(redirect ? { state: () => redirect.state } : {}),
|
||||
@@ -388,7 +417,9 @@ export const authorize = (input: {
|
||||
// CIMD needs the server to advertise it and accept public clients, and our published document only
|
||||
// lists the loopback redirect; a configured client_id always wins.
|
||||
const discovery = yield* Effect.tryPromise({
|
||||
try: () => discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
|
||||
try: async () =>
|
||||
(await configuredDiscovery({ config: input.config, fetchFn })) ??
|
||||
discoverOAuthServerInfo(input.config.url, { resourceMetadataUrl, fetchFn }),
|
||||
catch: (error) => (error instanceof Error ? error : new Error(String(error))),
|
||||
})
|
||||
const cimd =
|
||||
|
||||
+76
-57
@@ -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]
|
||||
}),
|
||||
),
|
||||
]
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
export * as IdentityPlugin from "./identity.js"
|
||||
|
||||
import { SystemPart } from "@opencode/ai"
|
||||
import { define } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Model } from "@opencode/schema/model"
|
||||
import { Effect } from "effect"
|
||||
|
||||
export function identity(model: { readonly name: string; readonly ref: Model.Ref }) {
|
||||
return [
|
||||
"# Your Model",
|
||||
`- Name: ${model.name}`,
|
||||
`- Provider ID: ${model.ref.providerID}`,
|
||||
`- Model ID: ${model.ref.id}`,
|
||||
].join("\n")
|
||||
}
|
||||
|
||||
export const Plugin = define({
|
||||
id: "opencode.prompt.identity",
|
||||
effect: Effect.fn("IdentityPlugin")(function* (ctx) {
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const model =
|
||||
(yield* ctx.model.list()).data.find(
|
||||
(model) => model.providerID === event.model.providerID && model.id === event.model.id,
|
||||
) ?? Model.Info.default(event.model.providerID, event.model.id)
|
||||
event.system.splice(1, 0, SystemPart.make(identity({ name: model.name, ref: event.model })))
|
||||
}).pipe(Effect.catch(() => Effect.void))
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}),
|
||||
})
|
||||
@@ -84,6 +84,7 @@ import { WriteTool } from "../tool/plugin/write.js"
|
||||
import { AgentPlugin } from "./agent.js"
|
||||
import BrowserPlugin from "@opencode/plugin-browser"
|
||||
import { CommandPlugin } from "./command.js"
|
||||
import { IdentityPlugin } from "./identity.js"
|
||||
import { PlanPlugin } from "./plan.js"
|
||||
import { ModelsDevPlugin } from "./models-dev.js"
|
||||
import { McpCodeModeExclusionPlugin } from "./mcp-codemode-exclusion.js"
|
||||
@@ -216,6 +217,7 @@ const pre = [
|
||||
PatchTool.Plugin,
|
||||
// Render model prompts after the patch plugin selects the available editing tools.
|
||||
...OptimizePlugin.Plugins,
|
||||
IdentityPlugin.Plugin,
|
||||
EditTool.Plugin,
|
||||
GlobTool.Plugin,
|
||||
GrepTool.Plugin,
|
||||
|
||||
@@ -167,7 +167,8 @@ export const AzurePlugin = define({
|
||||
draft.settings.baseURL,
|
||||
resolveResourceName(draft.settings, resourceName) ?? resourceName,
|
||||
)
|
||||
if (responsesWebSocketCapable(item.provider, draft)) draft.transport = "websocket"
|
||||
if (responsesWebSocketCapable(item.provider, draft))
|
||||
draft.transport = item.provider.transport ?? "websocket"
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +270,7 @@ export const OpenAIPlugin = define({
|
||||
// ChatGPT-plan tokens only authorize codex-eligible models, and the
|
||||
// subscription covers usage, so hide the rest and zero the cost.
|
||||
models.update(model.providerID, model.id, (draft) => {
|
||||
draft.transport = "websocket"
|
||||
draft.transport = models.provider.get(model.providerID)?.provider.transport ?? "websocket"
|
||||
if (!chatgpt) return
|
||||
if (Schema.is(Schema.Struct({ mode: Schema.Literal("pro") }))(draft.body?.reasoning)) {
|
||||
draft.enabled = false
|
||||
|
||||
@@ -311,7 +311,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Scope
|
||||
|
||||
// Console config can change independently of local credential activity, so re-fetch
|
||||
// periodically and only rebuild the catalog and search providers when the snapshot differs.
|
||||
yield* Effect.sleep(Duration.minutes(10)).pipe(
|
||||
yield* Effect.sleep(Duration.minutes(1)).pipe(
|
||||
Effect.andThen(
|
||||
loading.withPermit(
|
||||
load().pipe(Effect.flatMap((next) => (Equal.equals(snapshot, next) ? Effect.void : apply(next)))),
|
||||
|
||||
@@ -98,7 +98,7 @@ export const XAIPlugin = define({
|
||||
yield* ctx.model.transform((models) => {
|
||||
for (const model of models.list(providerID)) {
|
||||
models.update(providerID, model.id, (draft) => {
|
||||
draft.transport = "websocket"
|
||||
draft.transport = models.provider.get(providerID)?.provider.transport ?? "websocket"
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
You are an AI agent powered by OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
You are an AI agent running in OpenCode, a coding agent harness. Help the user accomplish their goals using the tools you have available.
|
||||
|
||||
# Harness
|
||||
- Responses are rendered as GitHub-flavored Markdown.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user