mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 04:46:23 +00:00
Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b8a7f03ced | ||
|
|
e16c56fe8b | ||
|
|
42ff564913 | ||
|
|
195158c34c | ||
|
|
7a31b5c0f7 | ||
|
|
fb3c10ca66 | ||
|
|
c43cfccc4e | ||
|
|
c5aa7d7e34 | ||
|
|
9c8a4ea4ff | ||
|
|
c82340a97b | ||
|
|
dbc63955b0 | ||
|
|
2816d1c849 | ||
|
|
61d812fcd6 | ||
|
|
e47b9b5453 | ||
|
|
21dac524f3 | ||
|
|
3dde11c392 | ||
|
|
0643a5638e | ||
|
|
81523d4a84 |
@@ -0,0 +1,261 @@
|
||||
# V2 HTTP API audit checklist
|
||||
|
||||
**Source:** `packages/protocol/openapi.json`
|
||||
**Current endpoint count:** 143
|
||||
**Last regenerated:** 2026-09-13
|
||||
|
||||
## How to use this checklist
|
||||
|
||||
Review endpoints in document order. For each endpoint, select one disposition and capture rationale or follow-up work in Notes. Mark **Reviewed** only after the disposition is agreed.
|
||||
|
||||
### Review criteria
|
||||
|
||||
- Resource and operation naming
|
||||
- HTTP method and idempotency
|
||||
- Request parameters and location scope
|
||||
- Response shape and error taxonomy
|
||||
- Authentication and authorization
|
||||
- Current production consumers
|
||||
- Stability level: public, experimental, or internal
|
||||
- Whether the generated client API is intuitive
|
||||
|
||||
### Disposition legend
|
||||
|
||||
- **Keep:** ship unchanged as a supported V2 API
|
||||
- **Change:** retain after a defined contract change
|
||||
- **Remove:** exclude from the official V2 API
|
||||
- **Experimental-only:** retain outside the stable API commitment
|
||||
|
||||
## Progress
|
||||
|
||||
- [ ] Group 1: Foundation and placement (7)
|
||||
- [ ] Group 2: Configuration and capability catalogs (17)
|
||||
- [ ] 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)
|
||||
|
||||
## Resolved during audit
|
||||
|
||||
### [x] `GET /api/project/current`
|
||||
|
||||
- **Decision:** Remove
|
||||
- **Replacement:** `GET /api/location`, using `project` from the response.
|
||||
- **Notes:** The endpoint duplicated `Location.Info.project`; production callers were migrated.
|
||||
|
||||
## Group 1: Foundation and placement
|
||||
|
||||
**Endpoints:** 7
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [x] 001 | `GET` | `/api/health` | `health.get` | Change | Keep path; rename operation to `health.get`; remove redundant `healthy` response field. |
|
||||
| [ ] 002 | `GET` | `/api/server` | `server.get` | | |
|
||||
| [ ] 003 | `GET` | `/api/location` | `location.get` | | |
|
||||
| [ ] 004 | `GET` | `/api/project` | `project.list` | | |
|
||||
| [ ] 005 | `PATCH` | `/api/project/{projectID}` | `project.update` | | |
|
||||
| [ ] 006 | `POST` | `/api/workspace` | `workspace.create` | Proposed remove | Awaiting feedback in `#core`. |
|
||||
| [ ] 007 | `DELETE` | `/api/workspace/{workspaceID}` | `workspace.destroy` | Proposed remove | Awaiting feedback in `#core`. |
|
||||
|
||||
## Group 2: Configuration and capability catalogs
|
||||
|
||||
**Endpoints:** 17
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 008 | `GET` | `/api/agent` | `agent.list` | | |
|
||||
| [ ] 009 | `GET` | `/api/agent/{agentID}` | `agent.get` | | |
|
||||
| [ ] 010 | `GET` | `/api/plugin` | `plugin.list` | | |
|
||||
| [ ] 011 | `POST` | `/api/plugin/await-activation` | `plugin.awaitActivation` | | |
|
||||
| [ ] 012 | `POST` | `/api/plugin/check` | `plugin.check` | | |
|
||||
| [ ] 013 | `POST` | `/api/plugin/update` | `plugin.update` | | |
|
||||
| [ ] 014 | `GET` | `/api/model` | `model.list` | | |
|
||||
| [ ] 015 | `GET` | `/api/model/default` | `model.default` | | |
|
||||
| [ ] 016 | `GET` | `/api/provider` | `provider.list` | | |
|
||||
| [ ] 017 | `GET` | `/api/provider/{providerID}` | `provider.get` | | |
|
||||
| [ ] 018 | `GET` | `/api/command` | `command.list` | | |
|
||||
| [ ] 019 | `GET` | `/api/skill` | `skill.list` | | |
|
||||
| [ ] 020 | `GET` | `/api/reference` | `reference.list` | | |
|
||||
| [ ] 021 | `GET` | `/api/config` | `config.get` | | |
|
||||
| [ ] 022 | `GET` | `/api/config/preferences` | `config.preferences` | | |
|
||||
| [ ] 023 | `PATCH` | `/api/config/preferences` | `config.updatePreferences` | | |
|
||||
| [ ] 024 | `GET` | `/api/config/shell` | `config.shells` | | |
|
||||
|
||||
## Group 3: Credentials, integrations, MCP, and web search
|
||||
|
||||
**Endpoints:** 22
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 025 | `GET` | `/api/integration` | `integration.list` | | |
|
||||
| [ ] 026 | `GET` | `/api/integration/{integrationID}` | `integration.get` | | |
|
||||
| [ ] 027 | `POST` | `/api/experimental/integration/wellknown` | `experimental.integration.wellknown.add` | | |
|
||||
| [ ] 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` | | |
|
||||
| [ ] 036 | `GET` | `/api/mcp` | `mcp.list` | | |
|
||||
| [ ] 037 | `PUT` | `/api/mcp/{server}` | `mcp.add` | | |
|
||||
| [ ] 038 | `DELETE` | `/api/mcp/{server}` | `mcp.remove` | | |
|
||||
| [ ] 039 | `POST` | `/api/mcp/{server}/connect` | `mcp.connect` | | |
|
||||
| [ ] 040 | `POST` | `/api/mcp/{server}/disconnect` | `mcp.disconnect` | | |
|
||||
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | |
|
||||
| [ ] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | | |
|
||||
| [ ] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | | |
|
||||
| [ ] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | | |
|
||||
| [ ] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | | |
|
||||
| [ ] 046 | `POST` | `/api/websearch` | `websearch.query` | | |
|
||||
|
||||
## Group 4: Session lifecycle
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 047 | `GET` | `/api/session` | `session.list` | | |
|
||||
| [ ] 048 | `POST` | `/api/session` | `session.create` | | |
|
||||
| [ ] 049 | `GET` | `/api/session/stats` | `session.stats` | | |
|
||||
| [ ] 050 | `GET` | `/api/session/active` | `session.active` | | |
|
||||
| [ ] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | | |
|
||||
| [ ] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | | |
|
||||
| [ ] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | | |
|
||||
| [ ] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | | |
|
||||
| [ ] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | | |
|
||||
| [ ] 056 | `POST` | `/api/session/{sessionID}/rename` | `session.rename` | | |
|
||||
| [ ] 057 | `POST` | `/api/session/{sessionID}/move` | `session.move` | | |
|
||||
| [ ] 058 | `POST` | `/api/session/{sessionID}/background` | `session.background` | | |
|
||||
|
||||
## Group 5: Session execution and inputs
|
||||
|
||||
**Endpoints:** 11
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 059 | `POST` | `/api/session/{sessionID}/prompt` | `session.prompt` | | |
|
||||
| [ ] 060 | `POST` | `/api/session/{sessionID}/command` | `session.command` | | |
|
||||
| [ ] 061 | `POST` | `/api/session/{sessionID}/skill` | `session.skill` | | |
|
||||
| [ ] 062 | `POST` | `/api/session/{sessionID}/synthetic` | `session.synthetic` | | |
|
||||
| [ ] 063 | `POST` | `/api/session/{sessionID}/shell` | `session.shell` | | |
|
||||
| [ ] 064 | `POST` | `/api/session/{sessionID}/compact` | `session.compact` | | |
|
||||
| [ ] 065 | `POST` | `/api/session/{sessionID}/wait` | `session.wait` | | |
|
||||
| [ ] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | | |
|
||||
| [ ] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | | |
|
||||
| [ ] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | | |
|
||||
| [ ] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | | |
|
||||
|
||||
## Group 6: Session history and recovery
|
||||
|
||||
**Endpoints:** 13
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 070 | `POST` | `/api/session/import` | `session.import` | | |
|
||||
| [ ] 071 | `GET` | `/api/session/{sessionID}/export` | `session.export` | | |
|
||||
| [ ] 072 | `POST` | `/api/session/{sessionID}/revert/stage` | `session.revert.stage` | | |
|
||||
| [ ] 073 | `POST` | `/api/session/{sessionID}/revert/clear` | `session.revert.clear` | | |
|
||||
| [ ] 074 | `POST` | `/api/session/{sessionID}/revert/commit` | `session.revert.commit` | | |
|
||||
| [ ] 075 | `GET` | `/api/session/{sessionID}/context` | `session.context` | | |
|
||||
| [ ] 076 | `GET` | `/api/session/{sessionID}/diff` | `session.diff` | | |
|
||||
| [ ] 077 | `GET` | `/api/session/{sessionID}/instructions/entries` | `session.instructions.entry.list` | | |
|
||||
| [ ] 078 | `PUT` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.put` | | |
|
||||
| [ ] 079 | `DELETE` | `/api/session/{sessionID}/instructions/entries/{key}` | `session.instructions.entry.remove` | | |
|
||||
| [ ] 080 | `GET` | `/api/experimental/session/{sessionID}/log` | `session.log` | | |
|
||||
| [ ] 081 | `GET` | `/api/session/{sessionID}/message/{messageID}` | `session.message` | | |
|
||||
| [ ] 082 | `GET` | `/api/session/{sessionID}/message` | `message.list` | | |
|
||||
|
||||
## Group 7: Inbox, permissions, and forms
|
||||
|
||||
**Endpoints:** 19
|
||||
|
||||
| Done | Method | Path | Operation ID | Decision | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| [ ] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | | |
|
||||
| [ ] 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` | | |
|
||||
|
||||
## Group 8: Filesystem, worktrees, and VCS
|
||||
|
||||
**Endpoints:** 12
|
||||
|
||||
| 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` | | |
|
||||
|
||||
## Group 9: PTYs, persistent terminals, and shells
|
||||
|
||||
**Endpoints:** 24
|
||||
|
||||
| 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` | | |
|
||||
| [ ] 121 | `GET` | `/api/experimental/session/{sessionID}/terminal/read` | `server.experimental.persistentPty.read` | | |
|
||||
| [ ] 122 | `GET` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.list` | | |
|
||||
| [ ] 123 | `POST` | `/api/experimental/session/{sessionID}/terminal` | `server.experimental.persistentPty.create` | | |
|
||||
| [ ] 124 | `POST` | `/api/experimental/persistent-pty/shutdown` | `server.experimental.persistentPty.shutdown` | | |
|
||||
| [ ] 125 | `POST` | `/api/experimental/persistent-pty/handoff` | `server.experimental.persistentPty.handoff` | | |
|
||||
| [ ] 126 | `GET` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.get` | | |
|
||||
| [ ] 127 | `PUT` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.update` | | |
|
||||
| [ ] 128 | `DELETE` | `/api/experimental/persistent-pty/{ptyID}` | `server.experimental.persistentPty.remove` | | |
|
||||
| [ ] 129 | `GET` | `/api/experimental/persistent-pty/{ptyID}/snapshot` | `server.experimental.persistentPty.snapshot` | | |
|
||||
| [ ] 130 | `POST` | `/api/experimental/persistent-pty/{ptyID}/connect-token` | `server.experimental.persistentPty.connectToken` | | |
|
||||
| [ ] 131 | `GET` | `/api/experimental/persistent-pty/{ptyID}/connect` | `persistentPty.connect` | | |
|
||||
| [ ] 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` | | |
|
||||
|
||||
## Group 10: Events, RPC, and experimental operations
|
||||
|
||||
**Endpoints:** 6
|
||||
|
||||
| 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` | | |
|
||||
| [ ] 143 | `GET` | `/api/experimental/migration/v1` | `experimental.migration.v1.status` | | |
|
||||
@@ -31,7 +31,7 @@
|
||||
},
|
||||
"packages/ai": {
|
||||
"name": "@opencode/ai",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/credential-providers": "3.1057.0",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -53,7 +53,7 @@
|
||||
},
|
||||
"packages/app": {
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@corvu/drawer": "catalog:",
|
||||
"@dnd-kit/abstract": "0.5.0",
|
||||
@@ -112,7 +112,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"bin": {
|
||||
"opencode2": "./bin/opencode2.cjs",
|
||||
},
|
||||
@@ -176,7 +176,7 @@
|
||||
},
|
||||
"packages/client": {
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/protocol": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -202,7 +202,7 @@
|
||||
},
|
||||
"packages/codemode": {
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"acorn": "8.15.0",
|
||||
"effect": "catalog:",
|
||||
@@ -216,7 +216,7 @@
|
||||
},
|
||||
"packages/console/app": {
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@ibm/plex": "6.4.1",
|
||||
@@ -252,7 +252,7 @@
|
||||
},
|
||||
"packages/console/core": {
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sts": "3.782.0",
|
||||
"@jsx-email/render": "1.1.1",
|
||||
@@ -279,7 +279,7 @@
|
||||
},
|
||||
"packages/console/function": {
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -296,7 +296,7 @@
|
||||
},
|
||||
"packages/console/mail": {
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
@@ -320,7 +320,7 @@
|
||||
},
|
||||
"packages/console/support": {
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@cloudflare/vite-plugin": "1.15.2",
|
||||
"@opencode/console-core": "workspace:*",
|
||||
@@ -340,7 +340,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@opencode/core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/anthropic": "3.0.82",
|
||||
@@ -412,7 +412,7 @@
|
||||
},
|
||||
"packages/desktop": {
|
||||
"name": "@opencode/desktop",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@zip.js/zip.js": "2.7.62",
|
||||
"electron-context-menu": "4.1.2",
|
||||
@@ -464,7 +464,7 @@
|
||||
},
|
||||
"packages/enterprise": {
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@hono/standard-validator": "catalog:",
|
||||
"@opencode-ai/sdk": "1.18.21",
|
||||
@@ -501,7 +501,7 @@
|
||||
},
|
||||
"packages/function": {
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@octokit/auth-app": "8.0.1",
|
||||
"@octokit/rest": "catalog:",
|
||||
@@ -517,7 +517,7 @@
|
||||
},
|
||||
"packages/http-recorder": {
|
||||
"name": "@opencode/http-recorder",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node-shared": "4.0.0-rc.112",
|
||||
},
|
||||
@@ -536,7 +536,7 @@
|
||||
},
|
||||
"packages/httpapi-codegen": {
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"effect": "catalog:",
|
||||
"prettier": "3.6.2",
|
||||
@@ -549,7 +549,7 @@
|
||||
},
|
||||
"packages/latex": {
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -563,7 +563,7 @@
|
||||
},
|
||||
"packages/merman": {
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opentui/core": "catalog:",
|
||||
@@ -578,7 +578,7 @@
|
||||
},
|
||||
"packages/plugin": {
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ai-sdk/provider": "3.0.8",
|
||||
"@opencode/ai": "workspace:*",
|
||||
@@ -617,7 +617,7 @@
|
||||
},
|
||||
"packages/plugin-browser": {
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/plugin": "workspace:*",
|
||||
"@opencode/schema": "workspace:*",
|
||||
@@ -647,7 +647,7 @@
|
||||
},
|
||||
"packages/protocol": {
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/schema": "workspace:*",
|
||||
"effect": "catalog:",
|
||||
@@ -662,7 +662,7 @@
|
||||
},
|
||||
"packages/schema": {
|
||||
"name": "@opencode/schema",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -686,7 +686,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@opencode/sdk",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -707,7 +707,7 @@
|
||||
},
|
||||
"packages/server": {
|
||||
"name": "@opencode/server",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/platform-node": "catalog:",
|
||||
"@effect/platform-node-shared": "catalog:",
|
||||
@@ -729,7 +729,7 @@
|
||||
},
|
||||
"packages/session-ui": {
|
||||
"name": "@opencode/session-ui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@opencode/client": "workspace:*",
|
||||
@@ -764,7 +764,7 @@
|
||||
},
|
||||
"packages/simulation": {
|
||||
"name": "@opencode/simulation",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/ai": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -784,7 +784,7 @@
|
||||
},
|
||||
"packages/stats/app": {
|
||||
"name": "@opencode/stats-app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@ibm/plex": "6.4.1",
|
||||
"@kobalte/core": "catalog:",
|
||||
@@ -818,7 +818,7 @@
|
||||
},
|
||||
"packages/stats/core": {
|
||||
"name": "@opencode/stats-core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-athena": "3.933.0",
|
||||
"@planetscale/database": "1.19.0",
|
||||
@@ -837,7 +837,7 @@
|
||||
},
|
||||
"packages/stats/server": {
|
||||
"name": "@opencode/stats-server",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-firehose": "3.933.0",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -883,7 +883,7 @@
|
||||
},
|
||||
"packages/theme": {
|
||||
"name": "@opencode/theme",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opentui/core": "catalog:",
|
||||
"effect": "catalog:",
|
||||
@@ -897,7 +897,7 @@
|
||||
},
|
||||
"packages/tui": {
|
||||
"name": "@opencode/tui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@opencode/client": "workspace:*",
|
||||
"@opencode/core": "workspace:*",
|
||||
@@ -932,7 +932,7 @@
|
||||
},
|
||||
"packages/ui": {
|
||||
"name": "@opencode/ui",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@kobalte/core": "catalog:",
|
||||
"@pierre/diffs": "catalog:",
|
||||
@@ -967,7 +967,7 @@
|
||||
},
|
||||
"packages/util": {
|
||||
"name": "@opencode/util",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@effect/opentelemetry": "catalog:",
|
||||
"@effect/platform-node": "catalog:",
|
||||
@@ -1000,7 +1000,7 @@
|
||||
},
|
||||
"packages/web": {
|
||||
"name": "@opencode/web",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@astrojs/cloudflare": "12.6.3",
|
||||
"@astrojs/markdown-remark": "6.3.1",
|
||||
@@ -1041,7 +1041,7 @@
|
||||
},
|
||||
"services/update": {
|
||||
"name": "@opencode/update",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"jose": "6.0.11",
|
||||
"semver": "catalog:",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-QJn59dTbxspHl35vGxs6akynlfaWaPqvbMNsi0EqnbM=",
|
||||
"aarch64-linux": "sha256-wa54uUIAth/ZOyejZ/TfU0zZdkhNirwq7yK1218jhBQ=",
|
||||
"aarch64-darwin": "sha256-VvsfVdtxcVijCvlrWpBUkgmkYFoXX7QiXM0Fhz1t2VE=",
|
||||
"x86_64-darwin": "sha256-IxYFBG5I+znXcTVqrAWKhG2qvVtjM+Amp68xIGKZogE="
|
||||
"x86_64-linux": "sha256-euVUyj0CzjCA1nYbN2vKctEPzLkUlNGTK2dMNbackqM=",
|
||||
"aarch64-linux": "sha256-qQkjqaxpjAae+rohoWI601QnrgKYghJ+ttqeiQBTwCM=",
|
||||
"aarch64-darwin": "sha256-HYWs31TJlDZsDBNmbPARo16r7zNKy9x840uHGcUMYsk=",
|
||||
"x86_64-darwin": "sha256-89FOrX813FENk3u8RAHCfyD7voaZWW++Z4Gpa3SkOJs="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "opencode",
|
||||
"description": "AI-powered development tool",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "bun@1.4.2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/ai",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -25,6 +25,7 @@ import { BedrockAuth } from "./utils/bedrock-auth.js"
|
||||
import { BedrockCache } from "./utils/bedrock-cache.js"
|
||||
import { BedrockMedia } from "./utils/bedrock-media.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolSchemaProjection } from "./utils/tool-schema.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
@@ -279,15 +280,19 @@ const removeEmptyToolInputKeys = (input: unknown): unknown => {
|
||||
)
|
||||
}
|
||||
|
||||
const lowerToolCall = (part: ToolCallPart): BedrockToolUseBlock => ({
|
||||
const lowerToolCall = (part: ToolCallPart, normalizeID: (id: string) => string): BedrockToolUseBlock => ({
|
||||
toolUse: {
|
||||
toolUseId: part.id,
|
||||
name: part.name,
|
||||
toolUseId: normalizeID(part.id),
|
||||
// Models can emit names that Converse rejects when replayed in history.
|
||||
name: part.name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64) || "_",
|
||||
input: removeEmptyToolInputKeys(part.input),
|
||||
},
|
||||
})
|
||||
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (part: ToolResultPart) {
|
||||
const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
) {
|
||||
if (part.result.type === "text" || part.result.type === "error")
|
||||
return [{ text: ProviderShared.toolResultText(part) }]
|
||||
if (part.result.type === "json") return [{ json: part.result.value }]
|
||||
@@ -298,22 +303,29 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
|
||||
content.push({ text: item.text })
|
||||
continue
|
||||
}
|
||||
const media = yield* BedrockMedia.lower({
|
||||
type: "media",
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
})
|
||||
content.push(media)
|
||||
const media = yield* BedrockMedia.lower(
|
||||
{
|
||||
type: "media",
|
||||
mediaType: item.mime,
|
||||
data: item.uri,
|
||||
filename: item.name,
|
||||
},
|
||||
documentNames,
|
||||
)
|
||||
content.push(...media)
|
||||
}
|
||||
return content
|
||||
})
|
||||
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (part: ToolResultPart) {
|
||||
const lowerToolResult = Effect.fn("BedrockConverse.lowerToolResult")(function* (
|
||||
part: ToolResultPart,
|
||||
documentNames: Set<string>,
|
||||
normalizeID: (id: string) => string,
|
||||
) {
|
||||
return {
|
||||
toolResult: {
|
||||
toolUseId: part.id,
|
||||
content: yield* lowerToolResultContent(part),
|
||||
toolUseId: normalizeID(part.id),
|
||||
content: yield* lowerToolResultContent(part, documentNames),
|
||||
status: part.result.type === "error" ? "error" : "success",
|
||||
},
|
||||
} satisfies BedrockToolResultBlock
|
||||
@@ -324,6 +336,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
breakpoints: BedrockCache.Breakpoints,
|
||||
) {
|
||||
const messages: BedrockMessage[] = []
|
||||
const documentNames = new Set<string>()
|
||||
// Mistral can reject replay IDs even when they satisfy Converse's broader ID syntax.
|
||||
const normalizeID = request.model.id.includes("mistral.") ? MistralToolID.normalizer(request) : (id: string) => id
|
||||
const providerMetadataKey = request.model.route.providerMetadataKey ?? String(request.model.provider)
|
||||
|
||||
for (const message of request.messages) {
|
||||
@@ -347,7 +362,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "media") {
|
||||
content.push(yield* BedrockMedia.lower(part))
|
||||
content.push(...(yield* BedrockMedia.lower(part, documentNames)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -388,7 +403,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
content.push(lowerToolCall(part))
|
||||
content.push(lowerToolCall(part, normalizeID))
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -400,7 +415,7 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
|
||||
for (const part of message.content) {
|
||||
if (!ProviderShared.supportsContent(part, ["tool-result"]))
|
||||
return yield* ProviderShared.unsupportedContent("Bedrock Converse", "tool", ["tool-result"])
|
||||
content.push(yield* lowerToolResult(part))
|
||||
content.push(yield* lowerToolResult(part, documentNames, normalizeID))
|
||||
const cachePoint = BedrockCache.block(breakpoints, part.cache)
|
||||
if (cachePoint) content.push(cachePoint)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,11 @@ import {
|
||||
import { classifyProviderFailure } from "../provider-error.js"
|
||||
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
|
||||
import { Lifecycle } from "./utils/lifecycle.js"
|
||||
import { MistralToolID } from "./utils/mistral-tool-id.js"
|
||||
import { ToolStream } from "./utils/tool-stream.js"
|
||||
|
||||
const ADAPTER = "mistral-chat"
|
||||
const DONE = "[DONE]" as const
|
||||
const TOOL_ID = /^[A-Za-z0-9]{9}$/
|
||||
export const DEFAULT_BASE_URL = "https://api.mistral.ai/v1"
|
||||
export const PATH = "/chat/completions"
|
||||
|
||||
@@ -223,34 +223,6 @@ const MistralEvent = Schema.StructWithRest(
|
||||
type MistralEvent = Schema.Schema.Type<typeof MistralEvent>
|
||||
const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)])
|
||||
|
||||
const hashID = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
const toolIDNormalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
const used = new Set(ids.filter((id) => TOOL_ID.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (TOOL_ID.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hashID(id)
|
||||
while (used.has(candidate)) candidate = hashID(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl
|
||||
@@ -359,7 +331,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (
|
||||
})
|
||||
|
||||
const lowerMessages = Effect.fn("MistralChat.lowerMessages")(function* (request: LLMRequest) {
|
||||
const normalizeID = toolIDNormalizer(request)
|
||||
const normalizeID = MistralToolID.normalizer(request)
|
||||
const messages: MistralMessage[] =
|
||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||
for (const message of request.messages) {
|
||||
@@ -582,13 +554,13 @@ const appendContent = (
|
||||
}
|
||||
|
||||
const normalizeStreamToolID = (state: ParserState, source: string) => {
|
||||
if (TOOL_ID.test(source))
|
||||
if (MistralToolID.valid.test(source))
|
||||
return { id: source, state: { ...state, usedToolIDs: new Set([...state.usedToolIDs, source]) } }
|
||||
const previous = state.toolIDs.get(source)
|
||||
if (previous) return { id: previous, state }
|
||||
let attempt = 0
|
||||
let id = hashID(source)
|
||||
while (state.usedToolIDs.has(id)) id = hashID(`${source}:${++attempt}`)
|
||||
let id = MistralToolID.hash(source)
|
||||
while (state.usedToolIDs.has(id)) id = MistralToolID.hash(`${source}:${++attempt}`)
|
||||
return {
|
||||
id,
|
||||
state: {
|
||||
|
||||
@@ -57,6 +57,25 @@ const documentBlock = (name: string, format: DocumentFormat, bytes: string): Doc
|
||||
},
|
||||
})
|
||||
|
||||
function documentName(filename: string | undefined, names: Set<string>) {
|
||||
const base =
|
||||
(filename ?? "")
|
||||
.replace(/\.[^.]*$/, "")
|
||||
.replace(/[^a-zA-Z0-9 ()[\]-]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 200)
|
||||
.trim() || "document"
|
||||
let name = base
|
||||
// Converse requires labels to be unique across the entire request, including tool results.
|
||||
for (let index = 2; names.has(name); index++) {
|
||||
const suffix = ` ${index}`
|
||||
name = `${base.slice(0, 200 - suffix.length).trimEnd()}${suffix}`
|
||||
}
|
||||
names.add(name)
|
||||
return name
|
||||
}
|
||||
|
||||
const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: MediaPart) {
|
||||
const media = ProviderShared.normalizeMedia(part)
|
||||
const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(
|
||||
@@ -72,19 +91,26 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part: Media
|
||||
// document block. Image MIME types not in `IMAGE_FORMATS` (e.g. `image/svg+xml`)
|
||||
// get an image-specific error so the caller knows it's a format-support issue,
|
||||
// not a kind-detection issue.
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart) {
|
||||
export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart, documentNames: Set<string>) {
|
||||
const mime = part.mediaType.toLowerCase()
|
||||
const imageFormat = IMAGE_FORMATS[mime as keyof typeof IMAGE_FORMATS]
|
||||
if (imageFormat) {
|
||||
return { image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock
|
||||
return [{ image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } } satisfies ImageBlock]
|
||||
}
|
||||
if (mime.startsWith("image/"))
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
|
||||
const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
|
||||
if (documentFormat) {
|
||||
if (!part.filename)
|
||||
return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
|
||||
return documentBlock(part.filename, documentFormat, yield* mediaBase64(part))
|
||||
const name = documentName(part.filename, documentNames)
|
||||
const block = documentBlock(name, documentFormat, yield* mediaBase64(part))
|
||||
return part.filename !== undefined && part.filename !== name
|
||||
? [
|
||||
{
|
||||
text: `Attached file ${ProviderShared.encodeJson(part.filename)} has document label ${ProviderShared.encodeJson(name)}.`,
|
||||
},
|
||||
block,
|
||||
]
|
||||
: [block]
|
||||
}
|
||||
return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { LLMRequest } from "../../schema/index.js"
|
||||
|
||||
export const valid = /^[A-Za-z0-9]{9}$/
|
||||
|
||||
export const hash = (value: string) => {
|
||||
const hash = (seed: number) => {
|
||||
let result = seed
|
||||
for (const char of value) result = Math.imul(result ^ char.charCodeAt(0), 16777619)
|
||||
return (result >>> 0).toString(36)
|
||||
}
|
||||
return `${hash(2166136261).padStart(7, "0")}${hash(2246822519).padStart(7, "0")}`.slice(-9)
|
||||
}
|
||||
|
||||
export const normalizer = (request: LLMRequest) => {
|
||||
const ids = request.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => (part.type === "tool-call" || part.type === "tool-result" ? [part.id] : [])),
|
||||
)
|
||||
// Reserve valid IDs before projecting any history, including IDs encountered later.
|
||||
const used = new Set(ids.filter((id) => valid.test(id)))
|
||||
const normalized = new Map<string, string>()
|
||||
return (id: string) => {
|
||||
if (valid.test(id)) return id
|
||||
const previous = normalized.get(id)
|
||||
if (previous) return previous
|
||||
let attempt = 0
|
||||
let candidate = hash(id)
|
||||
while (used.has(candidate)) candidate = hash(`${id}:${++attempt}`)
|
||||
used.add(candidate)
|
||||
normalized.set(id, candidate)
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
|
||||
export * as MistralToolID from "./mistral-tool-id.js"
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventStreamCodec } from "@smithy/eventstream-codec"
|
||||
import { fromUtf8, toUtf8 } from "@smithy/util-utf8"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Encoding, Ref, Stream } from "effect"
|
||||
import { Effect, Encoding, Ref, Schema, Stream } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import {
|
||||
CacheHint,
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
Message,
|
||||
Tool,
|
||||
ToolCallPart,
|
||||
ToolChoice,
|
||||
ToolDefinition,
|
||||
ToolRuntime,
|
||||
} from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
@@ -394,6 +396,45 @@ describe("Bedrock Converse route", () => {
|
||||
})
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{ name: "browser.tabs.open", expected: "browser_tabs_open" },
|
||||
{ name: "$lookup", expected: "_lookup" },
|
||||
{ name: "", expected: "_" },
|
||||
{ name: " ", expected: "___" },
|
||||
{ name: "a".repeat(65), expected: "a".repeat(64) },
|
||||
{ name: "lookup_123-ABC", expected: "lookup_123-ABC" },
|
||||
{ name: "a".repeat(64), expected: "a".repeat(64) },
|
||||
].forEach((item) => {
|
||||
it.effect(`replays historical tool name ${JSON.stringify(item.name)} within Bedrock constraints`, () =>
|
||||
Effect.gen(function* () {
|
||||
const call = ToolCallPart.make({ id: "call_unknown", name: item.name, input: { query: "weather" } })
|
||||
const error = `No tool named "${item.name}" is currently available. Please use a tool from the available tool list.`
|
||||
const request = LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
tools: [ToolDefinition.make({ name: "execute", description: "Run code", inputSchema: { type: "object" } })],
|
||||
messages: [
|
||||
Message.user("Check the weather"),
|
||||
Message.assistant([call]),
|
||||
Message.tool({ id: call.id, name: call.name, result: error, resultType: "error" }),
|
||||
Message.user("Say OK"),
|
||||
],
|
||||
})
|
||||
const prepared = yield* compileRequest(request)
|
||||
|
||||
expect(prepared.body.messages[1].content).toEqual([
|
||||
{ toolUse: { toolUseId: call.id, name: item.expected, input: call.input } },
|
||||
])
|
||||
expect(prepared.body.messages[2].content).toEqual([
|
||||
{ toolResult: { toolUseId: call.id, content: [{ text: error }], status: "error" } },
|
||||
{ text: "Say OK" },
|
||||
])
|
||||
expect(prepared.body.toolConfig.tools.map((tool) => tool.toolSpec.name)).toEqual(["execute"])
|
||||
expect(request.messages[1].content[0]).toEqual(call)
|
||||
expect(call.name).toBe(item.name)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("removes empty keys recursively from outbound tool inputs without mutating history", () =>
|
||||
Effect.gen(function* () {
|
||||
@@ -797,6 +838,57 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects a provider-emitted dotted name before normalizing its replay", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(
|
||||
Effect.provide(
|
||||
fixedBytes(
|
||||
eventStreamBody(
|
||||
[
|
||||
"contentBlockStart",
|
||||
{ contentBlockIndex: 0, start: { toolUse: { toolUseId: "call_unknown", name: "browser.tabs.open" } } },
|
||||
],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { toolUse: { input: "{}" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "tool_use" }],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
const call = response.toolCalls[0]
|
||||
if (!call) throw new Error("Expected a tool call")
|
||||
expect(call.name).toBe("browser.tabs.open")
|
||||
const dispatched = yield* ToolRuntime.dispatch(
|
||||
{
|
||||
browser_tabs_open: Tool.make({
|
||||
description: "Open a tab",
|
||||
parameters: Schema.Struct({}),
|
||||
success: Schema.String,
|
||||
execute: () => Effect.die("A normalized replay name must not select an executor"),
|
||||
}),
|
||||
},
|
||||
call,
|
||||
)
|
||||
expect(dispatched.result).toEqual({
|
||||
type: "error",
|
||||
value:
|
||||
'No tool named "browser.tabs.open" is currently available. Please use a tool from the available tool list.',
|
||||
})
|
||||
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [response.message, Message.tool({ id: call.id, name: call.name, result: dispatched.result })],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages[0].content).toEqual([
|
||||
{ toolUse: { toolUseId: call.id, name: "browser_tabs_open", input: {} } },
|
||||
])
|
||||
expect(response.toolCalls[0]?.name).toBe("browser.tabs.open")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("ignores tool deltas without an open tool block", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
@@ -1673,29 +1765,141 @@ describe("Bedrock Converse route", () => {
|
||||
role: "user",
|
||||
content: [
|
||||
{ text: "Summarize these documents." },
|
||||
{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
|
||||
{ document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||
{ text: 'Attached file "report.pdf" has document label "report".' },
|
||||
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||
{ text: 'Attached file "data.csv" has document label "data".' },
|
||||
{ document: { format: "csv", name: "data", source: { bytes: "Q1NWREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
;[
|
||||
{
|
||||
label: "filename punctuation",
|
||||
filename: "report_v1.2?.pdf",
|
||||
expected: "report v1 2",
|
||||
duplicate: "report v1 2 2",
|
||||
},
|
||||
{
|
||||
label: "repeated whitespace",
|
||||
filename: " Quarterly\t \n report.txt",
|
||||
expected: "Quarterly report",
|
||||
duplicate: "Quarterly report 2",
|
||||
},
|
||||
{
|
||||
label: "allowed characters",
|
||||
filename: "Report - Final (v2) [2026]",
|
||||
expected: "Report - Final (v2) [2026]",
|
||||
duplicate: "Report - Final (v2) [2026] 2",
|
||||
},
|
||||
{ label: "accented filename", filename: "résumé.pdf", expected: "r sum", duplicate: "r sum 2" },
|
||||
{ label: "non-Latin filename", filename: "報告書.pdf", expected: "document", duplicate: "document 2" },
|
||||
{ label: "missing filename", filename: undefined, expected: "document", duplicate: "document 2" },
|
||||
{ label: "empty filename", filename: "", expected: "document", duplicate: "document 2" },
|
||||
{ label: "blank filename", filename: " \t\n", expected: "document", duplicate: "document 2" },
|
||||
{ label: "extension-only filename", filename: ".pdf", expected: "document", duplicate: "document 2" },
|
||||
{ label: "symbols-only filename", filename: "@@@.pdf", expected: "document", duplicate: "document 2" },
|
||||
{
|
||||
label: "overlong filename",
|
||||
filename: `${"a".repeat(201)}.txt`,
|
||||
expected: "a".repeat(200),
|
||||
duplicate: `${"a".repeat(198)} 2`,
|
||||
},
|
||||
{
|
||||
label: "maximum-length label",
|
||||
filename: "a".repeat(200),
|
||||
expected: "a".repeat(200),
|
||||
duplicate: `${"a".repeat(198)} 2`,
|
||||
},
|
||||
{
|
||||
label: "whitespace at truncation",
|
||||
filename: `${"a".repeat(199)} b.txt`,
|
||||
expected: "a".repeat(199),
|
||||
duplicate: `${"a".repeat(198)} 2`,
|
||||
},
|
||||
].forEach((item) => {
|
||||
it.effect(`normalizes ${item.label} in user and tool-result documents`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Read this document" },
|
||||
{ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: item.filename },
|
||||
]),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_read", name: "read", input: {} })]),
|
||||
Message.tool({
|
||||
id: "call_read",
|
||||
name: "read",
|
||||
result: {
|
||||
type: "content",
|
||||
value: [
|
||||
{ type: "text", text: "Read successfully" },
|
||||
{
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,UERGREFUQQ==",
|
||||
mime: "application/pdf",
|
||||
name: item.filename,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
const original = JSON.stringify(request.messages)
|
||||
const first = yield* compileRequest(request)
|
||||
const second = yield* compileRequest(request)
|
||||
const expected = { format: "pdf", name: item.expected, source: { bytes: "UERGREFUQQ==" } }
|
||||
|
||||
it.effect("requires names for document media", () =>
|
||||
expect(first.body.messages[0].content.find((part) => "document" in part)?.document).toEqual(expected)
|
||||
expect(
|
||||
first.body.messages[2].content[0].toolResult.content.find((part) => "document" in part)?.document,
|
||||
).toEqual({
|
||||
...expected,
|
||||
name: item.duplicate,
|
||||
})
|
||||
expect(second.body).toEqual(first.body)
|
||||
expect(JSON.stringify(request.messages)).toBe(original)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.effect("keeps colliding document labels distinct within a request", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* compileRequest(
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Read these documents" },
|
||||
...["report_v1.txt", "report#v1.txt", "report v1 2.txt", "report v1.txt"].map((filename) => ({
|
||||
type: "media" as const,
|
||||
mediaType: "text/plain",
|
||||
data: "SGVsbG8=",
|
||||
filename,
|
||||
})),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("document media requires a filename")
|
||||
)
|
||||
expect(prepared.body.messages[0].content.slice(1)).toEqual([
|
||||
{ text: 'Attached file "report_v1.txt" has document label "report v1".' },
|
||||
{ document: { format: "txt", name: "report v1", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report#v1.txt" has document label "report v1 2".' },
|
||||
{ document: { format: "txt", name: "report v1 2", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report v1 2.txt" has document label "report v1 2 2".' },
|
||||
{ document: { format: "txt", name: "report v1 2 2", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report v1.txt" has document label "report v1 3".' },
|
||||
{ document: { format: "txt", name: "report v1 3", source: { bytes: "SGVsbG8=" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("passes named document-only messages through for provider validation", () =>
|
||||
it.effect("annotates renamed document-only messages with their original filename", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
@@ -1715,12 +1919,44 @@ describe("Bedrock Converse route", () => {
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
|
||||
content: [
|
||||
{ text: 'Attached file "report.pdf" has document label "report".' },
|
||||
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("quotes original filenames in annotations and omits redundant mappings", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model,
|
||||
cache: "none",
|
||||
messages: [
|
||||
Message.user([
|
||||
{ type: "text", text: "Read these documents" },
|
||||
...["report", undefined, 'report "final"\n.pdf'].map((filename) => ({
|
||||
type: "media" as const,
|
||||
mediaType: "text/plain",
|
||||
data: "SGVsbG8=",
|
||||
filename,
|
||||
})),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages[0].content).toEqual([
|
||||
{ text: "Read these documents" },
|
||||
{ document: { format: "txt", name: "report", source: { bytes: "SGVsbG8=" } } },
|
||||
{ document: { format: "txt", name: "document", source: { bytes: "SGVsbG8=" } } },
|
||||
{ text: 'Attached file "report \\"final\\"\\n.pdf" has document label "report final".' },
|
||||
{ document: { format: "txt", name: "report final", source: { bytes: "SGVsbG8=" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers document media in tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* compileRequest(
|
||||
@@ -1740,7 +1976,7 @@ describe("Bedrock Converse route", () => {
|
||||
type: "file",
|
||||
uri: "data:application/pdf;base64,UERGREFUQQ==",
|
||||
mime: "application/pdf",
|
||||
name: "report",
|
||||
name: "report.pdf",
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -1763,6 +1999,7 @@ describe("Bedrock Converse route", () => {
|
||||
status: "success",
|
||||
content: [
|
||||
{ text: "Read successfully" },
|
||||
{ text: 'Attached file "report.pdf" has document label "report".' },
|
||||
{ document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { LLM, Message, ToolCallPart } from "../../src/index.js"
|
||||
import { AmazonBedrock } from "../../src/providers.js"
|
||||
import { BedrockConverse } from "../../src/protocols/bedrock-converse.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
|
||||
const bedrock = AmazonBedrock.configure({ baseURL: "https://bedrock.test", apiKey: "test-key" })
|
||||
const history = (ids: string[]) => [
|
||||
Message.user("Read every label"),
|
||||
Message.assistant(ids.map((id, index) => ToolCallPart.make({ id, name: "lookup", input: { label: index } }))),
|
||||
...ids.map((id, index) => Message.tool({ id, name: "lookup", result: `value-${index}` })).toReversed(),
|
||||
]
|
||||
|
||||
for (const model of [
|
||||
"mistral.mistral-large-2407-v1:0",
|
||||
"us.mistral.pixtral-large-2502-v1:0",
|
||||
"arn:aws:bedrock:us-east-1::foundation-model/mistral.pixtral-large-2502-v1:0",
|
||||
]) {
|
||||
it.effect(`preserves distinct call/result pairs and history for ${model}`, () =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({ model: bedrock.model(model), messages: history(["a"]), cache: "none" })
|
||||
const first = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(
|
||||
(yield* compileRequest(request)).body,
|
||||
)
|
||||
const reserved = first.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
|
||||
)[0]
|
||||
if (!reserved) throw new Error("Expected projected tool call")
|
||||
const ids = [
|
||||
"a",
|
||||
"b",
|
||||
"tooluse_GQn7COr2AN8bw2oVKYyYzZ",
|
||||
"tooluse_abcdefghi111111111",
|
||||
"tooluse_abcdefghi222222222",
|
||||
"call_other-provider-id",
|
||||
"Ab12Cd34E",
|
||||
reserved,
|
||||
]
|
||||
const messages = history(ids)
|
||||
const before = structuredClone(messages)
|
||||
const input = LLM.request({ model: bedrock.model(model), messages, cache: "none" })
|
||||
const prepared = yield* compileRequest(input)
|
||||
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
|
||||
const calls = body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
|
||||
)
|
||||
const results = body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
|
||||
)
|
||||
expect(calls).toHaveLength(ids.length)
|
||||
expect(new Set(calls).size).toBe(ids.length)
|
||||
calls.forEach((id) => expect(id).toMatch(/^[A-Za-z0-9]{9}$/))
|
||||
expect(results).toEqual(calls.toReversed())
|
||||
expect(calls[0]).not.toBe(reserved)
|
||||
expect(calls.slice(-2)).toEqual(ids.slice(-2))
|
||||
expect((yield* compileRequest(input)).body).toEqual(prepared.body)
|
||||
expect(structuredClone(messages)).toEqual(before)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
it.effect("leaves non-Mistral Bedrock IDs unchanged", () =>
|
||||
Effect.gen(function* () {
|
||||
const ids = ["a", "tooluse_abcdefghi111111111", "tooluse_abcdefghi222222222", "Ab12Cd34E"]
|
||||
const prepared = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: bedrock.model("global.anthropic.claude-haiku-4-5-20251001-v1:0"),
|
||||
messages: history(ids),
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
const body = Schema.decodeUnknownSync(BedrockConverse.protocol.body.schema)(prepared.body)
|
||||
expect(
|
||||
body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolUse" in part ? [part.toolUse.toolUseId] : [])),
|
||||
),
|
||||
).toEqual(ids)
|
||||
expect(
|
||||
body.messages.flatMap((message) =>
|
||||
message.content.flatMap((part) => ("toolResult" in part ? [part.toolResult.toolUseId] : [])),
|
||||
),
|
||||
).toEqual(ids.toReversed())
|
||||
}),
|
||||
)
|
||||
@@ -76,7 +76,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: current.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: current.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
if (url.pathname === "/api/project") {
|
||||
const project = {
|
||||
id: current.projectID,
|
||||
canonical: current.directory,
|
||||
@@ -84,7 +84,7 @@ async function mockServers(page: Page, requests: string[]) {
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(route, url.pathname === "/api/project" ? [project] : { id: project.id, directory: current.directory })
|
||||
return json(route, [project])
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -248,7 +248,7 @@ for (const direction of ["ltr", "rtl"]) {
|
||||
expect(messageAfter).toEqual(messageBefore)
|
||||
await page.locator('[data-component="composer-editor"]').pressSequentially("Also: ")
|
||||
await expect(page.locator('[data-component="composer-editor"]')).toHaveText(`Also: ${followUp}`)
|
||||
expect(mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
await expect.poll(() => mock.calls).toEqual(["worktree", "session", "prompt"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -491,7 +491,8 @@ async function openDraft(
|
||||
if (request.method() !== "POST") return
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === "/api/worktree") {
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(new URL(request.url()).searchParams.has("location[directory]")).toBe(false)
|
||||
expect(request.postDataJSON()).toMatchObject({ projectID })
|
||||
calls.push("worktree")
|
||||
worktreeRequests.push(request.postDataJSON())
|
||||
}
|
||||
|
||||
@@ -398,8 +398,6 @@ async function mockServers(
|
||||
},
|
||||
])
|
||||
}
|
||||
if (url.pathname === "/api/project/current")
|
||||
return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory, canonical: directory })
|
||||
if (url.pathname === "/api/session")
|
||||
return json(route, { data: sessions.map((session) => currentSession(session)), cursor: {} })
|
||||
if (url.pathname === "/api/session/active")
|
||||
|
||||
@@ -55,6 +55,8 @@ for (const direction of ["ltr", "rtl"] as const) {
|
||||
"href",
|
||||
`#opencode-v2-icon-${workspace ? "outline-worktree" : "monitor"}`,
|
||||
)
|
||||
// Initial layout scrolls this sticky header's ancestor and dismisses its tooltip.
|
||||
await expect(page.locator("[data-timeline-virtual-content]")).toHaveCSS("visibility", "visible")
|
||||
const background = await trigger.evaluate((element) => getComputedStyle(element).backgroundColor)
|
||||
await trigger.hover()
|
||||
await expect(trigger).not.toHaveCSS("background-color", background)
|
||||
|
||||
@@ -236,7 +236,7 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
const requested = page.waitForRequest(
|
||||
(request) =>
|
||||
new URL(request.url()).pathname === "/api/worktree" &&
|
||||
new URL(request.url()).searchParams.get("location[directory]") === directory &&
|
||||
new URL(request.url()).searchParams.get("projectID") === "proj_settings_demo" &&
|
||||
request.method() === "GET",
|
||||
)
|
||||
await settings.getByRole("tab", { name: "Worktrees", exact: true }).click()
|
||||
@@ -265,7 +265,7 @@ test("workspaces opens without waiting for inventory or sessions", async ({ page
|
||||
refresh.resolve()
|
||||
})
|
||||
|
||||
test("worktree deletion sends the project location separately from the target", async ({ page }) => {
|
||||
test("worktree deletion sends the project ID and target without a location", async ({ page }) => {
|
||||
const removed = new Set<string>()
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
@@ -297,8 +297,8 @@ test("worktree deletion sends the project location separately from the target",
|
||||
)
|
||||
await remove.click()
|
||||
const request = await deleting
|
||||
expect(new URL(request.url()).searchParams.get("location[directory]")).toBe(directory)
|
||||
expect(request.postDataJSON()).toEqual({ directory: sandboxes[0], force: true })
|
||||
expect(new URL(request.url()).searchParams.has("location[directory]")).toBe(false)
|
||||
expect(request.postDataJSON()).toEqual({ projectID: "proj_settings_demo", directory: sandboxes[0], force: true })
|
||||
await expect(settings.getByText(sandboxes[0], { exact: true })).toHaveCount(0)
|
||||
await expect(settings.getByText("11 worktrees", { exact: true })).toBeVisible()
|
||||
})
|
||||
|
||||
@@ -592,7 +592,7 @@ async function mockServer(page: Page) {
|
||||
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
|
||||
if (url.pathname === "/api/mcp/resource")
|
||||
return json(route, { location: { directory: sessionA.directory }, data: { resources: [], templates: [] } })
|
||||
if (url.pathname === "/api/project" || url.pathname === "/api/project/current") {
|
||||
if (url.pathname === "/api/project") {
|
||||
const project = {
|
||||
id: sessionA.projectID,
|
||||
canonical: sessionA.directory,
|
||||
@@ -600,10 +600,7 @@ async function mockServer(page: Page) {
|
||||
time: { created: 1, updated: 1 },
|
||||
sandboxes: [],
|
||||
}
|
||||
return json(
|
||||
route,
|
||||
url.pathname === "/api/project" ? [project] : { id: project.id, directory: sessionA.directory },
|
||||
)
|
||||
return json(route, [project])
|
||||
}
|
||||
if (url.pathname === "/api/location")
|
||||
return json(route, {
|
||||
|
||||
@@ -98,7 +98,7 @@ for (const theme of ["light", "dark"] as const) {
|
||||
const refreshed = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
new URL(response.url()).searchParams.get("projectID") === projectID &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
view.worktrees.push({ directory: workspace, strategy: "git" })
|
||||
@@ -228,7 +228,7 @@ async function openSession(page: Page, directory: string, worktrees = [...invent
|
||||
const loaded = page.waitForResponse(
|
||||
(response) =>
|
||||
new URL(response.url()).pathname === "/api/worktree" &&
|
||||
new URL(response.url()).searchParams.get("location[directory]") === root &&
|
||||
new URL(response.url()).searchParams.get("projectID") === projectID &&
|
||||
response.request().method() === "GET",
|
||||
)
|
||||
await page.goto(
|
||||
|
||||
@@ -54,7 +54,7 @@ for (const interaction of ["hover", "focus"] as const) {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
calls.push(new URL(route.request().url()).searchParams.get("location[directory]") ?? "")
|
||||
calls.push(new URL(route.request().url()).searchParams.get("projectID") ?? "")
|
||||
await inventory.promise
|
||||
await route.fallback()
|
||||
},
|
||||
@@ -74,7 +74,7 @@ for (const interaction of ["hover", "focus"] as const) {
|
||||
await worktrees[interaction]()
|
||||
await requested
|
||||
await expect(worktrees).toHaveAttribute("aria-selected", "false")
|
||||
await expect.poll(() => calls).toEqual([directory])
|
||||
await expect.poll(() => calls).toEqual([project.id])
|
||||
expect(sessions).toEqual([])
|
||||
|
||||
if (interaction === "hover") {
|
||||
@@ -90,7 +90,7 @@ for (const interaction of ["hover", "focus"] as const) {
|
||||
inventory.resolve()
|
||||
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
|
||||
await expect(settings.getByText("Cached worktree session", { exact: true })).toBeVisible()
|
||||
expect(calls).toEqual([directory])
|
||||
expect(calls).toEqual([project.id])
|
||||
await expect.poll(() => sessions.toSorted()).toEqual(sandboxes.toSorted())
|
||||
})
|
||||
}
|
||||
@@ -124,9 +124,9 @@ for (const nested of [false, true]) {
|
||||
await page.route(
|
||||
(url) => url.pathname === "/api/worktree",
|
||||
async (route) => {
|
||||
const requested = new URL(route.request().url()).searchParams.get("location[directory]") ?? ""
|
||||
const requested = new URL(route.request().url()).searchParams.get("projectID") ?? ""
|
||||
calls.worktrees.push(requested)
|
||||
if (requested === other.canonical) return route.fulfill({ json: [{ directory: other.canonical }] })
|
||||
if (requested === other.id) return route.fulfill({ json: [{ directory: other.canonical }] })
|
||||
await route.fallback()
|
||||
},
|
||||
)
|
||||
@@ -146,7 +146,7 @@ for (const nested of [false, true]) {
|
||||
await worktrees.click()
|
||||
await expect(settings.getByText("2 worktrees", { exact: true })).toBeVisible()
|
||||
expect(calls.projects).toBe(1)
|
||||
expect(calls.worktrees.toSorted()).toEqual([directory, other.canonical].toSorted())
|
||||
expect(calls.worktrees.toSorted()).toEqual([project.id, other.id].toSorted())
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Schema, SchemaGetter } from "effect"
|
||||
import { HttpApi, HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Worktree } from "@opencode/schema/worktree"
|
||||
|
||||
const Json = Schema.Json.pipe(
|
||||
Schema.decodeTo(Schema.Unknown, {
|
||||
@@ -77,7 +78,6 @@ const Group = HttpApiGroup.make("mock")
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(HttpApiEndpoint.get("projectCurrent", "/api/project/current", { success: Json }))
|
||||
.add(HttpApiEndpoint.get("configPreferences", "/api/config/preferences", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.patch("configUpdatePreferences", "/api/config/preferences", {
|
||||
@@ -89,22 +89,25 @@ const Group = HttpApiGroup.make("mock")
|
||||
.add(HttpApiEndpoint.get("websearchProviders", "/api/websearch/provider", { success: Json }))
|
||||
.add(
|
||||
HttpApiEndpoint.get("worktreeList", "/api/worktree", {
|
||||
query: Schema.Struct({ projectID: Schema.String }),
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeCreate", "/api/worktree", {
|
||||
payload: JsonPayload,
|
||||
payload: Worktree.CreateInput,
|
||||
success: Json,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.delete("worktreeRemove", "/api/worktree", {
|
||||
payload: Worktree.RemoveInput,
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("worktreeRefresh", "/api/worktree/refresh", {
|
||||
payload: Schema.Struct({ projectID: Schema.String }),
|
||||
success: NoContent,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -285,12 +285,6 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
canonical: project.canonical ?? config.directory,
|
||||
})
|
||||
},
|
||||
projectCurrent: () =>
|
||||
Effect.succeed({
|
||||
id: (config.project as { id?: string }).id,
|
||||
directory: config.directory,
|
||||
canonical: config.directory,
|
||||
}),
|
||||
configPreferences: () => Effect.succeed(preferences.current),
|
||||
configUpdatePreferences: (ctx) =>
|
||||
Effect.sync(() => {
|
||||
@@ -308,7 +302,7 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
|
||||
})),
|
||||
]),
|
||||
worktreeCreate: (ctx) => {
|
||||
const input = record(ctx.payload) ? ctx.payload : {}
|
||||
const input = ctx.payload
|
||||
return Effect.succeed({
|
||||
directory: `${typeof input.directory === "string" ? input.directory : config.directory}/${
|
||||
typeof input.name === "string" ? input.name : "copy"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -39,8 +39,7 @@ export function createHomeController() {
|
||||
const id = selectedProject()?.id
|
||||
if (!ctx || !id || ctx.sdk.connection.status() !== "connected") return
|
||||
// Selecting a project is the demand for its worktree inventory: the session filter spans its worktrees.
|
||||
const root = ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void ctx.sync.worktrees.load(root)
|
||||
void ctx.sync.worktrees.load(id)
|
||||
})
|
||||
|
||||
function setSelection(next: HomeProjectSelection) {
|
||||
@@ -110,7 +109,7 @@ export function createHomeController() {
|
||||
.list({ path: ".", location })
|
||||
.then(async (files) => {
|
||||
// TODO: Initialize empty directories when V2 exposes a native Git init API.
|
||||
return ctx.sdk.api.project.current({ location })
|
||||
return ctx.sdk.api.location.get({ location }).then((result) => result.project)
|
||||
})
|
||||
.then((project) => ctx.sync.child(item, { bootstrap: false })[1]("project", project.id))
|
||||
.catch(() => undefined)
|
||||
|
||||
@@ -67,12 +67,13 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const [worktrees, worktreeActions] = createResource(worktreeSource, async (source) => ({
|
||||
projectID: source.projectID,
|
||||
items: await serverSDK.api.worktree
|
||||
.list({ location: { directory: source.directory } })
|
||||
.list({ projectID: source.projectID })
|
||||
.catch(() => (currentProject()?.id === source.projectID ? currentProject()?.worktrees : undefined) ?? []),
|
||||
}))
|
||||
onCleanup(
|
||||
serverSDK.event.listen((event) => {
|
||||
if (event.type === "worktree.updated") void worktreeActions.refetch()
|
||||
if (event.type === "worktree.updated" && event.data.projectID === currentProject()?.id)
|
||||
void worktreeActions.refetch()
|
||||
}),
|
||||
)
|
||||
// `latest` only skips Suspense once the resource has resolved at least once. Before that it
|
||||
|
||||
@@ -49,7 +49,7 @@ test("bootstraps projects through the native store setter and preserves subseque
|
||||
expect(store.config).toEqual({})
|
||||
|
||||
// A refetch keeps the inventory a view already loaded for this project.
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "/repo/"), [
|
||||
queryClient.setQueryData(worktreeInventoryKey(ServerScope.local, "project"), [
|
||||
{ directory: "/repo" },
|
||||
{ directory: "/repo/feature", strategy: "git" },
|
||||
])
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/runtime/server/types"
|
||||
import type {
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
} from "@opencode/client/promise"
|
||||
import type { LocationGetInput, LocationGetOutput, ProjectListOutput } from "@opencode/client/promise"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { getFilename } from "@opencode/util/path"
|
||||
import { retry } from "@opencode/util/retry"
|
||||
@@ -61,7 +55,6 @@ export const loadGlobalConfigQuery = (scope: ServerScope) =>
|
||||
|
||||
type ProjectApi = {
|
||||
readonly list: () => Promise<ProjectListOutput>
|
||||
readonly current: (input?: ProjectCurrentInput) => Promise<ProjectCurrentOutput>
|
||||
}
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
@@ -100,7 +93,7 @@ export async function bootstrapGlobal(input: {
|
||||
data.map((project) =>
|
||||
withWorktreeInventory(
|
||||
project,
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.worktree)),
|
||||
input.queryClient.getQueryData(worktreeInventoryKey(input.scope, project.id)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -132,6 +125,7 @@ export async function bootstrapDirectory(input: {
|
||||
mcp: boolean
|
||||
api: {
|
||||
readonly project: ProjectApi
|
||||
readonly location: LocationApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
@@ -155,8 +149,8 @@ export async function bootstrapDirectory(input: {
|
||||
seededProject
|
||||
? undefined
|
||||
: () =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
input.setStore("project", project.id),
|
||||
retry(() => input.api.location.get({ location: { directory: input.directory } })).then((location) =>
|
||||
input.setStore("project", location.project.id),
|
||||
),
|
||||
].filter((task): task is () => Promise<void> => !!task)
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ import { createConnectionSync, reconnectOrder } from "./server-sync/connection"
|
||||
import { usePlatform } from "@/runtime/platform/platform"
|
||||
import type { Data } from "@opencode/client/solid"
|
||||
import { createWorktreeInventory, withWorktreeInventory } from "@/workspaces/inventory"
|
||||
import { sameDirectory } from "@/workspaces/paths"
|
||||
|
||||
type GlobalStore = {
|
||||
path: Path
|
||||
@@ -85,11 +84,9 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
api: () => serverSDK.api.worktree,
|
||||
updated: (directory, items) =>
|
||||
updated: (projectID, items) =>
|
||||
setGlobalStore("project", (projects) =>
|
||||
projects.map((project) =>
|
||||
sameDirectory(project.worktree, directory) ? withWorktreeInventory(project, items) : project,
|
||||
),
|
||||
projects.map((project) => (project.id === projectID ? withWorktreeInventory(project, items) : project)),
|
||||
),
|
||||
})
|
||||
const bootstrap = useQuery(() => ({
|
||||
@@ -164,8 +161,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
// The refresh queue re-syncs two directories at a time, held ones first. Syncing every active
|
||||
// directory here as well sent the whole catalog fan-out for all of them at once.
|
||||
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach(
|
||||
(directory) => queue.push(directory),
|
||||
reconnectOrder(Object.keys(children.children).filter(children.active), children.pinned).forEach((directory) =>
|
||||
queue.push(directory),
|
||||
)
|
||||
},
|
||||
})
|
||||
@@ -212,7 +209,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
projects.map((project) =>
|
||||
project.id === update.id
|
||||
? // The wire payload carries no worktrees; keep the inventory this project already loaded.
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.canonical))
|
||||
withWorktreeInventory(updateProjectInfo(project, update), worktrees.cached(update.id))
|
||||
: project,
|
||||
),
|
||||
)
|
||||
@@ -222,8 +219,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
connection.handleEvent({ type: event.type })
|
||||
if (event.type === "project.updated") applyProjectUpdate(event.data)
|
||||
if (event.type === "worktree.updated") {
|
||||
const root = globalStore.project.find((project) => project.id === event.data.projectID)?.worktree
|
||||
if (root) void worktrees.refresh(root)
|
||||
void worktrees.refresh(event.data.projectID)
|
||||
void bootstrap.refetch()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -37,13 +37,21 @@ export function SessionWorkspaceMenu(props: {
|
||||
props.onOpenChange?.(open)
|
||||
if (!open) return
|
||||
const sdk = serverSDK
|
||||
const list = () =>
|
||||
sdk.api.worktree
|
||||
.list({ projectID: props.project.id })
|
||||
.then((items) =>
|
||||
setDirectories(
|
||||
items
|
||||
.map((item) => item.directory)
|
||||
.filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||
),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
void list()
|
||||
void sdk.api.worktree
|
||||
.list({ location: { directory: props.directory } })
|
||||
.then((items) =>
|
||||
setDirectories(
|
||||
items.map((item) => item.directory).filter((directory) => !sameDirectory(props.project.worktree, directory)),
|
||||
),
|
||||
)
|
||||
.refresh({ projectID: props.project.id })
|
||||
.then(list)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
const move = async (selection: "create" | string) => {
|
||||
|
||||
@@ -103,7 +103,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount })
|
||||
const [rendering, setRendering] = createStore({ initialTail: coldBottomMount, scrollAdjustment: 0 })
|
||||
const rows = input.projection.rows
|
||||
const rowByKey = input.projection.rowByKey
|
||||
const rowKeys = createMemo(() => rows().map(TimelineRow.key), undefined, {
|
||||
@@ -154,6 +154,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
const measuredElements = new WeakSet<Element>()
|
||||
let touchStart: number | undefined
|
||||
let touchTarget: EventTarget | null = null
|
||||
let touchNested = false
|
||||
let touchScrolling = false
|
||||
let touchAdjustment = 0
|
||||
let pointerHeld = false
|
||||
let maxScroll = 0
|
||||
let virtualContent: HTMLDivElement | undefined
|
||||
@@ -177,7 +181,23 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
observeElementOffset: (instance, callback) => {
|
||||
reportOffset = (offset, scrolling) => {
|
||||
if (!active()) return
|
||||
callback(offset, scrolling)
|
||||
// Rows and the sizer use the opposite translation while native touch
|
||||
// scrolling keeps its own offset. Range selection uses the logical offset.
|
||||
batch(() => {
|
||||
const logicalOffset = offset + rendering.scrollAdjustment
|
||||
callback(rendering.scrollAdjustment ? Math.max(0, logicalOffset) : offset, scrolling)
|
||||
// Reconcile both start boundaries in one native write. Gradually
|
||||
// clamping row translations lets the compositor paint between
|
||||
// corrections and makes the content oscillate at the top.
|
||||
const root = listRoot()
|
||||
if (
|
||||
rendering.scrollAdjustment !== 0 &&
|
||||
root &&
|
||||
(logicalOffset <= 0 || offset <= 0 || (touchStart !== undefined && offset <= root.clientHeight))
|
||||
)
|
||||
flushTouchAdjustment()
|
||||
if (!scrolling && touchStart === undefined) finishTouchScroll()
|
||||
})
|
||||
settleColdBottom()
|
||||
}
|
||||
return observeElementOffsetReconnectAware(instance, reportOffset, () => {
|
||||
@@ -212,6 +232,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
scrollToFn: (offset, options, instance) => {
|
||||
if (!active()) return
|
||||
if (batchingColdSizes && input.pinned()) return
|
||||
setRendering("scrollAdjustment", 0)
|
||||
if (virtualContent) virtualContent.style.height = `${instance.getTotalSize()}px`
|
||||
elementScroll(offset, options, instance)
|
||||
},
|
||||
@@ -263,7 +284,15 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
batch(() => {
|
||||
sizes.forEach(([index, value]) => {
|
||||
const row = rows()[index]
|
||||
if (row && TimelineRow.key(row) === value.key) resizeItem(index, value.size)
|
||||
if (!row || TimelineRow.key(row) !== value.key) return
|
||||
resizeItem(index, value.size)
|
||||
// TanStack recalculates its range after each resize. Advance the
|
||||
// logical fold before deciding whether the next row needs anchoring.
|
||||
if (!touchAdjustment) return
|
||||
setRendering("scrollAdjustment", (value) => value + touchAdjustment)
|
||||
touchAdjustment = 0
|
||||
const root = listRoot()
|
||||
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
|
||||
})
|
||||
})
|
||||
batchingColdSizes = false
|
||||
@@ -278,13 +307,40 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
})
|
||||
}
|
||||
onCleanup(() => pendingSizes.clear())
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
|
||||
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, delta, instance) => {
|
||||
// Prepended rows can resize more than once as deferred content mounts. Keep
|
||||
// compensating while they remain entirely above the visible content fold.
|
||||
if (addedKeys.has(String(item.key)))
|
||||
return item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
|
||||
const first = instance.range?.startIndex
|
||||
return first !== undefined && item.index < first
|
||||
const adjust = addedKeys.has(String(item.key))
|
||||
? item.end <= (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
|
||||
: first !== undefined && item.index < first
|
||||
if (!touchScrolling || input.pinned()) return adjust
|
||||
// iOS defers native scroll writes until momentum ends. Keep the same visual
|
||||
// anchor now, rather than moving rows now and snapping the viewport back later.
|
||||
if (adjust) touchAdjustment += delta
|
||||
return false
|
||||
}
|
||||
|
||||
function finishTouchScroll() {
|
||||
touchScrolling = false
|
||||
flushTouchAdjustment()
|
||||
}
|
||||
|
||||
function prepareNavigation() {
|
||||
if (touchStart === undefined) touchScrolling = false
|
||||
flushTouchAdjustment()
|
||||
}
|
||||
|
||||
function flushTouchAdjustment() {
|
||||
const adjustment = rendering.scrollAdjustment
|
||||
const root = listRoot()
|
||||
if (!adjustment || !root) return
|
||||
// Transfer the translation into the native offset in the same paint.
|
||||
batch(() => {
|
||||
setRendering("scrollAdjustment", 0)
|
||||
if (virtualContent) virtualContent.style.height = `${virtualizer.getTotalSize()}px`
|
||||
elementScroll(Math.max(0, root.scrollTop + adjustment), {}, virtualizer)
|
||||
})
|
||||
}
|
||||
const virtualItemByKey = createMemo(
|
||||
() => new Map(virtualizer.getVirtualItems().map((item) => [item.key, item] as const)),
|
||||
@@ -314,10 +370,12 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
: -1
|
||||
const index = partIndex >= 0 ? partIndex : input.projection.messageRowIndex().get(id)
|
||||
if (index === undefined) return
|
||||
prepareNavigation()
|
||||
virtualizer.scrollToIndex(index, { align: "center" })
|
||||
})
|
||||
input.setScrollToEnd?.(() => {
|
||||
if (!active() || !listRoot()?.isConnected) return
|
||||
prepareNavigation()
|
||||
input.onPin()
|
||||
virtualizer.scrollToEnd()
|
||||
})
|
||||
@@ -422,19 +480,53 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
}
|
||||
|
||||
const handleListTouchStart = (event: TouchEvent) => {
|
||||
clearTouchTarget()
|
||||
input.onUserScroll(event.target)
|
||||
touchScrolling = true
|
||||
touchStart = event.touches[0]?.clientY
|
||||
const root = listRoot()
|
||||
const nested = event.target instanceof Element ? event.target.closest<HTMLElement>("[data-scrollable]") : null
|
||||
touchNested = !!nested && nested !== root && nested.scrollHeight > nested.clientHeight
|
||||
// Native touch events keep their original target, even when streaming or
|
||||
// virtualization detaches it. Listen there instead of relying on bubbling.
|
||||
touchTarget = event.target
|
||||
touchTarget?.addEventListener("touchmove", handleListTouchMove, { passive: true })
|
||||
touchTarget?.addEventListener("touchend", handleListTouchEnd, { passive: true })
|
||||
touchTarget?.addEventListener("touchcancel", handleListTouchEnd, { passive: true })
|
||||
if (root) reportOffset?.(root.scrollTop, virtualizer.isScrolling)
|
||||
}
|
||||
|
||||
const handleListTouchMove = (event: TouchEvent & { currentTarget: HTMLDivElement }) => {
|
||||
const handleListTouchMove = (event: Event) => {
|
||||
if (!(event instanceof TouchEvent)) return
|
||||
const current = event.touches[0]?.clientY
|
||||
if (current === undefined || touchStart === undefined) return
|
||||
// Dragging the content downward reveals earlier messages.
|
||||
if (current <= touchStart) return
|
||||
const previous = touchStart
|
||||
touchStart = current
|
||||
// A retained target can outlive its whole session view. Only the active
|
||||
// timeline may change the shared follow state; release still cleans up below.
|
||||
if (!active()) return
|
||||
// Dragging the content downward reveals earlier messages.
|
||||
if (current <= previous) return
|
||||
// A nested scrollport owns the intent. If it chains into the timeline at a
|
||||
// boundary, the resulting native timeline scroll below will unpin instead.
|
||||
if (touchNested) return
|
||||
input.onUnpin()
|
||||
}
|
||||
|
||||
const handleListTouchEnd = () => {
|
||||
clearTouchTarget()
|
||||
touchStart = undefined
|
||||
if (!virtualizer.isScrolling) finishTouchScroll()
|
||||
}
|
||||
|
||||
function clearTouchTarget() {
|
||||
touchTarget?.removeEventListener("touchmove", handleListTouchMove)
|
||||
touchTarget?.removeEventListener("touchend", handleListTouchEnd)
|
||||
touchTarget?.removeEventListener("touchcancel", handleListTouchEnd)
|
||||
touchTarget = null
|
||||
}
|
||||
onCleanup(clearTouchTarget)
|
||||
|
||||
// Drag-selecting past the edge and dragging the scrollbar both scroll without a wheel or key,
|
||||
// so a held pointer is what separates those from the virtualizer's own measurement adjustments.
|
||||
const handleListPointerDown = (event: PointerEvent & { currentTarget: HTMLDivElement }) => {
|
||||
@@ -475,7 +567,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
const atEnd = maxScroll - scrollTop <= endEpsilon
|
||||
const arrived = scrollTop > previousTop + endEpsilon || maxScroll < previousMaxScroll
|
||||
if (maxScroll <= 1 || (atEnd && arrived)) input.onPin()
|
||||
else if (pointerHeld && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
else if ((pointerHeld || touchScrolling) && scrollTop < previousTop - endEpsilon) input.onUnpin()
|
||||
settleColdBottom()
|
||||
input.onScheduleScrollState(root)
|
||||
input.onHistoryScroll()
|
||||
@@ -504,7 +596,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
data-timeline-key={rowProps.rowKey}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: `${item().start - topOffset()}px`,
|
||||
top: `${item().start - topOffset() - rendering.scrollAdjustment}px`,
|
||||
left: "0",
|
||||
width: "100%",
|
||||
height: `${item().size}px`,
|
||||
@@ -582,9 +674,10 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<ScrollView
|
||||
data-slot="session-timeline-scroll"
|
||||
viewportRef={bindListRoot}
|
||||
onBeforeScroll={prepareNavigation}
|
||||
verticalScrollAdjustment={rendering.scrollAdjustment}
|
||||
onWheel={handleListWheel}
|
||||
onTouchStart={handleListTouchStart}
|
||||
onTouchMove={handleListTouchMove}
|
||||
onPointerDown={handleListPointerDown}
|
||||
onKeyDown={handleListKeyDown}
|
||||
onScroll={handleListScroll}
|
||||
@@ -602,7 +695,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
if (active()) input.setContentRef(element)
|
||||
}}
|
||||
style={{
|
||||
height: `${virtualizer.getTotalSize()}px`,
|
||||
height: `${virtualizer.getTotalSize() - rendering.scrollAdjustment}px`,
|
||||
position: "relative",
|
||||
width: "100%",
|
||||
visibility: coldBottomMount ? "hidden" : undefined,
|
||||
@@ -612,7 +705,7 @@ export function createTimelineVirtualizer(input: Input) {
|
||||
<div
|
||||
data-timeline-row="bottom-spacer"
|
||||
class="h-16 absolute top-0 left-0 w-full"
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64}px)` }}
|
||||
style={{ transform: `translateY(${virtualizer.getTotalSize() - 64 - rendering.scrollAdjustment}px)` }}
|
||||
>
|
||||
{props.bottomSpacer}
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@ export function workspaceInventoryQuery(context: ServerCtx, client: QueryClient,
|
||||
(await client.fetchQuery(workspaceProjectsQuery(context.sdk)))
|
||||
.filter((project) => projectID === undefined || project.id === projectID)
|
||||
.map(async (project) => {
|
||||
const worktrees = (await context.sync.worktrees.load(project.canonical)) ?? [
|
||||
const worktrees = (await context.sync.worktrees.load(project.id)) ?? [
|
||||
{ directory: project.canonical },
|
||||
...project.sandboxes.map((directory) => ({ directory })),
|
||||
]
|
||||
|
||||
@@ -215,7 +215,7 @@ export const SettingsWorkspaces: Component<{
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
location: { directory: workspace.project.worktree },
|
||||
projectID: workspace.project.id,
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
@@ -243,7 +243,7 @@ export const SettingsWorkspaces: Component<{
|
||||
})
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.worktree),
|
||||
queryKey: worktreeInventoryKey(context.sdk.scope, workspace.project.id),
|
||||
})
|
||||
await queryClient.invalidateQueries({ queryKey: [context.sdk.scope, "settings-workspace-inventory"] })
|
||||
} finally {
|
||||
|
||||
@@ -60,13 +60,11 @@ describe("worktree creation", () => {
|
||||
}),
|
||||
).toBe("/created")
|
||||
expect(await requests.find((request) => request.method === "POST")?.json()).toEqual({
|
||||
strategy: "git",
|
||||
projectID: project.id,
|
||||
from: input.canonical,
|
||||
branch: "clone-only",
|
||||
})
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe(
|
||||
`http://localhost:3000/api/worktree?location%5Bdirectory%5D=${encodeURIComponent(input.directory)}`,
|
||||
)
|
||||
expect(requests.find((request) => request.method === "POST")?.url).toBe("http://localhost:3000/api/worktree")
|
||||
expect(
|
||||
requests
|
||||
.filter((request) => request.method === "GET")
|
||||
|
||||
@@ -10,8 +10,7 @@ export async function createWorktree(input: {
|
||||
}) {
|
||||
const project = input.project ?? (await input.api.location.get({ location: { directory: input.directory } })).project
|
||||
const created = await input.api.worktree.create({
|
||||
location: { directory: input.directory },
|
||||
strategy: "git",
|
||||
projectID: project.id,
|
||||
from: project.canonical,
|
||||
branch: input.branch,
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ import { normalizeProjectInfo, updateProjectInfo } from "@/runtime/server/global
|
||||
|
||||
function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
const client = new QueryClient()
|
||||
const discovery = Promise.withResolvers<void>()
|
||||
const discoveries: string[] = []
|
||||
const calls: string[] = []
|
||||
const updates: Array<[string, WorktreeDirectory[]]> = []
|
||||
const inventory = createWorktreeInventory({
|
||||
@@ -14,14 +16,20 @@ function setup(list: (directory: string) => Promise<WorktreeDirectory[]>) {
|
||||
queryClient: client,
|
||||
api: () => ({
|
||||
list: (input) => {
|
||||
const directory = input!.location!.directory!
|
||||
const directory = input.projectID
|
||||
calls.push(directory)
|
||||
return list(directory)
|
||||
},
|
||||
refresh: (input) => {
|
||||
discoveries.push(input.projectID)
|
||||
return discovery.promise
|
||||
},
|
||||
}),
|
||||
updated: (directory, items) => updates.push([directory, items]),
|
||||
updated: (directory, items) => {
|
||||
updates.push([directory, items])
|
||||
},
|
||||
})
|
||||
return { client, calls, updates, inventory }
|
||||
return { client, calls, updates, inventory, discovery, discoveries }
|
||||
}
|
||||
|
||||
describe("createWorktreeInventory", () => {
|
||||
@@ -32,7 +40,7 @@ describe("createWorktreeInventory", () => {
|
||||
return [{ directory }, { directory: `${directory}/feature`, strategy: "git" }]
|
||||
})
|
||||
const first = setupResult.inventory.load("/repo")
|
||||
const second = setupResult.inventory.load("/repo/")
|
||||
const second = setupResult.inventory.load("/repo")
|
||||
expect(setupResult.calls).toEqual(["/repo"])
|
||||
gate.resolve()
|
||||
expect(await first).toHaveLength(2)
|
||||
@@ -42,7 +50,8 @@ describe("createWorktreeInventory", () => {
|
||||
expect(setupResult.updates).toEqual([
|
||||
["/repo", [{ directory: "/repo" }, { directory: "/repo/feature", strategy: "git" }]],
|
||||
])
|
||||
expect(setupResult.inventory.cached("/repo/")).toHaveLength(2)
|
||||
expect(setupResult.inventory.cached("/repo")).toHaveLength(2)
|
||||
expect(setupResult.discoveries).toEqual(["/repo"])
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
@@ -70,13 +79,25 @@ describe("createWorktreeInventory", () => {
|
||||
setupResult.client.clear()
|
||||
})
|
||||
|
||||
test("keys are partitioned by server and normalized by path", () => {
|
||||
test("keys are partitioned by server and use opaque project IDs", () => {
|
||||
const remote = "https://remote.example" as typeof ServerScope.local
|
||||
expect(worktreeInventoryKey(ServerScope.local, "C:\\Repo\\")).toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "C:/Repo"),
|
||||
expect(worktreeInventoryKey(ServerScope.local, "project")).not.toEqual(
|
||||
worktreeInventoryKey(ServerScope.local, "project/"),
|
||||
)
|
||||
expect(worktreeInventoryKey(ServerScope.local, "/repo")).not.toEqual(worktreeInventoryKey(remote, "/repo"))
|
||||
})
|
||||
|
||||
test("shows saved inventory during discovery and re-reads it on an inventory event", async () => {
|
||||
const rows = [{ directory: "/repo" }]
|
||||
const result = setup(async () => [...rows])
|
||||
expect(await result.inventory.load("project")).toEqual(rows)
|
||||
rows.push({ directory: "/external" })
|
||||
result.discovery.resolve()
|
||||
expect(await result.inventory.refresh("project")).toEqual(rows)
|
||||
expect(result.calls).toEqual(["project", "project"])
|
||||
expect(result.discoveries).toEqual(["project"])
|
||||
result.client.clear()
|
||||
})
|
||||
})
|
||||
|
||||
describe("withWorktreeInventory", () => {
|
||||
|
||||
@@ -3,11 +3,10 @@ import type { WorktreeDirectory } from "@opencode/client/promise"
|
||||
import type { ServerApi } from "@/runtime/server/api"
|
||||
import type { ServerScope } from "@/runtime/server/scope"
|
||||
import type { Project } from "@/runtime/server/types"
|
||||
import { pathKey } from "./path-key"
|
||||
import { sameDirectory } from "./paths"
|
||||
|
||||
export function worktreeInventoryKey(scope: ServerScope, directory: string) {
|
||||
return [scope, "worktree", pathKey(directory)] as const
|
||||
export function worktreeInventoryKey(scope: ServerScope, projectID: string) {
|
||||
return [scope, "worktree", projectID] as const
|
||||
}
|
||||
|
||||
// Project metadata arrives without worktrees; a loaded inventory supplies the workspace list.
|
||||
@@ -22,22 +21,21 @@ export function withWorktreeInventory(project: Project, worktrees: readonly Work
|
||||
}
|
||||
}
|
||||
|
||||
// Listing a project's worktrees boots its Location on the server and runs discovery, so only
|
||||
// projects the user is looking at are loaded. Historical projects stay metadata-only.
|
||||
// Reads use saved inventory; explicit demand also discovers external worktree changes.
|
||||
export function createWorktreeInventory(input: {
|
||||
scope: ServerScope
|
||||
queryClient: QueryClient
|
||||
api: () => Pick<ServerApi["worktree"], "list">
|
||||
updated: (directory: string, worktrees: WorktreeDirectory[]) => void
|
||||
api: () => Pick<ServerApi["worktree"], "list" | "refresh">
|
||||
updated: (projectID: string, worktrees: WorktreeDirectory[]) => void
|
||||
}) {
|
||||
const options = (directory: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, directory),
|
||||
const options = (projectID: string) => ({
|
||||
queryKey: worktreeInventoryKey(input.scope, projectID),
|
||||
queryFn: () =>
|
||||
input
|
||||
.api()
|
||||
.list({ location: { directory } })
|
||||
.list({ projectID })
|
||||
.then((items) => {
|
||||
input.updated(directory, items)
|
||||
input.updated(projectID, items)
|
||||
return items
|
||||
}),
|
||||
// `worktree.updated` and reconnect invalidation drive refreshes; time alone does not re-list.
|
||||
@@ -46,13 +44,24 @@ export function createWorktreeInventory(input: {
|
||||
retry: false,
|
||||
})
|
||||
return {
|
||||
cached: (directory: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, directory)),
|
||||
load: (directory: string) => input.queryClient.fetchQuery(options(directory)).catch(() => undefined),
|
||||
cached: (projectID: string) =>
|
||||
input.queryClient.getQueryData<WorktreeDirectory[]>(worktreeInventoryKey(input.scope, projectID)),
|
||||
load: async (projectID: string) => {
|
||||
const discover = !input.queryClient.getQueryState(worktreeInventoryKey(input.scope, projectID))
|
||||
const items = await input.queryClient.fetchQuery(options(projectID)).catch(() => undefined)
|
||||
if (discover) {
|
||||
// The worktree.updated event re-reads inventory only when discovery actually changes it.
|
||||
void input
|
||||
.api()
|
||||
.refresh({ projectID })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
return items
|
||||
},
|
||||
// Only inventories some view already demanded are refreshed.
|
||||
refresh: (directory: string) => {
|
||||
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, directory))) return Promise.resolve()
|
||||
return input.queryClient.fetchQuery({ ...options(directory), staleTime: 0 }).catch(() => undefined)
|
||||
refresh: (projectID: string) => {
|
||||
if (!input.queryClient.getQueryState(worktreeInventoryKey(input.scope, projectID))) return Promise.resolve()
|
||||
return input.queryClient.fetchQuery({ ...options(projectID), staleTime: 0 }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,9 +45,7 @@ const context = createSimpleContext({
|
||||
const id = current()?.project.id
|
||||
if (!id || serverSDK.connection.status() !== "connected") return
|
||||
// Showing a Location is the demand for its project's worktree inventory (workspace styling, picker).
|
||||
// Key it by the metadata root so the result merges into the same global project record.
|
||||
const root = server.ctx.sync.data.project.find((project) => project.id === id)?.worktree
|
||||
if (root) void server.ctx.sync.worktrees.load(root)
|
||||
void server.ctx.sync.worktrees.load(id)
|
||||
})
|
||||
|
||||
const location = createMemo(() => serverSDK.ensureDirSdkContext(current()?.directory ?? ref().directory))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/cli",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
|
||||
@@ -8,11 +8,11 @@ describe("api request resolution", () => {
|
||||
{
|
||||
paths: {
|
||||
"/api/session/{sessionID}": {
|
||||
get: { operationId: "v2.session.get" },
|
||||
get: { operationId: "session.get" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"v2.session.get",
|
||||
"session.get",
|
||||
{ sessionID: "ses/a", workspace: "work" },
|
||||
),
|
||||
).toEqual({ method: "GET", path: "/api/session/ses%2Fa?workspace=work" })
|
||||
@@ -21,8 +21,8 @@ describe("api request resolution", () => {
|
||||
test("rejects a missing path parameter", () => {
|
||||
expect(() =>
|
||||
resolveOperation(
|
||||
{ paths: { "/api/session/{sessionID}": { get: { operationId: "v2.session.get" } } } },
|
||||
"v2.session.get",
|
||||
{ paths: { "/api/session/{sessionID}": { get: { operationId: "session.get" } } } },
|
||||
"session.get",
|
||||
{},
|
||||
),
|
||||
).toThrow("Missing path parameter: sessionID")
|
||||
@@ -30,6 +30,6 @@ describe("api request resolution", () => {
|
||||
|
||||
test("resolves curl-like method and path input", () => {
|
||||
expect(rawRequest(["post", "/api/foo"])).toEqual({ method: "POST", path: "/api/foo" })
|
||||
expect(rawRequest(["v2.session.list"])).toBeUndefined()
|
||||
expect(rawRequest(["session.list"])).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/** @jsxImportSource @opentui/solid */
|
||||
// Split-footer status shown while a freshly launched CLI replaces a
|
||||
// version-mismatched background service before the TUI attaches.
|
||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer, type ThemeMode } from "@opentui/core"
|
||||
import { createCliRenderer, RGBA, TextAttributes, type CliRenderer } from "@opentui/core"
|
||||
import { render, useTerminalDimensions } from "@opentui/solid"
|
||||
import { OPENCODE_VERSION } from "../version"
|
||||
import { registerOpencodeSpinner } from "@opencode/tui/component/register-spinner"
|
||||
@@ -30,17 +30,11 @@ const completionHold = 650
|
||||
export type Handle = {
|
||||
readonly begin: (from?: string) => boolean
|
||||
readonly loading: () => void
|
||||
readonly finish: () => Promise<Handoff | undefined>
|
||||
readonly finish: () => Promise<undefined>
|
||||
readonly fail: (message: string) => Promise<void>
|
||||
readonly close: () => Promise<void>
|
||||
}
|
||||
|
||||
export type Handoff = {
|
||||
readonly renderer: CliRenderer
|
||||
readonly mode: ThemeMode | null
|
||||
readonly complete: () => void
|
||||
}
|
||||
|
||||
export const make = (): Handle => {
|
||||
let session: Promise<Session | undefined> | undefined
|
||||
return {
|
||||
@@ -72,7 +66,7 @@ export const make = (): Handle => {
|
||||
|
||||
type Session = {
|
||||
readonly loading: () => Promise<void>
|
||||
readonly finish: () => Promise<Handoff>
|
||||
readonly finish: () => Promise<undefined>
|
||||
readonly fail: (message: string) => Promise<void>
|
||||
readonly close: () => Promise<void>
|
||||
}
|
||||
@@ -83,7 +77,6 @@ async function open(from?: string): Promise<Session> {
|
||||
const [outcome, setOutcome] = createSignal<"running" | "success" | "failure">("running")
|
||||
const [failure, setFailure] = createSignal("")
|
||||
const [animating, setAnimating] = createSignal(true)
|
||||
const [visible, setVisible] = createSignal(true)
|
||||
let resolveOutcome: (() => void) | undefined
|
||||
const renderer = await createCliRenderer({
|
||||
stdin: process.stdin,
|
||||
@@ -101,20 +94,17 @@ async function open(from?: string): Promise<Session> {
|
||||
externalOutputMode: "capture-stdout",
|
||||
consoleMode: "disabled",
|
||||
})
|
||||
const terminalMode = renderer.waitForThemeMode(1000).catch(() => null)
|
||||
await render(
|
||||
() => (
|
||||
<Show when={visible()}>
|
||||
<UpdateFooter
|
||||
from={from}
|
||||
active={active}
|
||||
outcome={outcome}
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
</Show>
|
||||
<UpdateFooter
|
||||
from={from}
|
||||
active={active}
|
||||
outcome={outcome}
|
||||
failure={failure}
|
||||
animating={animating}
|
||||
renderer={renderer}
|
||||
onOutcomeSettled={() => resolveOutcome?.()}
|
||||
/>
|
||||
),
|
||||
renderer,
|
||||
).catch((error) => {
|
||||
@@ -148,10 +138,8 @@ async function open(from?: string): Promise<Session> {
|
||||
if (completed) await setTimeout(hold)
|
||||
}
|
||||
let closing: Promise<void> | undefined
|
||||
let transferred = false
|
||||
const close = () =>
|
||||
(closing ??= (async () => {
|
||||
if (transferred) return
|
||||
setAnimating(false)
|
||||
if (renderer.isDestroyed) return
|
||||
renderer.pause()
|
||||
@@ -174,18 +162,8 @@ async function open(from?: string): Promise<Session> {
|
||||
await waitForStage()
|
||||
await transitionTo("success", completionHold)
|
||||
})
|
||||
const mode = await terminalMode
|
||||
renderer.externalOutputMode = "passthrough"
|
||||
renderer.screenMode = "alternate-screen"
|
||||
renderer.consoleMode = "console-overlay"
|
||||
renderer.requestRender()
|
||||
await Promise.race([renderer.idle(), setTimeout(500)])
|
||||
transferred = true
|
||||
return {
|
||||
renderer,
|
||||
mode,
|
||||
complete: () => setVisible(false),
|
||||
}
|
||||
await close()
|
||||
return undefined
|
||||
},
|
||||
fail: (message) =>
|
||||
settle(async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/client",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { PromptInput } from "@opencode/schema/prompt-input"
|
||||
import type { AgentAttachment } from "@opencode/schema/prompt"
|
||||
import type { Skill } from "@opencode/schema/skill"
|
||||
import type { Event } from "@opencode/schema/event"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { InstructionEntry } from "@opencode/schema/instruction-entry"
|
||||
import type { Schema } from "effect"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
@@ -36,7 +37,6 @@ import type { PtyTicket } from "@opencode/schema/pty-ticket"
|
||||
import type { Reference } from "@opencode/schema/reference"
|
||||
import type { Worktree } from "@opencode/schema/worktree"
|
||||
import type { Vcs } from "@opencode/schema/vcs"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import type { WebSearch } from "@opencode/schema/websearch"
|
||||
import type { Config } from "@opencode/schema/config"
|
||||
|
||||
@@ -361,6 +361,15 @@ export type SessionContextInput = { readonly sessionID: Session.ID }
|
||||
export type SessionContextOutput = ReadonlyArray<SessionMessage.Info>
|
||||
export type SessionContextOperation<E = never> = (input: SessionContextInput) => Effect.Effect<SessionContextOutput, E>
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: Session.ID
|
||||
readonly from?: SessionMessage.ID | undefined
|
||||
readonly to?: SessionMessage.ID | undefined
|
||||
readonly context?: number | undefined
|
||||
}
|
||||
export type SessionDiffOutput = ReadonlyArray<FileDiff.Info>
|
||||
export type SessionDiffOperation<E = never> = (input: SessionDiffInput) => Effect.Effect<SessionDiffOutput, E>
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: Session.ID }
|
||||
export type SessionInboxListOutput = ReadonlyArray<SessionInbox.Info>
|
||||
export type SessionInboxListOperation<E = never> = (
|
||||
@@ -1150,6 +1159,7 @@ export interface SessionApi<E = never> {
|
||||
readonly commit: SessionRevertCommitOperation<E>
|
||||
}
|
||||
readonly context: SessionContextOperation<E>
|
||||
readonly diff: SessionDiffOperation<E>
|
||||
readonly inbox: {
|
||||
readonly list: SessionInboxListOperation<E>
|
||||
readonly cancel: SessionInboxCancelOperation<E>
|
||||
@@ -1479,16 +1489,9 @@ export type ProjectUpdateInput = {
|
||||
export type ProjectUpdateOutput = Project.Info
|
||||
export type ProjectUpdateOperation<E = never> = (input: ProjectUpdateInput) => Effect.Effect<ProjectUpdateOutput, E>
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type ProjectCurrentOutput = Project.Current
|
||||
export type ProjectCurrentOperation<E = never> = (input?: ProjectCurrentInput) => Effect.Effect<ProjectCurrentOutput, E>
|
||||
|
||||
export interface ProjectApi<E = never> {
|
||||
readonly list: ProjectListOperation<E>
|
||||
readonly update: ProjectUpdateOperation<E>
|
||||
readonly current: ProjectCurrentOperation<E>
|
||||
}
|
||||
|
||||
export type FormRequestListInput = {
|
||||
@@ -1984,37 +1987,32 @@ export interface ReferenceApi<E = never> {
|
||||
readonly list: ReferenceListOperation<E>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type WorktreeListInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeListOutput = Worktree.List
|
||||
export type WorktreeListOperation<E = never> = (input?: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
export type WorktreeListOperation<E = never> = (input: WorktreeListInput) => Effect.Effect<WorktreeListOutput, E>
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly strategy?: Worktree.StrategyID | undefined
|
||||
readonly projectID: Project.ID
|
||||
readonly from?: AbsolutePath | undefined
|
||||
readonly branch?: string | undefined
|
||||
readonly directory?: AbsolutePath | undefined
|
||||
readonly name?: string | undefined
|
||||
}
|
||||
export type WorktreeCreateOutput = Worktree.Info
|
||||
export type WorktreeCreateOperation<E = never> = (input?: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
export type WorktreeCreateOperation<E = never> = (input: WorktreeCreateInput) => Effect.Effect<WorktreeCreateOutput, E>
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
readonly projectID: Project.ID
|
||||
readonly directory: AbsolutePath
|
||||
readonly force: boolean
|
||||
}
|
||||
export type WorktreeRemoveOutput = void
|
||||
export type WorktreeRemoveOperation<E = never> = (input: WorktreeRemoveInput) => Effect.Effect<WorktreeRemoveOutput, E>
|
||||
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}
|
||||
export type WorktreeRefreshInput = { readonly projectID: Project.ID }
|
||||
export type WorktreeRefreshOutput = void
|
||||
export type WorktreeRefreshOperation<E = never> = (
|
||||
input?: WorktreeRefreshInput,
|
||||
input: WorktreeRefreshInput,
|
||||
) => Effect.Effect<WorktreeRefreshOutput, E>
|
||||
|
||||
export interface WorktreeApi<E = never> {
|
||||
|
||||
@@ -68,6 +68,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -151,8 +153,6 @@ import type {
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
FormRequestListOutput,
|
||||
FormListInput,
|
||||
@@ -599,6 +599,17 @@ const EndpointSessionContext = (raw: RawClient["server.session"]) => (input: Ses
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionDiff = (raw: RawClient["server.session"]) => (input: SessionDiffInput) =>
|
||||
preserveEffect<SessionDiffOutput>()(
|
||||
raw["session.diff"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { from: input["from"], to: input["to"], context: input["context"] },
|
||||
}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
),
|
||||
)
|
||||
|
||||
const EndpointSessionInboxList = (raw: RawClient["server.session"]) => (input: SessionInboxListInput) =>
|
||||
preserveEffect<SessionInboxListOutput>()(
|
||||
raw["session.inbox.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
@@ -738,6 +749,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
|
||||
commit: EndpointSessionRevertCommit(raw),
|
||||
},
|
||||
context: EndpointSessionContext(raw),
|
||||
diff: EndpointSessionDiff(raw),
|
||||
inbox: {
|
||||
list: EndpointSessionInboxList(raw),
|
||||
cancel: EndpointSessionInboxCancel(raw),
|
||||
@@ -1015,15 +1027,9 @@ const EndpointProjectUpdate = (raw: RawClient["server.project"]) => (input: Proj
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointProjectCurrent = (raw: RawClient["server.project"]) => (input?: ProjectCurrentInput) =>
|
||||
preserveEffect<ProjectCurrentOutput>()(
|
||||
raw["project.current"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupProject = (raw: RawClient["server.project"]) => ({
|
||||
list: EndpointProjectList(raw),
|
||||
update: EndpointProjectUpdate(raw),
|
||||
current: EndpointProjectCurrent(raw),
|
||||
})
|
||||
|
||||
const EndpointFormRequestList = (raw: RawClient["server.form"]) => (input?: FormRequestListInput) =>
|
||||
@@ -1453,21 +1459,20 @@ const EndpointReferenceList = (raw: RawClient["server.reference"]) => (input?: R
|
||||
|
||||
const adaptGroupReference = (raw: RawClient["server.reference"]) => ({ list: EndpointReferenceList(raw) })
|
||||
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input?: WorktreeListInput) =>
|
||||
const EndpointWorktreeList = (raw: RawClient["server.worktree"]) => (input: WorktreeListInput) =>
|
||||
preserveEffect<WorktreeListOutput>()(
|
||||
raw["worktree.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.list"]({ query: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: WorktreeCreateInput) =>
|
||||
const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input: WorktreeCreateInput) =>
|
||||
preserveEffect<WorktreeCreateOutput>()(
|
||||
raw["worktree.create"]({
|
||||
query: { location: input?.["location"] },
|
||||
payload: {
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
projectID: input["projectID"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
@@ -1475,14 +1480,13 @@ const EndpointWorktreeCreate = (raw: RawClient["server.worktree"]) => (input?: W
|
||||
const EndpointWorktreeRemove = (raw: RawClient["server.worktree"]) => (input: WorktreeRemoveInput) =>
|
||||
preserveEffect<WorktreeRemoveOutput>()(
|
||||
raw["worktree.remove"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { directory: input["directory"], force: input["force"] },
|
||||
payload: { projectID: input["projectID"], directory: input["directory"], force: input["force"] },
|
||||
}).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input?: WorktreeRefreshInput) =>
|
||||
const EndpointWorktreeRefresh = (raw: RawClient["server.worktree"]) => (input: WorktreeRefreshInput) =>
|
||||
preserveEffect<WorktreeRefreshOutput>()(
|
||||
raw["worktree.refresh"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
raw["worktree.refresh"]({ payload: { projectID: input["projectID"] } }).pipe(Effect.mapError(mapClientError)),
|
||||
)
|
||||
|
||||
const adaptGroupWorktree = (raw: RawClient["server.worktree"]) => ({
|
||||
|
||||
@@ -62,6 +62,8 @@ import type {
|
||||
SessionRevertCommitOutput,
|
||||
SessionContextInput,
|
||||
SessionContextOutput,
|
||||
SessionDiffInput,
|
||||
SessionDiffOutput,
|
||||
SessionInboxListInput,
|
||||
SessionInboxListOutput,
|
||||
SessionInboxCancelInput,
|
||||
@@ -145,8 +147,6 @@ import type {
|
||||
ProjectListOutput,
|
||||
ProjectUpdateInput,
|
||||
ProjectUpdateOutput,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
FormRequestListInput,
|
||||
FormRequestListOutput,
|
||||
FormListInput,
|
||||
@@ -849,6 +849,18 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
diff: (input: SessionDiffInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionDiffOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`,
|
||||
query: { from: input["from"], to: input["to"], context: input["context"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401, 404, 500],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
inbox: {
|
||||
list: (input: SessionInboxListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: SessionInboxListOutput }>(
|
||||
@@ -1382,18 +1394,6 @@ export function make(options: ClientOptions) {
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
current: (input?: ProjectCurrentInput, requestOptions?: RequestOptions) =>
|
||||
request<ProjectCurrentOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/project/current`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
form: {
|
||||
request: {
|
||||
@@ -1981,33 +1981,32 @@ export function make(options: ClientOptions) {
|
||||
),
|
||||
},
|
||||
worktree: {
|
||||
list: (input?: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
list: (input: WorktreeListInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
query: { projectID: input["projectID"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input?: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
create: (input: WorktreeCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input?.["location"] },
|
||||
body: {
|
||||
strategy: input?.["strategy"],
|
||||
from: input?.["from"],
|
||||
branch: input?.["branch"],
|
||||
directory: input?.["directory"],
|
||||
name: input?.["name"],
|
||||
projectID: input["projectID"],
|
||||
from: input["from"],
|
||||
branch: input["branch"],
|
||||
directory: input["directory"],
|
||||
name: input["name"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
@@ -2017,22 +2016,21 @@ export function make(options: ClientOptions) {
|
||||
{
|
||||
method: "DELETE",
|
||||
path: `/api/worktree`,
|
||||
query: { location: input["location"] },
|
||||
body: { directory: input["directory"], force: input["force"] },
|
||||
body: { projectID: input["projectID"], directory: input["directory"], force: input["force"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
refresh: (input?: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
refresh: (input: WorktreeRefreshInput, requestOptions?: RequestOptions) =>
|
||||
request<WorktreeRefreshOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/worktree/refresh`,
|
||||
query: { location: input?.["location"] },
|
||||
body: { projectID: input["projectID"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [400, 401],
|
||||
declaredStatuses: [400, 401, 404],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
|
||||
@@ -147,6 +147,14 @@ export type SessionProviderContextProvenance = {
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionMessageIdle = {
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
type: "idle"
|
||||
outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -302,8 +310,6 @@ export type ProjectCommands = { start?: string }
|
||||
|
||||
export type ProjectTime = { created: number; updated: number; initialized?: number }
|
||||
|
||||
export type ProjectCurrent = { id: string; directory: string; canonical: string }
|
||||
|
||||
export type FormMetadata = { [x: string]: JsonValue }
|
||||
|
||||
export type FormValue = string | number | boolean | Array<string>
|
||||
@@ -2210,6 +2216,7 @@ export type SessionMessageInfo =
|
||||
| SessionMessageShell
|
||||
| SessionMessageAssistant
|
||||
| SessionMessageCompaction
|
||||
| SessionMessageIdle
|
||||
|
||||
export type SessionMessageContentUpdated = {
|
||||
id: string
|
||||
@@ -3222,6 +3229,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["info"]
|
||||
@@ -3532,6 +3546,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["messages"]
|
||||
@@ -3842,6 +3863,13 @@ export type SessionImportInput = {
|
||||
}
|
||||
}
|
||||
)
|
||||
| {
|
||||
readonly id: string
|
||||
readonly metadata?: { readonly [x: string]: JsonValue }
|
||||
readonly time: { readonly created: number }
|
||||
readonly type: "idle"
|
||||
readonly outcome: "succeeded" | "failed" | "interrupted"
|
||||
}
|
||||
>
|
||||
readonly location?: { readonly directory: string; readonly workspaceID?: string } | null
|
||||
}["location"]
|
||||
@@ -4331,6 +4359,27 @@ export type SessionContextInput = { readonly sessionID: { readonly sessionID: st
|
||||
|
||||
export type SessionContextOutput = { data: Array<SessionMessageInfo> }["data"]
|
||||
|
||||
export type SessionDiffInput = {
|
||||
readonly sessionID: { readonly sessionID: string }["sessionID"]
|
||||
readonly from?: {
|
||||
readonly from?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["from"]
|
||||
readonly to?: {
|
||||
readonly from?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["to"]
|
||||
readonly context?: {
|
||||
readonly from?: string | undefined
|
||||
readonly to?: string | undefined
|
||||
readonly context?: number | undefined
|
||||
}["context"]
|
||||
}
|
||||
|
||||
export type SessionDiffOutput = { data: Array<FileDiffInfo> }["data"]
|
||||
|
||||
export type SessionInboxListInput = { readonly sessionID: { readonly sessionID: string }["sessionID"] }
|
||||
|
||||
export type SessionInboxListOutput = { data: Array<SessionInboxInfo> }["data"]
|
||||
@@ -4861,14 +4910,6 @@ export type ProjectUpdateInput = {
|
||||
|
||||
export type ProjectUpdateOutput = Project
|
||||
|
||||
export type ProjectCurrentInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
|
||||
export type ProjectCurrentOutput = ProjectCurrent
|
||||
|
||||
export type FormRequestListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
@@ -6263,48 +6304,41 @@ export type ReferenceListOutput = {
|
||||
data: Array<ReferenceInfo>
|
||||
}
|
||||
|
||||
export type WorktreeListInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
export type WorktreeListInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeListOutput = WorktreeList
|
||||
|
||||
export type WorktreeCreateInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly strategy?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: {
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["strategy"]
|
||||
}["projectID"]
|
||||
readonly from?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["from"]
|
||||
readonly branch?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["branch"]
|
||||
readonly directory?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
readonly name?: string
|
||||
}["directory"]
|
||||
readonly name?: {
|
||||
readonly strategy?: string
|
||||
readonly projectID: string
|
||||
readonly from?: string
|
||||
readonly branch?: string
|
||||
readonly directory?: string
|
||||
@@ -6315,20 +6349,14 @@ export type WorktreeCreateInput = {
|
||||
export type WorktreeCreateOutput = WorktreeInfo
|
||||
|
||||
export type WorktreeRemoveInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
readonly directory: { readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly directory: string; readonly force: boolean }["force"]
|
||||
readonly projectID: { readonly projectID: string; readonly directory: string; readonly force: boolean }["projectID"]
|
||||
readonly directory: { readonly projectID: string; readonly directory: string; readonly force: boolean }["directory"]
|
||||
readonly force: { readonly projectID: string; readonly directory: string; readonly force: boolean }["force"]
|
||||
}
|
||||
|
||||
export type WorktreeRemoveOutput = void
|
||||
|
||||
export type WorktreeRefreshInput = {
|
||||
readonly location?: {
|
||||
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
|
||||
}["location"]
|
||||
}
|
||||
export type WorktreeRefreshInput = { readonly projectID: { readonly projectID: string }["projectID"] }
|
||||
|
||||
export type WorktreeRefreshOutput = void
|
||||
|
||||
|
||||
@@ -1028,6 +1028,18 @@ export function createData(config: CreateDataInput) {
|
||||
if (currentAssistant) currentAssistant.retry = undefined
|
||||
})
|
||||
if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") return
|
||||
// Mirror the projected idle marker so turn boundaries match before the next message read.
|
||||
message.insert(event.data.sessionID, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome:
|
||||
event.type === "session.execution.succeeded"
|
||||
? "succeeded"
|
||||
: event.type === "session.execution.failed"
|
||||
? "failed"
|
||||
: "interrupted",
|
||||
time: { created: event.created },
|
||||
})
|
||||
// An event can overtake the first read; queue a revalidation when that read is still active.
|
||||
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
|
||||
result.session.invalidate(event.data.sessionID)
|
||||
|
||||
@@ -54,7 +54,7 @@ test("exposes every standard HTTP API group", () => {
|
||||
expect(Object.keys(client.experimental)).toEqual(["persistentPty"])
|
||||
expect(client.experimental.persistentPty.read).toBeFunction()
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "timeout", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update", "current"])
|
||||
expect(Object.keys(client.project)).toEqual(["list", "update"])
|
||||
expect(Object.keys(client.worktree)).toEqual(["list", "create", "remove", "refresh"])
|
||||
})
|
||||
|
||||
@@ -336,7 +336,7 @@ test("file.read returns binary content from the public HTTP contract", async ()
|
||||
)
|
||||
})
|
||||
|
||||
test("all worktree operations use location-based routes without a project parameter", async () => {
|
||||
test("all worktree operations require a project ID", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
@@ -350,38 +350,41 @@ test("all worktree operations use location-based routes without a project parame
|
||||
},
|
||||
})
|
||||
|
||||
expect(await client.worktree.list()).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(await client.worktree.list({ projectID: "project" })).toEqual([{ directory: "/tmp/project" }])
|
||||
expect(
|
||||
await client.worktree.create({
|
||||
strategy: "git",
|
||||
projectID: "project",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
}),
|
||||
).toEqual({ directory: "/tmp/worktrees/api" })
|
||||
await client.worktree.remove({
|
||||
projectID: "project",
|
||||
directory: "/tmp/worktrees/api",
|
||||
force: false,
|
||||
})
|
||||
await client.worktree.refresh()
|
||||
await client.worktree.refresh({ projectID: "project" })
|
||||
|
||||
expect(requests.map((request) => [request.method, request.url])).toEqual([
|
||||
["GET", "http://localhost:3000/api/worktree"],
|
||||
["GET", "http://localhost:3000/api/worktree?projectID=project"],
|
||||
["POST", "http://localhost:3000/api/worktree"],
|
||||
["DELETE", "http://localhost:3000/api/worktree"],
|
||||
["POST", "http://localhost:3000/api/worktree/refresh"],
|
||||
])
|
||||
expect(await requests[1]?.json()).toEqual({
|
||||
strategy: "git",
|
||||
projectID: "project",
|
||||
directory: "/tmp/worktrees",
|
||||
name: "api",
|
||||
})
|
||||
expect(await requests[2]?.json()).toEqual({ directory: "/tmp/worktrees/api", force: false })
|
||||
expect(await requests[2]?.json()).toEqual({ projectID: "project", directory: "/tmp/worktrees/api", force: false })
|
||||
expect(await requests[3]?.json()).toEqual({ projectID: "project" })
|
||||
})
|
||||
|
||||
test("worktree operations send the configuration location separately from their payload", async () => {
|
||||
test("worktree operations use the explicit project even with default location headers", async () => {
|
||||
const requests: Request[] = []
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
headers: { "x-opencode-directory": "/unrelated" },
|
||||
fetch: async (input, init) => {
|
||||
const request = new Request(input, init)
|
||||
requests.push(request)
|
||||
@@ -391,24 +394,24 @@ test("worktree operations send the configuration location separately from their
|
||||
return Response.json({ directory: "/configured/task" })
|
||||
},
|
||||
})
|
||||
expect(await client.worktree.create({ location: { directory: "/repo/nested" }, name: "task" })).toEqual({
|
||||
expect(await client.worktree.create({ projectID: "project", name: "task" })).toEqual({
|
||||
directory: "/configured/task",
|
||||
})
|
||||
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[0]?.json()).toEqual({ name: "task" })
|
||||
expect(requests[0]?.url).toBe("http://localhost:3000/api/worktree")
|
||||
expect(await requests[0]?.json()).toEqual({ projectID: "project", name: "task" })
|
||||
await client.worktree.remove({
|
||||
location: { directory: "/repo/nested" },
|
||||
projectID: "project",
|
||||
directory: "/configured/task",
|
||||
force: true,
|
||||
})
|
||||
await client.worktree.refresh({ location: { directory: "/repo/nested" } })
|
||||
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await requests[1]?.json()).toEqual({ directory: "/configured/task", force: true })
|
||||
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(await client.worktree.list({ location: { directory: "/repo/nested" } })).toEqual([
|
||||
await client.worktree.refresh({ projectID: "project" })
|
||||
expect(requests[1]?.url).toBe("http://localhost:3000/api/worktree")
|
||||
expect(await requests[1]?.json()).toEqual({ projectID: "project", directory: "/configured/task", force: true })
|
||||
expect(requests[2]?.url).toBe("http://localhost:3000/api/worktree/refresh")
|
||||
expect(await client.worktree.list({ projectID: "project" })).toEqual([
|
||||
{ directory: "/configured/task", strategy: "git" },
|
||||
])
|
||||
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?location%5Bdirectory%5D=%2Frepo%2Fnested")
|
||||
expect(requests[3]?.url).toBe("http://localhost:3000/api/worktree?projectID=project")
|
||||
})
|
||||
|
||||
test("workspace.destroy returns the transition result", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/codemode",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"description": "Effect-native confined code execution over schema-described tools",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+97
-180
@@ -9,7 +9,7 @@
|
||||
"/api/health": {
|
||||
"get": {
|
||||
"tags": ["health"],
|
||||
"operationId": "v2.health.get",
|
||||
"operationId": "health.get",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -70,7 +70,7 @@
|
||||
"/api/server": {
|
||||
"get": {
|
||||
"tags": ["server"],
|
||||
"operationId": "v2.server.get",
|
||||
"operationId": "server.get",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -122,7 +122,7 @@
|
||||
"/api/location": {
|
||||
"get": {
|
||||
"tags": ["location"],
|
||||
"operationId": "v2.location.get",
|
||||
"operationId": "location.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -205,7 +205,7 @@
|
||||
"/api/agent": {
|
||||
"get": {
|
||||
"tags": ["agent"],
|
||||
"operationId": "v2.agent.list",
|
||||
"operationId": "agent.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -301,7 +301,7 @@
|
||||
"/api/plugin": {
|
||||
"get": {
|
||||
"tags": ["plugin"],
|
||||
"operationId": "v2.plugin.list",
|
||||
"operationId": "plugin.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -397,7 +397,7 @@
|
||||
"/api/session": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.list",
|
||||
"operationId": "session.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "workspace",
|
||||
@@ -606,7 +606,7 @@
|
||||
},
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.create",
|
||||
"operationId": "session.create",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -713,7 +713,7 @@
|
||||
"/api/session/active": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.active",
|
||||
"operationId": "session.active",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -767,7 +767,7 @@
|
||||
"/api/session/{sessionID}": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.get",
|
||||
"operationId": "session.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -845,7 +845,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.remove",
|
||||
"operationId": "session.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -911,7 +911,7 @@
|
||||
"/api/session/{sessionID}/fork": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.fork",
|
||||
"operationId": "session.fork",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1022,7 +1022,7 @@
|
||||
"/api/session/{sessionID}/agent": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.switchAgent",
|
||||
"operationId": "session.switchAgent",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1105,7 +1105,7 @@
|
||||
"/api/session/{sessionID}/model": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.switchModel",
|
||||
"operationId": "session.switchModel",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1188,7 +1188,7 @@
|
||||
"/api/session/{sessionID}/rename": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.rename",
|
||||
"operationId": "session.rename",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1271,7 +1271,7 @@
|
||||
"/api/session/{sessionID}/move": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.move",
|
||||
"operationId": "session.move",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1378,7 +1378,7 @@
|
||||
"/api/session/{sessionID}/prompt": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.prompt",
|
||||
"operationId": "session.prompt",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1528,7 +1528,7 @@
|
||||
"/api/session/{sessionID}/command": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.command",
|
||||
"operationId": "session.command",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1733,7 +1733,7 @@
|
||||
"/api/session/{sessionID}/skill": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.skill",
|
||||
"operationId": "session.skill",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1844,7 +1844,7 @@
|
||||
"/api/session/{sessionID}/synthetic": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.synthetic",
|
||||
"operationId": "session.synthetic",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -1950,7 +1950,7 @@
|
||||
"/api/session/{sessionID}/shell": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.shell",
|
||||
"operationId": "session.shell",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2048,7 +2048,7 @@
|
||||
"/api/session/{sessionID}/compact": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.compact",
|
||||
"operationId": "session.compact",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2166,7 +2166,7 @@
|
||||
"/api/session/{sessionID}/wait": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.wait",
|
||||
"operationId": "session.wait",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2242,7 +2242,7 @@
|
||||
"/api/session/{sessionID}/revert/stage": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.revert.stage",
|
||||
"operationId": "session.revert.stage",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2377,7 +2377,7 @@
|
||||
"/api/session/{sessionID}/revert/clear": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.revert.clear",
|
||||
"operationId": "session.revert.clear",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2462,7 +2462,7 @@
|
||||
"/api/session/{sessionID}/revert/commit": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.revert.commit",
|
||||
"operationId": "session.revert.commit",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2537,7 +2537,7 @@
|
||||
"/api/session/{sessionID}/context": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.context",
|
||||
"operationId": "session.context",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2630,7 +2630,7 @@
|
||||
"/api/session/{sessionID}/instructions/entries": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.instructions.entry.list",
|
||||
"operationId": "session.instructions.entry.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2713,7 +2713,7 @@
|
||||
"/api/session/{sessionID}/instructions/entries/{key}": {
|
||||
"put": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.instructions.entry.put",
|
||||
"operationId": "session.instructions.entry.put",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2800,7 +2800,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.instructions.entry.remove",
|
||||
"operationId": "session.instructions.entry.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -2874,7 +2874,7 @@
|
||||
"/api/experimental/session/{sessionID}/log": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.log",
|
||||
"operationId": "session.log",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -3059,7 +3059,7 @@
|
||||
"/api/session/{sessionID}/interrupt": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.interrupt",
|
||||
"operationId": "session.interrupt",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -3125,7 +3125,7 @@
|
||||
"/api/session/{sessionID}/background": {
|
||||
"post": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.background",
|
||||
"operationId": "session.background",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -3191,7 +3191,7 @@
|
||||
"/api/session/{sessionID}/message/{messageID}": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.session.message",
|
||||
"operationId": "session.message",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -3287,7 +3287,7 @@
|
||||
"/api/session/{sessionID}/message": {
|
||||
"get": {
|
||||
"tags": ["session"],
|
||||
"operationId": "v2.message.list",
|
||||
"operationId": "message.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -3426,7 +3426,7 @@
|
||||
"/api/model": {
|
||||
"get": {
|
||||
"tags": ["model"],
|
||||
"operationId": "v2.model.list",
|
||||
"operationId": "model.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -3532,7 +3532,7 @@
|
||||
"/api/model/default": {
|
||||
"get": {
|
||||
"tags": ["model"],
|
||||
"operationId": "v2.model.default",
|
||||
"operationId": "model.default",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -3642,7 +3642,7 @@
|
||||
"/api/generate": {
|
||||
"post": {
|
||||
"tags": ["generate"],
|
||||
"operationId": "v2.generate.text",
|
||||
"operationId": "generate.text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -3769,7 +3769,7 @@
|
||||
"/api/provider": {
|
||||
"get": {
|
||||
"tags": ["provider"],
|
||||
"operationId": "v2.provider.list",
|
||||
"operationId": "provider.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -3875,7 +3875,7 @@
|
||||
"/api/provider/{providerID}": {
|
||||
"get": {
|
||||
"tags": ["provider"],
|
||||
"operationId": "v2.provider.get",
|
||||
"operationId": "provider.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "providerID",
|
||||
@@ -3996,7 +3996,7 @@
|
||||
"/api/integration": {
|
||||
"get": {
|
||||
"tags": ["integration"],
|
||||
"operationId": "v2.integration.list",
|
||||
"operationId": "integration.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -4092,7 +4092,7 @@
|
||||
"/api/integration/{integrationID}": {
|
||||
"get": {
|
||||
"tags": ["integration"],
|
||||
"operationId": "v2.integration.get",
|
||||
"operationId": "integration.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "integrationID",
|
||||
@@ -4200,7 +4200,7 @@
|
||||
"/api/integration/{integrationID}/connect/key": {
|
||||
"post": {
|
||||
"tags": ["integration"],
|
||||
"operationId": "v2.integration.connect.key",
|
||||
"operationId": "integration.connect.key",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "integrationID",
|
||||
@@ -4318,7 +4318,7 @@
|
||||
"/api/integration/{integrationID}/connect/oauth": {
|
||||
"post": {
|
||||
"tags": ["integration"],
|
||||
"operationId": "v2.integration.connect.oauth",
|
||||
"operationId": "integration.connect.oauth",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "integrationID",
|
||||
@@ -4459,7 +4459,7 @@
|
||||
"/api/integration/attempt/{attemptID}": {
|
||||
"get": {
|
||||
"tags": ["integration"],
|
||||
"operationId": "v2.integration.attempt.status",
|
||||
"operationId": "integration.attempt.status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "attemptID",
|
||||
@@ -4558,7 +4558,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["integration"],
|
||||
"operationId": "v2.integration.attempt.cancel",
|
||||
"operationId": "integration.attempt.cancel",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "attemptID",
|
||||
@@ -4642,7 +4642,7 @@
|
||||
"/api/integration/attempt/{attemptID}/complete": {
|
||||
"post": {
|
||||
"tags": ["integration"],
|
||||
"operationId": "v2.integration.attempt.complete",
|
||||
"operationId": "integration.attempt.complete",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "attemptID",
|
||||
@@ -4756,7 +4756,7 @@
|
||||
"/api/mcp": {
|
||||
"get": {
|
||||
"tags": ["mcp"],
|
||||
"operationId": "v2.mcp.list",
|
||||
"operationId": "mcp.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -4852,7 +4852,7 @@
|
||||
"/api/mcp/resource": {
|
||||
"get": {
|
||||
"tags": ["mcp"],
|
||||
"operationId": "v2.mcp.resource.catalog",
|
||||
"operationId": "mcp.resource.catalog",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -4945,7 +4945,7 @@
|
||||
"/api/credential/{credentialID}": {
|
||||
"patch": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "v2.credential.update",
|
||||
"operationId": "credential.update",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "credentialID",
|
||||
@@ -5044,7 +5044,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["credential"],
|
||||
"operationId": "v2.credential.remove",
|
||||
"operationId": "credential.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "credentialID",
|
||||
@@ -5128,7 +5128,7 @@
|
||||
"/api/project": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
"operationId": "v2.project.list",
|
||||
"operationId": "project.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -5170,93 +5170,10 @@
|
||||
"summary": "List projects"
|
||||
}
|
||||
},
|
||||
"/api/project/current": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
"operationId": "v2.project.current",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directory": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"workspace": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"required": false,
|
||||
"style": "deepObject",
|
||||
"explode": true
|
||||
}
|
||||
],
|
||||
"security": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Project.Current",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Project.Current"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "InvalidRequestError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/InvalidRequestError"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "UnauthorizedError",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnauthorizedError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Resolve the project for the requested location.",
|
||||
"summary": "Get current project"
|
||||
}
|
||||
},
|
||||
"/api/project/{projectID}/directories": {
|
||||
"get": {
|
||||
"tags": ["project"],
|
||||
"operationId": "v2.project.directories",
|
||||
"operationId": "project.directories",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -5347,7 +5264,7 @@
|
||||
"/api/form/request": {
|
||||
"get": {
|
||||
"tags": ["form"],
|
||||
"operationId": "v2.form.request.list",
|
||||
"operationId": "form.request.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -5450,7 +5367,7 @@
|
||||
"/api/session/{sessionID}/form": {
|
||||
"get": {
|
||||
"tags": ["form"],
|
||||
"operationId": "v2.session.form.list",
|
||||
"operationId": "session.form.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -5533,7 +5450,7 @@
|
||||
},
|
||||
"post": {
|
||||
"tags": ["form"],
|
||||
"operationId": "v2.session.form.create",
|
||||
"operationId": "session.form.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -5642,7 +5559,7 @@
|
||||
"/api/session/{sessionID}/form/{formID}": {
|
||||
"get": {
|
||||
"tags": ["form"],
|
||||
"operationId": "v2.session.form.get",
|
||||
"operationId": "session.form.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -5740,7 +5657,7 @@
|
||||
"/api/session/{sessionID}/form/{formID}/state": {
|
||||
"get": {
|
||||
"tags": ["form"],
|
||||
"operationId": "v2.session.form.state",
|
||||
"operationId": "session.form.state",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -5831,7 +5748,7 @@
|
||||
"/api/session/{sessionID}/form/{formID}/reply": {
|
||||
"post": {
|
||||
"tags": ["form"],
|
||||
"operationId": "v2.session.form.reply",
|
||||
"operationId": "session.form.reply",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -5935,7 +5852,7 @@
|
||||
"/api/session/{sessionID}/form/{formID}/cancel": {
|
||||
"post": {
|
||||
"tags": ["form"],
|
||||
"operationId": "v2.session.form.cancel",
|
||||
"operationId": "session.form.cancel",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -6022,7 +5939,7 @@
|
||||
"/api/permission/request": {
|
||||
"get": {
|
||||
"tags": ["permission"],
|
||||
"operationId": "v2.permission.request.list",
|
||||
"operationId": "permission.request.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -6118,7 +6035,7 @@
|
||||
"/api/permission/saved": {
|
||||
"get": {
|
||||
"tags": ["permission"],
|
||||
"operationId": "v2.permission.saved.list",
|
||||
"operationId": "permission.saved.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -6186,7 +6103,7 @@
|
||||
"/api/permission/saved/{id}": {
|
||||
"delete": {
|
||||
"tags": ["permission"],
|
||||
"operationId": "v2.permission.saved.remove",
|
||||
"operationId": "permission.saved.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -6230,7 +6147,7 @@
|
||||
"/api/session/{sessionID}/permission": {
|
||||
"post": {
|
||||
"tags": ["permission"],
|
||||
"operationId": "v2.session.permission.create",
|
||||
"operationId": "session.permission.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -6383,7 +6300,7 @@
|
||||
},
|
||||
"get": {
|
||||
"tags": ["permission"],
|
||||
"operationId": "v2.session.permission.list",
|
||||
"operationId": "session.permission.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -6466,7 +6383,7 @@
|
||||
"/api/session/{sessionID}/permission/{requestID}": {
|
||||
"get": {
|
||||
"tags": ["permission"],
|
||||
"operationId": "v2.session.permission.get",
|
||||
"operationId": "session.permission.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -6562,7 +6479,7 @@
|
||||
"/api/session/{sessionID}/permission/{requestID}/reply": {
|
||||
"post": {
|
||||
"tags": ["permission"],
|
||||
"operationId": "v2.session.permission.reply",
|
||||
"operationId": "session.permission.reply",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -6671,7 +6588,7 @@
|
||||
"/api/fs/read/*": {
|
||||
"get": {
|
||||
"tags": ["filesystem"],
|
||||
"operationId": "v2.fs.read",
|
||||
"operationId": "fs.read",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -6755,7 +6672,7 @@
|
||||
"/api/fs/list": {
|
||||
"get": {
|
||||
"tags": ["filesystem"],
|
||||
"operationId": "v2.fs.list",
|
||||
"operationId": "fs.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -6866,7 +6783,7 @@
|
||||
"/api/fs/find": {
|
||||
"get": {
|
||||
"tags": ["filesystem"],
|
||||
"operationId": "v2.fs.find",
|
||||
"operationId": "fs.find",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -6994,7 +6911,7 @@
|
||||
"/api/command": {
|
||||
"get": {
|
||||
"tags": ["command"],
|
||||
"operationId": "v2.command.list",
|
||||
"operationId": "command.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -7090,7 +7007,7 @@
|
||||
"/api/skill": {
|
||||
"get": {
|
||||
"tags": ["skill"],
|
||||
"operationId": "v2.skill.list",
|
||||
"operationId": "skill.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -7186,7 +7103,7 @@
|
||||
"/api/event": {
|
||||
"get": {
|
||||
"tags": ["event"],
|
||||
"operationId": "v2.event.subscribe",
|
||||
"operationId": "event.subscribe",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -7309,7 +7226,7 @@
|
||||
"/api/pty": {
|
||||
"get": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "v2.pty.list",
|
||||
"operationId": "pty.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -7403,7 +7320,7 @@
|
||||
},
|
||||
"post": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "v2.pty.create",
|
||||
"operationId": "pty.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -7530,7 +7447,7 @@
|
||||
"/api/pty/{ptyID}": {
|
||||
"get": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "v2.pty.get",
|
||||
"operationId": "pty.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ptyID",
|
||||
@@ -7644,7 +7561,7 @@
|
||||
},
|
||||
"put": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "v2.pty.update",
|
||||
"operationId": "pty.update",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ptyID",
|
||||
@@ -7797,7 +7714,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "v2.pty.remove",
|
||||
"operationId": "pty.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ptyID",
|
||||
@@ -7896,7 +7813,7 @@
|
||||
"/api/pty/{ptyID}/connect-token": {
|
||||
"post": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "v2.pty.connect.token",
|
||||
"operationId": "pty.connect.token",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ptyID",
|
||||
@@ -8022,7 +7939,7 @@
|
||||
"/api/pty/{ptyID}/connect": {
|
||||
"get": {
|
||||
"tags": ["pty"],
|
||||
"operationId": "v2.pty.connect",
|
||||
"operationId": "pty.connect",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "ptyID",
|
||||
@@ -8127,7 +8044,7 @@
|
||||
"/api/shell": {
|
||||
"get": {
|
||||
"tags": ["shell"],
|
||||
"operationId": "v2.shell.list",
|
||||
"operationId": "shell.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -8221,7 +8138,7 @@
|
||||
},
|
||||
"post": {
|
||||
"tags": ["shell"],
|
||||
"operationId": "v2.shell.create",
|
||||
"operationId": "shell.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -8345,7 +8262,7 @@
|
||||
"/api/shell/{id}": {
|
||||
"get": {
|
||||
"tags": ["shell"],
|
||||
"operationId": "v2.shell.get",
|
||||
"operationId": "shell.get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -8459,7 +8376,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["shell"],
|
||||
"operationId": "v2.shell.remove",
|
||||
"operationId": "shell.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -8558,7 +8475,7 @@
|
||||
"/api/shell/{id}/timeout": {
|
||||
"patch": {
|
||||
"tags": ["shell"],
|
||||
"operationId": "v2.shell.timeout",
|
||||
"operationId": "shell.timeout",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -8696,7 +8613,7 @@
|
||||
"/api/shell/{id}/output": {
|
||||
"get": {
|
||||
"tags": ["shell"],
|
||||
"operationId": "v2.shell.output",
|
||||
"operationId": "shell.output",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
@@ -8864,7 +8781,7 @@
|
||||
"/api/question/request": {
|
||||
"get": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.question.request.list",
|
||||
"operationId": "question.request.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -8960,7 +8877,7 @@
|
||||
"/api/session/{sessionID}/question": {
|
||||
"get": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.session.question.list",
|
||||
"operationId": "session.question.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -9043,7 +8960,7 @@
|
||||
"/api/session/{sessionID}/question/{requestID}/reply": {
|
||||
"post": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.session.question.reply",
|
||||
"operationId": "session.question.reply",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -9135,7 +9052,7 @@
|
||||
"/api/session/{sessionID}/question/{requestID}/reject": {
|
||||
"post": {
|
||||
"tags": ["question"],
|
||||
"operationId": "v2.session.question.reject",
|
||||
"operationId": "session.question.reject",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "sessionID",
|
||||
@@ -9217,7 +9134,7 @@
|
||||
"/api/reference": {
|
||||
"get": {
|
||||
"tags": ["reference"],
|
||||
"operationId": "v2.reference.list",
|
||||
"operationId": "reference.list",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -9313,7 +9230,7 @@
|
||||
"/api/experimental/project/{projectID}/copy": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.create",
|
||||
"operationId": "projectCopy.create",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -9430,7 +9347,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.remove",
|
||||
"operationId": "projectCopy.remove",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -9539,7 +9456,7 @@
|
||||
"/api/experimental/project/{projectID}/copy/refresh": {
|
||||
"post": {
|
||||
"tags": ["projectCopy"],
|
||||
"operationId": "v2.projectCopy.refresh",
|
||||
"operationId": "projectCopy.refresh",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "projectID",
|
||||
@@ -9628,7 +9545,7 @@
|
||||
"/api/vcs/status": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
"operationId": "v2.vcs.status",
|
||||
"operationId": "vcs.status",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -9724,7 +9641,7 @@
|
||||
"/api/vcs/diff": {
|
||||
"get": {
|
||||
"tags": ["vcs"],
|
||||
"operationId": "v2.vcs.diff",
|
||||
"operationId": "vcs.diff",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
@@ -9843,7 +9760,7 @@
|
||||
"/api/debug/location": {
|
||||
"get": {
|
||||
"tags": ["debug"],
|
||||
"operationId": "v2.debug.location.list",
|
||||
"operationId": "debug.location.list",
|
||||
"parameters": [],
|
||||
"security": [],
|
||||
"responses": {
|
||||
@@ -9886,7 +9803,7 @@
|
||||
},
|
||||
"delete": {
|
||||
"tags": ["debug"],
|
||||
"operationId": "v2.debug.location.evict",
|
||||
"operationId": "debug.location.evict",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "location",
|
||||
|
||||
@@ -235,32 +235,32 @@ describe("OpenAPI.fromSpec", () => {
|
||||
path: "/api/fs/read/*",
|
||||
reason: "binary responses are not supported",
|
||||
})
|
||||
expect(toolAt(result.tools, "v2.health.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.session.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.session.create")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "health.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "session.get")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "session.create")).not.toBeUndefined()
|
||||
|
||||
const sessionGet = toolAt(result.tools, "v2.session.get")
|
||||
const sessionGet = toolAt(result.tools, "session.get")
|
||||
expect(Tool.isTool(sessionGet)).toBe(true)
|
||||
if (!Tool.isTool(sessionGet)) throw new Error("v2.session.get was not generated")
|
||||
if (!Tool.isTool(sessionGet)) throw new Error("session.get was not generated")
|
||||
expect(inputTypeScript(sessionGet)).toBe("{ sessionID: string }")
|
||||
expect(outputTypeScript(sessionGet)).toContain("id: string")
|
||||
expect(outputTypeScript(sessionGet)).toContain("additions: number")
|
||||
|
||||
const switchAgent = toolAt(result.tools, "v2.session.switchAgent")
|
||||
const switchAgent = toolAt(result.tools, "session.switchAgent")
|
||||
expect(Tool.isTool(switchAgent)).toBe(true)
|
||||
if (!Tool.isTool(switchAgent)) throw new Error("v2.session.switchAgent was not generated")
|
||||
if (!Tool.isTool(switchAgent)) throw new Error("session.switchAgent was not generated")
|
||||
expect(inputTypeScript(switchAgent)).toBe("{ sessionID: string; agent: string }")
|
||||
|
||||
const instructionPut = toolAt(result.tools, "v2.session.instructions.entry.put")
|
||||
const instructionPut = toolAt(result.tools, "session.instructions.entry.put")
|
||||
expect(Tool.isTool(instructionPut)).toBe(true)
|
||||
if (!Tool.isTool(instructionPut)) throw new Error("v2.session.instructions.entry.put was not generated")
|
||||
if (!Tool.isTool(instructionPut)) throw new Error("session.instructions.entry.put was not generated")
|
||||
expect(inputTypeScript(instructionPut)).toBe("{ sessionID: string; key: string; value: unknown }")
|
||||
expect(toolAt(result.tools, "v2_session_instructions_entry_put_2")).toBeUndefined()
|
||||
expect(Tool.isTool(toolAt(result.tools, "v2.pty.connect"))).toBe(false)
|
||||
expect(toolAt(result.tools, "v2.session.log")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.event.subscribe")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.fs.read")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "v2.pty.connect.token")).not.toBeUndefined()
|
||||
expect(toolAt(result.tools, "session_instructions_entry_put_2")).toBeUndefined()
|
||||
expect(Tool.isTool(toolAt(result.tools, "pty.connect"))).toBe(false)
|
||||
expect(toolAt(result.tools, "session.log")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "event.subscribe")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "fs.read")).toBeUndefined()
|
||||
expect(toolAt(result.tools, "pty.connect.token")).not.toBeUndefined()
|
||||
})
|
||||
|
||||
test("preserves operation path sanitization and collision handling", () => {
|
||||
@@ -978,7 +978,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
|
||||
expect(spec.security).toStrictEqual([])
|
||||
expect(isRecord(components.securitySchemes) ? Object.keys(components.securitySchemes) : []).toStrictEqual([])
|
||||
const health = toolAt(result.tools, "v2.health.get")
|
||||
const health = toolAt(result.tools, "health.get")
|
||||
const healthInput = Tool.isTool(health) && isRecord(health.input) ? health.input : undefined
|
||||
expect(healthInput).toMatchObject({ type: "object", properties: {} })
|
||||
const input = isRecord(healthInput) ? healthInput : {}
|
||||
@@ -1005,7 +1005,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
expect(result.value).toMatchObject({
|
||||
items: [
|
||||
{
|
||||
path: "tools.opencode.v2.health.get",
|
||||
path: "tools.opencode.health.get",
|
||||
description: "Check whether the API server is ready to accept requests.",
|
||||
},
|
||||
],
|
||||
@@ -1026,8 +1026,8 @@ describe("OpenAPI.fromSpec", () => {
|
||||
runtime
|
||||
.execute(
|
||||
`
|
||||
const existing = await tools.opencode.v2.session.get({ sessionID: "ses_123" })
|
||||
const created = await tools.opencode.v2.session.create({ id: "ses_456" })
|
||||
const existing = await tools.opencode.session.get({ sessionID: "ses_123" })
|
||||
const created = await tools.opencode.session.create({ id: "ses_456" })
|
||||
return { existing, created }
|
||||
`,
|
||||
)
|
||||
@@ -1047,8 +1047,8 @@ describe("OpenAPI.fromSpec", () => {
|
||||
|
||||
test("serializes deep-object query parameters from the opencode fixture", async () => {
|
||||
const client = recordingClient(() => json({ directory: "/tmp" }))
|
||||
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "v2.location.get")
|
||||
if (!Tool.isTool(location)) throw new Error("v2.location.get was not generated")
|
||||
const location = toolAt(OpenAPI.fromSpec({ spec: await opencodeSpec(), baseUrl }).tools, "location.get")
|
||||
if (!Tool.isTool(location)) throw new Error("location.get was not generated")
|
||||
|
||||
await Effect.runPromise(
|
||||
location
|
||||
@@ -1470,7 +1470,7 @@ describe("OpenAPI.fromSpec", () => {
|
||||
})
|
||||
|
||||
const result = await Effect.runPromise(
|
||||
runtime.execute("return await tools.opencode.v2.session.get({})").pipe(Effect.provide(layer)),
|
||||
runtime.execute("return await tools.opencode.session.get({})").pipe(Effect.provide(layer)),
|
||||
)
|
||||
|
||||
expect(result).toMatchObject({ ok: false })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-app",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/console-core",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-function",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-mail",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"dependencies": {
|
||||
"@jsx-email/all": "2.2.3",
|
||||
"@jsx-email/cli": "1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/console-support",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/core",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Config } from "../../config.js"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Location } from "../../location.js"
|
||||
import { AbsolutePath } from "../../schema.js"
|
||||
import { Worktree } from "../../worktree.js"
|
||||
import { WorktreeStrategies } from "../../worktree/strategies.js"
|
||||
import { ConfigEntryObserver } from "./entry-observer.js"
|
||||
|
||||
export const Plugin = define({
|
||||
@@ -16,7 +16,7 @@ export const Plugin = define({
|
||||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* WorktreeStrategies.Service
|
||||
const loaded = yield* ConfigEntryObserver.observe(config, ctx.event, worktrees.reload())
|
||||
yield* worktrees.transform((editor) => {
|
||||
for (const entry of loaded.entries) {
|
||||
|
||||
+75
-64
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
|
||||
import { makeGlobalNode } from "@opencode/util/effect/app-node"
|
||||
import { File } from "./file.js"
|
||||
import { KeyedMutex } from "./effect/keyed-mutex.js"
|
||||
import { VcsPatch } from "./vcs/patch.js"
|
||||
|
||||
export class Repository extends Schema.Class<Repository>("Git.Repository")({
|
||||
worktree: AbsolutePath,
|
||||
@@ -308,7 +309,7 @@ const layer = Layer.effect(
|
||||
operationName: OperationError["operation"],
|
||||
repository: Repository,
|
||||
args: string[],
|
||||
options?: { stdin?: string; env?: Record<string, string> },
|
||||
options?: { stdin?: string; env?: Record<string, string>; maxOutputBytes?: number },
|
||||
) {
|
||||
const result = yield* proc
|
||||
.run(
|
||||
@@ -317,7 +318,7 @@ const layer = Layer.effect(
|
||||
env: options?.env,
|
||||
extendEnv: true,
|
||||
}),
|
||||
{ stdin: options?.stdin },
|
||||
{ stdin: options?.stdin, maxOutputBytes: options?.maxOutputBytes },
|
||||
)
|
||||
.pipe(
|
||||
Effect.mapError(
|
||||
@@ -331,7 +332,8 @@ const layer = Layer.effect(
|
||||
),
|
||||
)
|
||||
const text = result.stdout.toString("utf8")
|
||||
if (result.exitCode === 0) return { text, stderr: result.stderr.toString("utf8") }
|
||||
if (result.exitCode === 0)
|
||||
return { text, stderr: result.stderr.toString("utf8"), truncated: result.stdoutTruncated }
|
||||
return yield* new OperationError({
|
||||
operation: operationName,
|
||||
directory: repository.worktree,
|
||||
@@ -385,9 +387,7 @@ const layer = Layer.effect(
|
||||
maximumUntrackedFileBytes?: number
|
||||
}) {
|
||||
const list = (args: string[]) =>
|
||||
repositoryOperation("refresh", input.repository, args).pipe(
|
||||
Effect.map((result) => result.text.split("\0").filter(Boolean)),
|
||||
)
|
||||
repositoryOperation("refresh", input.repository, args).pipe(Effect.map((result) => nuls(result.text)))
|
||||
const [tracked, untracked] = yield* Effect.all(
|
||||
[
|
||||
list(["diff-files", "--name-only", "-z", "--", input.scope]),
|
||||
@@ -464,13 +464,7 @@ const layer = Layer.effect(
|
||||
directory: input.repository.worktree,
|
||||
message: result.stderr.toString("utf8").trim() || "Failed to check ignored paths",
|
||||
})
|
||||
return new Set(
|
||||
result.stdout
|
||||
.toString("utf8")
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file)),
|
||||
)
|
||||
return new Set(nuls(result.stdout.toString("utf8")).map((file) => RelativePath.make(file)))
|
||||
})
|
||||
|
||||
const writeTree = Effect.fn("Git.tree.write")(function* (repository: Repository) {
|
||||
@@ -499,19 +493,23 @@ const layer = Layer.effect(
|
||||
to: TreeID
|
||||
}) {
|
||||
// Undo needs both paths of a rename, not only its destination.
|
||||
return (yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text
|
||||
.split("\0")
|
||||
.filter(Boolean)
|
||||
.map((file) => RelativePath.make(file))
|
||||
return nuls(
|
||||
(yield* repositoryOperation("list_files", input.repository, [
|
||||
"diff",
|
||||
"--name-only",
|
||||
"--no-renames",
|
||||
"-z",
|
||||
input.from,
|
||||
input.to,
|
||||
])).text,
|
||||
).map((file) => RelativePath.make(file))
|
||||
})
|
||||
|
||||
/**
|
||||
* Three batched invocations over the tree pair instead of three per file. An
|
||||
* explicit empty selection diffs nothing; an absent one diffs every changed path.
|
||||
* Patch output is capped like VCS diffs: files past the cap get an empty patch.
|
||||
*/
|
||||
const treeDiff = Effect.fn("Git.tree.diff")(function* (input: {
|
||||
repository: Repository
|
||||
from: TreeID
|
||||
@@ -519,49 +517,57 @@ const layer = Layer.effect(
|
||||
context?: number
|
||||
paths?: readonly RelativePath[]
|
||||
}) {
|
||||
const paths = input.paths ?? (yield* treeFiles(input))
|
||||
return yield* Effect.forEach(paths, (file) =>
|
||||
Effect.gen(function* () {
|
||||
const statusText = (yield* repositoryOperation("diff", input.repository, [
|
||||
if (input.paths?.length === 0) return []
|
||||
const args = ["--no-renames", input.from, input.to, "--", ...(input.paths ?? [])]
|
||||
// Patch headers have no -z form: unquoted paths keep chunksByFile matching non-ASCII names.
|
||||
const [names, numbers, patch] = yield* Effect.all(
|
||||
[
|
||||
repositoryOperation("diff", input.repository, ["diff", "--name-status", "-z", ...args]),
|
||||
repositoryOperation("diff", input.repository, ["diff", "--numstat", "-z", ...args]),
|
||||
repositoryOperation(
|
||||
"diff",
|
||||
"--name-status",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.trim()
|
||||
const status = statusText.startsWith("A") ? "added" : statusText.startsWith("D") ? "deleted" : "modified"
|
||||
const stats = (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
"--numstat",
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text.split("\t")
|
||||
const binary = stats[0] === "-" || stats[1] === "-"
|
||||
const patch = binary
|
||||
? ""
|
||||
: (yield* repositoryOperation("diff", input.repository, [
|
||||
"diff",
|
||||
`--unified=${input.context ?? 3}`,
|
||||
"--no-renames",
|
||||
input.from,
|
||||
input.to,
|
||||
"--",
|
||||
file,
|
||||
])).text
|
||||
return {
|
||||
file,
|
||||
status,
|
||||
additions: binary ? 0 : Number(stats[0] ?? 0),
|
||||
deletions: binary ? 0 : Number(stats[1] ?? 0),
|
||||
patch,
|
||||
} satisfies File.Diff
|
||||
input.repository,
|
||||
["-c", "core.quotepath=false", "diff", "--no-ext-diff", `--unified=${input.context ?? 3}`, ...args],
|
||||
{ maxOutputBytes: VcsPatch.MAX_TOTAL_PATCH_BYTES },
|
||||
),
|
||||
],
|
||||
{ concurrency: 3 },
|
||||
)
|
||||
const statuses = nuls(names.text)
|
||||
const files = statuses.flatMap((code, index) => {
|
||||
const file = statuses[index + 1]
|
||||
if (index % 2 !== 0 || !file) return []
|
||||
return [
|
||||
{
|
||||
file: RelativePath.make(file),
|
||||
status: code.startsWith("A") ? "added" : code.startsWith("D") ? "deleted" : "modified",
|
||||
} as const,
|
||||
]
|
||||
})
|
||||
const stats = new Map(
|
||||
nuls(numbers.text).flatMap((line) => {
|
||||
const [additions, deletions, ...file] = line.split("\t")
|
||||
if (!additions || !deletions || file.length === 0) return []
|
||||
return [
|
||||
[
|
||||
file.join("\t"),
|
||||
additions === "-" || deletions === "-"
|
||||
? { binary: true, additions: 0, deletions: 0 }
|
||||
: { binary: false, additions: Number(additions), deletions: Number(deletions) },
|
||||
] as const,
|
||||
]
|
||||
}),
|
||||
)
|
||||
const patches = VcsPatch.chunksByFile(patch, (index) => files[index]?.file)
|
||||
return files.map((entry) => {
|
||||
const stat = stats.get(entry.file)
|
||||
return {
|
||||
...entry,
|
||||
additions: stat?.additions ?? 0,
|
||||
deletions: stat?.deletions ?? 0,
|
||||
patch: stat?.binary ? "" : (patches.get(entry.file) ?? VcsPatch.emptyPatch(entry.file)),
|
||||
} satisfies File.Diff
|
||||
})
|
||||
})
|
||||
|
||||
const hasEntry = Effect.fnUntraced(function* (repository: Repository, tree: TreeID, file: RelativePath) {
|
||||
@@ -733,6 +739,11 @@ function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
|
||||
)
|
||||
}
|
||||
|
||||
/** Split NUL-terminated git output into its records. */
|
||||
function nuls(text: string) {
|
||||
return text.split("\0").filter(Boolean)
|
||||
}
|
||||
|
||||
function resolvePath(cwd: string, value: string) {
|
||||
const trimmed = value.replace(/[\r\n]+$/, "")
|
||||
if (!trimmed) return cwd
|
||||
|
||||
@@ -25,8 +25,7 @@ import { Plugin } from "./plugin.js"
|
||||
import { PluginHooks } from "./plugin/hooks.js"
|
||||
import { InstancePlugins } from "./plugin/instance.js"
|
||||
import { PluginSupervisor } from "./plugin/supervisor.js"
|
||||
import { WorktreeRefresh } from "./worktree/refresh.js"
|
||||
import { Worktree } from "./worktree.js"
|
||||
import { WorktreeStrategies } from "./worktree/strategies.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Shell } from "./shell.js"
|
||||
import { ShellSelect } from "./shell/select.js"
|
||||
@@ -72,8 +71,7 @@ const nodes = [
|
||||
PluginHooks.node,
|
||||
InstancePlugins.node,
|
||||
PluginSupervisor.node,
|
||||
WorktreeRefresh.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
FileSystemSearch.node,
|
||||
FileSystem.node,
|
||||
ShellSelect.node,
|
||||
|
||||
@@ -31,6 +31,7 @@ import { Workspace } from "../workspace.js"
|
||||
import { Vcs } from "../vcs.js"
|
||||
import { WebSearch } from "../websearch.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { WorktreeStrategies } from "../worktree/strategies.js"
|
||||
import { Generate } from "../generate.js"
|
||||
import { Permission } from "../permission.js"
|
||||
import { PluginHooks } from "./hooks.js"
|
||||
@@ -71,6 +72,8 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const persistentPty = yield* PersistentPty.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktreeStrategies = yield* WorktreeStrategies.Service
|
||||
const currentWorktreeStrategies = location.workspaceID ? undefined : worktreeStrategies
|
||||
const locationInfo = () =>
|
||||
new Location.Info({
|
||||
directory: location.directory,
|
||||
@@ -90,21 +93,6 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
const response = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
|
||||
effect.pipe(Effect.map((data) => ({ location: locationInfo(), data })))
|
||||
|
||||
const atWorktree = <A, E>(
|
||||
ref: Location.Ref | undefined,
|
||||
run: (service: Worktree.Interface) => Effect.Effect<A, E>,
|
||||
) => {
|
||||
if (ref?.workspaceID) return Effect.fail(new Worktree.UnsupportedLocationError({ directory: ref.directory }))
|
||||
if (!ref || isCurrentLocation(ref)) return run(worktrees)
|
||||
return Effect.gen(function* () {
|
||||
// Defer this import: Plugin's construction depends on this host. Same-location setup calls never wait on themselves.
|
||||
const { Plugin } = yield* Effect.promise(() => import("../plugin.js"))
|
||||
const plugins = yield* Plugin.Service
|
||||
const target = yield* Worktree.Service
|
||||
yield* plugins.awaitActivation
|
||||
return yield* run(target)
|
||||
}).pipe(Effect.provide(locations.get(ref)))
|
||||
}
|
||||
const decodeWorktree = Schema.decodeUnknownEffect(Worktree.Info)
|
||||
const decodeWorktrees = Schema.decodeUnknownEffect(Schema.Array(Worktree.ListEntry))
|
||||
|
||||
@@ -484,13 +472,13 @@ export const make = Effect.fn("PluginHost.make")(function* (
|
||||
}),
|
||||
},
|
||||
worktree: {
|
||||
list: (input) => atWorktree(locationRef(input), (service) => service.list()),
|
||||
create: (input) => atWorktree(locationRef(input), (service) => service.create(input)),
|
||||
refresh: (input) => atWorktree(locationRef(input), (service) => service.refresh()).pipe(Effect.asVoid),
|
||||
remove: (input) => atWorktree(locationRef(input), (service) => service.remove(input)),
|
||||
reload: worktrees.reload,
|
||||
list: worktrees.list,
|
||||
create: (input) => worktrees.create(input, currentWorktreeStrategies),
|
||||
refresh: (input) => worktrees.refresh(input, currentWorktreeStrategies).pipe(Effect.asVoid),
|
||||
remove: (input) => worktrees.remove(input, currentWorktreeStrategies),
|
||||
reload: worktreeStrategies.reload,
|
||||
transform: (callback) =>
|
||||
worktrees.transform((editor) =>
|
||||
worktreeStrategies.transform((editor) =>
|
||||
callback({
|
||||
add: (definition) =>
|
||||
editor.add({
|
||||
@@ -553,6 +541,7 @@ export const requirements = LayerNode.group([
|
||||
Vcs.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
Generate.node,
|
||||
Permission.node,
|
||||
PluginHooks.node,
|
||||
|
||||
@@ -29,6 +29,7 @@ import { ConfigToolOutputPlugin } from "../config/plugin/tool-output.js"
|
||||
import { ConfigWebSearchPlugin } from "../config/plugin/websearch.js"
|
||||
import { ConfigWorktreePlugin } from "../config/plugin/worktree.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
import { WorktreeStrategies } from "../worktree/strategies.js"
|
||||
import { Bus } from "../bus.js"
|
||||
import { Environment } from "../environment/index.js"
|
||||
import { FileAccess } from "../file-access.js"
|
||||
@@ -139,6 +140,7 @@ const services = [
|
||||
Watcher.Service,
|
||||
WellKnown.Service,
|
||||
Worktree.Service,
|
||||
WorktreeStrategies.Service,
|
||||
] as const
|
||||
|
||||
export type Requirements = Context.Service.Identifier<(typeof services)[number]>
|
||||
@@ -188,11 +190,13 @@ export const requirements = LayerNode.group([
|
||||
Watcher.node,
|
||||
WellKnown.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
])
|
||||
|
||||
export type InternalPlugin = Plugin<Requirements | Scope.Scope>
|
||||
|
||||
const pre = [
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
BrowserPlugin,
|
||||
ConfigMcpPlugin.Plugin,
|
||||
McpCodeModeExclusionPlugin.Plugin,
|
||||
@@ -239,7 +243,6 @@ const post = [
|
||||
ConfigSkillPlugin.Plugin,
|
||||
ConfigProviderPlugin.Plugin,
|
||||
ConfigWebSearchPlugin.Plugin,
|
||||
ConfigWorktreePlugin.Plugin,
|
||||
VariantPlugin.Plugin,
|
||||
ConfigPolicyPlugin.Plugin,
|
||||
] as const satisfies readonly InternalPlugin[]
|
||||
|
||||
@@ -55,8 +55,11 @@ import { SessionModelTransport } from "./session/model-transport.js"
|
||||
import { llmClient } from "./effect/app-node-platform.js"
|
||||
import { Snapshot } from "./snapshot.js"
|
||||
import { Session } from "./session/session.js"
|
||||
import { SessionDiff, TurnRangeError } from "./session/diff.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import type { EventLog } from "@opencode/schema/event-log"
|
||||
import type { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Job } from "./job.js"
|
||||
import type { Command } from "./command.js"
|
||||
import { SessionEnvironment } from "./session/environment.js"
|
||||
@@ -109,6 +112,7 @@ export {
|
||||
type InboxItemRef = { readonly sessionID: SessionSchema.ID; readonly inboxID: SessionMessage.ID }
|
||||
|
||||
export { DestinationNotFoundError, DestinationNotDirectoryError, DestinationUnavailableError }
|
||||
export { TurnRangeError }
|
||||
|
||||
export interface Interface {
|
||||
readonly list: (input?: ListInput) => Effect.Effect<{
|
||||
@@ -135,6 +139,13 @@ export interface Interface {
|
||||
readonly context: (
|
||||
sessionID: SessionSchema.ID,
|
||||
) => Effect.Effect<SessionMessage.Info[], NotFoundError | MessageDecodeError>
|
||||
/** Structured diffs of the files changed by a turn or range of turns; see `SessionDiff.turn`. */
|
||||
readonly diff: (input: {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly from?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
}) => Effect.Effect<readonly FileDiff.Info[], NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error>
|
||||
/**
|
||||
* Durable admitted session work not yet visible in projected history,
|
||||
* ordered by admission. Includes unpromoted user and synthetic inputs and
|
||||
@@ -227,6 +238,7 @@ const layer = Layer.effect(
|
||||
const moves = yield* SessionMove.Service
|
||||
const jobs = yield* Job.Service
|
||||
const environments = yield* SessionEnvironment.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const sessions = yield* Session.make()
|
||||
const isDurableSessionEvent = Schema.is(SessionEvent.Durable)
|
||||
|
||||
@@ -359,6 +371,17 @@ const layer = Layer.effect(
|
||||
yield* result.get(sessionID)
|
||||
return yield* store.context(sessionID)
|
||||
}),
|
||||
diff: Effect.fn("Session.diff")(function* (input) {
|
||||
const session = yield* result.get(input.sessionID)
|
||||
const active = yield* execution.isActive(input.sessionID)
|
||||
return yield* SessionDiff.turn(db, locations, {
|
||||
session,
|
||||
active,
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
context: input.context,
|
||||
})
|
||||
}),
|
||||
inbox: (sessionID) => sessions.forSession(sessionID).inbox(),
|
||||
cancelInbox: (input) => sessions.forSession(input.sessionID).cancelInbox(input.inboxID),
|
||||
steerInbox: (input) => sessions.forSession(input.sessionID).steerInbox(input.inboxID),
|
||||
@@ -448,6 +471,7 @@ export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.gl
|
||||
SessionInbox.node,
|
||||
SessionMove.node,
|
||||
SessionProjector.node,
|
||||
LocationServiceMap.node,
|
||||
FSUtil.node,
|
||||
App.node,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
export * as SessionDiff from "./diff.js"
|
||||
|
||||
import { and, asc, eq, gt, inArray, lt, or, sql } from "drizzle-orm"
|
||||
import { Context, Effect, Schema } from "effect"
|
||||
import { Location } from "@opencode/schema/location"
|
||||
import { Database } from "../database/database.js"
|
||||
import { LocationServiceMap } from "../location-service-map.js"
|
||||
import { Snapshot } from "../snapshot.js"
|
||||
import { PATCH_CONTEXT_LINES } from "../vcs/patch.js"
|
||||
import { MessageNotFoundError } from "./error.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
|
||||
export class TurnRangeError extends Schema.TaggedError<TurnRangeError>()("Session.TurnRangeError", {
|
||||
sessionID: SessionSchema.ID,
|
||||
field: Schema.Literals(["from", "to"]),
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
const decodeLocation = Schema.decodeUnknownSync(Schema.fromJsonString(Location.Ref))
|
||||
|
||||
/**
|
||||
* Diff the files changed by the turn containing a user message. A turn runs from
|
||||
* the first prompt after the Session was last idle until the next idle marker, so
|
||||
* prompts steered in while it was busy belong to the same turn; `to` extends the
|
||||
* range through the turn containing a later user message. Compares the range's
|
||||
* first recorded start snapshot with its last recorded end snapshot; only a step
|
||||
* still running in the active Session compares against the working copy. Like VCS
|
||||
* diffs, an omitted `context` yields full-file patches.
|
||||
*
|
||||
* A Session without any idle marker predates them, so its prompts span until the
|
||||
* next user message instead.
|
||||
*
|
||||
* Snapshot trees live in the repository of the Location that captured them, so a
|
||||
* range spanning a location switch is rejected rather than diffed wrongly.
|
||||
*/
|
||||
export const turn = Effect.fn("SessionDiff.turn")(function* (
|
||||
db: Database.Interface["db"],
|
||||
locations: Context.Service.Shape<typeof LocationServiceMap.Service>,
|
||||
input: {
|
||||
readonly session: SessionSchema.Info
|
||||
/** The process is currently executing this Session. */
|
||||
readonly active: boolean
|
||||
readonly from?: SessionMessage.ID
|
||||
readonly to?: SessionMessage.ID
|
||||
readonly context?: number
|
||||
},
|
||||
) {
|
||||
const sessionID = input.session.id
|
||||
const rows = yield* db
|
||||
.select({ id: SessionMessageTable.id, type: SessionMessageTable.type, seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
or(
|
||||
inArray(SessionMessageTable.type, ["user", "idle"]),
|
||||
input.from ? eq(SessionMessageTable.id, input.from) : undefined,
|
||||
input.to ? eq(SessionMessageTable.id, input.to) : undefined,
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const users = rows.filter((row) => row.type === "user")
|
||||
const markers = rows.filter((row) => row.type === "idle")
|
||||
const resolve = Effect.fn(function* (field: "from" | "to", id: SessionMessage.ID) {
|
||||
const row = rows.find((row) => row.id === id)
|
||||
if (!row) return yield* new MessageNotFoundError({ sessionID, messageID: id })
|
||||
if (row.type !== "user")
|
||||
return yield* new TurnRangeError({ sessionID, field, message: `Message ${id} is not a user message` })
|
||||
return row
|
||||
})
|
||||
const anchor = input.from ? yield* resolve("from", input.from) : users[users.length - 1]
|
||||
if (!anchor) return []
|
||||
const last = input.to ? yield* resolve("to", input.to) : anchor
|
||||
if (last.seq < anchor.seq)
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: `Message ${last.id} precedes ${anchor.id}` })
|
||||
// Without any marker, history predates idle markers and a prompt's turn ends at the next prompt.
|
||||
const legacy = markers.length === 0
|
||||
// The turn opens with the first prompt after the previous idle marker; the anchor itself is the latest candidate.
|
||||
const opened = markers.findLast((row) => row.seq < anchor.seq)?.seq ?? -1
|
||||
const start = legacy ? anchor.seq : (users.find((row) => row.seq > opened)?.seq ?? anchor.seq)
|
||||
const end = legacy ? users.find((row) => row.seq > last.seq)?.seq : markers.find((row) => row.seq > last.seq)?.seq
|
||||
const steps = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
start: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.start')`,
|
||||
end: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.snapshot.end')`,
|
||||
completed: sql<number | null>`json_extract(${SessionMessageTable.data}, '$.time.completed')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
gt(SessionMessageTable.seq, start),
|
||||
end === undefined ? undefined : lt(SessionMessageTable.seq, end),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
const first = steps[0]
|
||||
const final = steps[steps.length - 1]
|
||||
const from = steps.find((step) => step.start)?.start
|
||||
if (!first || !final || !from) return []
|
||||
const switches = yield* db
|
||||
.select({
|
||||
seq: SessionMessageTable.seq,
|
||||
location: sql<string>`json_extract(${SessionMessageTable.data}, '$.location')`,
|
||||
previous: sql<string | null>`json_extract(${SessionMessageTable.data}, '$.previous.location')`,
|
||||
})
|
||||
.from(SessionMessageTable)
|
||||
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "location-switched")))
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
if (switches.some((row) => row.seq > first.seq && row.seq < final.seq))
|
||||
return yield* new TurnRangeError({ sessionID, field: "to", message: "Turn range spans a location change" })
|
||||
const before = switches.findLast((row) => row.seq < first.seq)?.location
|
||||
const after = switches.find((row) => row.seq > first.seq)?.previous
|
||||
const location = before ? decodeLocation(before) : after ? decodeLocation(after) : input.session.location
|
||||
const recorded = steps.findLast((step) => step.end)?.end
|
||||
return yield* Effect.gen(function* () {
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const running = input.active && final.completed === null
|
||||
const to = running ? ((yield* snapshot.capture()) ?? recorded) : recorded
|
||||
if (!to) return []
|
||||
return yield* snapshot.diff({
|
||||
from: Snapshot.ID.make(from),
|
||||
to: Snapshot.ID.make(to),
|
||||
context: input.context ?? PATCH_CONTEXT_LINES,
|
||||
})
|
||||
}).pipe(Effect.provide(locations.get(location)))
|
||||
})
|
||||
@@ -60,6 +60,21 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
)
|
||||
})
|
||||
|
||||
const idle = (outcome: SessionMessage.Idle["outcome"]) =>
|
||||
clearCurrentRetry.pipe(
|
||||
Effect.andThen(
|
||||
adapter.appendMessage(
|
||||
SessionMessage.Idle.make({
|
||||
id: SessionMessage.ID.fromEvent(event.id),
|
||||
type: "idle",
|
||||
outcome,
|
||||
metadata: event.metadata,
|
||||
time: { created },
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const project = pipe(
|
||||
Match.type<SessionEvent.DurableEvent>(),
|
||||
Match.discriminatorsExhaustive("type")({
|
||||
@@ -124,9 +139,11 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
"session.inbox.cancelled": () => Effect.void,
|
||||
"session.inbox.delivery.changed": () => Effect.void,
|
||||
"session.execution.started": () => Effect.void,
|
||||
"session.execution.succeeded": () => clearCurrentRetry,
|
||||
"session.execution.failed": () => clearCurrentRetry,
|
||||
"session.execution.interrupted": () => clearCurrentRetry,
|
||||
"session.execution.succeeded": () => idle("succeeded"),
|
||||
"session.execution.failed": () => idle("failed"),
|
||||
// Shutdown keeps the execution claim and the resumed drain continues the turn.
|
||||
"session.execution.interrupted": (event) =>
|
||||
event.data.reason === "shutdown" ? clearCurrentRetry : idle("interrupted"),
|
||||
"session.instructions.updated": (event) => {
|
||||
if (event.data.text === undefined) return Effect.void
|
||||
return adapter.appendMessage(
|
||||
|
||||
@@ -226,6 +226,7 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
case "idle":
|
||||
return []
|
||||
case "location-switched":
|
||||
return [
|
||||
|
||||
@@ -131,38 +131,55 @@ const layer = Layer.effect(
|
||||
)
|
||||
})
|
||||
|
||||
const compare = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const comparison = Effect.fnUntraced(function* (operation: "files" | "diff", input: CompareInput) {
|
||||
const repo = yield* repository.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const comparison = {
|
||||
return {
|
||||
source: repo.source,
|
||||
repository: repo.snapshotRepository,
|
||||
from: Git.TreeID.make(input.from),
|
||||
to: Git.TreeID.make(input.to),
|
||||
}
|
||||
const files = yield* git.tree.files(comparison).pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
const ignored = yield* git.index
|
||||
.ignored({ repository: repo.source, paths: files })
|
||||
})
|
||||
|
||||
// Snapshots track every scoped file; the source repository's ignore rules decide what callers see.
|
||||
const ignored = Effect.fnUntraced(function* (
|
||||
operation: "files" | "diff",
|
||||
source: Git.Repository,
|
||||
paths: readonly RelativePath[],
|
||||
) {
|
||||
return yield* git.index
|
||||
.ignored({ repository: source, paths })
|
||||
.pipe(Effect.mapError((cause) => failure(operation, cause)))
|
||||
return {
|
||||
input: comparison,
|
||||
files,
|
||||
ignored,
|
||||
}
|
||||
})
|
||||
|
||||
const files = Effect.fn("Snapshot.files")(function* (input: CompareInput) {
|
||||
const comparison = yield* compare("files", input)
|
||||
return comparison.files.filter((file) => !comparison.ignored.has(file))
|
||||
const compared = yield* comparison("files", input)
|
||||
const changed = yield* git.tree
|
||||
.files({ repository: compared.repository, from: compared.from, to: compared.to })
|
||||
.pipe(Effect.mapError((cause) => failure("files", cause)))
|
||||
const skipped = yield* ignored("files", compared.source, changed)
|
||||
return changed.filter((file) => !skipped.has(file))
|
||||
})
|
||||
|
||||
const diff = Effect.fn("Snapshot.diff")(function* (input: DiffInput) {
|
||||
const comparison = yield* compare("diff", input)
|
||||
return yield* git.tree
|
||||
if (input.paths?.length === 0) return []
|
||||
const compared = yield* comparison("diff", input)
|
||||
// Only an explicit selection becomes a pathspec; ignored paths are dropped from the result instead.
|
||||
const diffs = yield* git.tree
|
||||
.diff({
|
||||
...comparison.input,
|
||||
repository: compared.repository,
|
||||
from: compared.from,
|
||||
to: compared.to,
|
||||
context: input.context,
|
||||
paths: (input.paths ?? comparison.files).filter((file) => !comparison.ignored.has(file)),
|
||||
paths: input.paths,
|
||||
})
|
||||
.pipe(Effect.mapError((cause) => failure("diff", cause)))
|
||||
const skipped = yield* ignored(
|
||||
"diff",
|
||||
compared.source,
|
||||
diffs.map((file) => RelativePath.make(file.file)),
|
||||
)
|
||||
return diffs.filter((file) => !skipped.has(RelativePath.make(file.file)))
|
||||
})
|
||||
|
||||
const plan = Effect.fnUntraced(function* (worktree: AbsolutePath, input: RestoreInput) {
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as OpenCodeTools from "./opencode.js"
|
||||
|
||||
import { SystemPart, ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { AbsolutePath } from "@opencode/schema/schema"
|
||||
import { Session } from "@opencode/schema/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
@@ -25,15 +26,17 @@ const MoveOutput = Schema.Struct({ sessionID: Session.ID, directory: AbsolutePat
|
||||
export const Plugin = {
|
||||
id: "opencode.tools",
|
||||
effect: Effect.fn("OpenCodeTools.Plugin")(function* (ctx: Context) {
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.sync(() => {
|
||||
event.system.push(
|
||||
SystemPart.make(
|
||||
"When you create a worktree outside the current working directory and intend to use it as your primary working directory, consider using `execute` to call `tools.opencode.session_move` and make the worktree the session's working directory.",
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
yield* ctx.tool
|
||||
.transform((draft) => {
|
||||
draft.namespace({ name: "opencode", description: "OpenCode session and runtime tools." })
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as PatchTool from "./patch.js"
|
||||
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { ToolFailure } from "@opencode/ai"
|
||||
import { FileDiff } from "@opencode/schema/file-diff"
|
||||
import { Effect, Result, Schema } from "effect"
|
||||
@@ -292,7 +293,7 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.sync(() => {
|
||||
const usePatch =
|
||||
event.model.id.includes("gpt-") && !event.model.id.includes("oss") && !event.model.id.includes("gpt-4")
|
||||
@@ -302,8 +303,10 @@ export const Plugin = {
|
||||
return
|
||||
}
|
||||
delete event.tools.patch
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}),
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as ShellTool from "./shell.js"
|
||||
|
||||
import { ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import type { ShellCreateBefore } from "@opencode/plugin/effect/shell"
|
||||
import type { Tool } from "@opencode/schema/tool"
|
||||
import { Deferred, Effect, Schema, Scope } from "effect"
|
||||
@@ -271,12 +272,14 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
tool.description = description(ShellSelect.name(yield* compatibleShell))
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ export * as SubagentTool from "./subagent.js"
|
||||
|
||||
import { ToolFailure } from "@opencode/ai"
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Agent } from "../../agent.js"
|
||||
import { Config } from "../../config.js"
|
||||
@@ -234,7 +235,7 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const tool = event.tools[name]
|
||||
if (!tool) return
|
||||
@@ -258,7 +259,9 @@ export const Plugin = {
|
||||
`- ${agent.id}: ${agent.description ?? "This subagent should only be called when explicitly requested."}`,
|
||||
),
|
||||
].join("\n")
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as WebSearchTool from "./websearch.js"
|
||||
|
||||
import type { Context } from "@opencode/plugin/effect/plugin"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { ToolFailure } from "@opencode/ai"
|
||||
import { Effect, Schema, Semaphore } from "effect"
|
||||
import { HttpClientError } from "effect/unstable/http"
|
||||
@@ -180,14 +181,16 @@ export const Plugin = {
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
yield* ctx.session.hook("context", (event) =>
|
||||
const hook = (event: SessionHooks["context"]) =>
|
||||
Effect.gen(function* () {
|
||||
const disabled = yield* websearch.default().pipe(
|
||||
Effect.as(false),
|
||||
Effect.catchTag("WebSearch.Disabled", () => Effect.succeed(true)),
|
||||
)
|
||||
if (disabled) delete event.tools[name]
|
||||
}),
|
||||
)
|
||||
})
|
||||
yield* ctx.session.hook("context", hook)
|
||||
yield* ctx.session.hook("compaction", hook)
|
||||
yield* ctx.session.hook("generate", hook)
|
||||
}),
|
||||
}
|
||||
|
||||
+100
-103
@@ -1,18 +1,19 @@
|
||||
export * as Worktree from "./worktree.js"
|
||||
|
||||
import { Context, Effect, Layer, Schema } from "effect"
|
||||
import { Context, Effect, Layer, Option, Schema } from "effect"
|
||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"
|
||||
import path from "path"
|
||||
import { AbsolutePath } from "./schema.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Git } from "./git.js"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { ProjectSchema } from "./project/schema.js"
|
||||
import { Node } from "@opencode/util/effect/app-node"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Slug } from "./util/slug.js"
|
||||
import { Bus } from "./bus.js"
|
||||
import { Database } from "./database/database.js"
|
||||
import { Location } from "./location.js"
|
||||
import { LocationServiceMap } from "./location-service-map.js"
|
||||
import { Project } from "./project.js"
|
||||
import { Worktree } from "@opencode/schema/worktree"
|
||||
import { WorktreeTable } from "./worktree/sql.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
@@ -21,7 +22,9 @@ import type { EffectDrizzleSqlite } from "./database/drizzle.js"
|
||||
import { ProjectTable } from "./project/sql.js"
|
||||
import { AppProcess } from "@opencode/util/process"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { State } from "./state.js"
|
||||
import { WorktreeStrategies } from "./worktree/strategies.js"
|
||||
|
||||
export type { Strategy, Editor } from "./worktree/strategies.js"
|
||||
|
||||
export { DirectoryUnavailableError } from "./worktree/directory.js"
|
||||
export { OperationError } from "@opencode/schema/worktree"
|
||||
@@ -52,7 +55,7 @@ export type ListEntry = typeof ListEntry.Type
|
||||
|
||||
export class SourceDirectoryNotFoundError extends Schema.TaggedError<SourceDirectoryNotFoundError>()(
|
||||
"Worktree.SourceDirectoryNotFoundError",
|
||||
{ projectID: ProjectSchema.ID, directory: Schema.optional(AbsolutePath) },
|
||||
{ projectID: Project.ID, directory: Schema.optional(AbsolutePath) },
|
||||
) {}
|
||||
|
||||
export class DestinationExistsError extends Schema.TaggedError<DestinationExistsError>()(
|
||||
@@ -70,33 +73,17 @@ export class StrategyUnavailableError extends Schema.TaggedError<StrategyUnavail
|
||||
{ strategy: StrategyID },
|
||||
) {}
|
||||
|
||||
export class UnsupportedLocationError extends Schema.TaggedError<UnsupportedLocationError>()(
|
||||
"Worktree.UnsupportedLocationError",
|
||||
{ directory: AbsolutePath },
|
||||
) {}
|
||||
|
||||
export type Error =
|
||||
| Project.NotFoundError
|
||||
| SourceDirectoryNotFoundError
|
||||
| DestinationExistsError
|
||||
| DirectoryUnavailableError
|
||||
| InvalidDirectoryError
|
||||
| StrategyUnavailableError
|
||||
| UnsupportedLocationError
|
||||
| Worktree.OperationError
|
||||
| AppProcess.AppProcessError
|
||||
| Git.WorktreeError
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
}) => Effect.Effect<Info, unknown>
|
||||
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly ListEntry[], unknown>
|
||||
}
|
||||
|
||||
export const Event = Worktree.Event
|
||||
|
||||
interface StoredInput {
|
||||
@@ -108,19 +95,18 @@ interface StoredInput {
|
||||
type DatabaseClient = EffectDrizzleSqlite.EffectSQLiteDatabase
|
||||
type Transaction = Parameters<Parameters<DatabaseClient["transaction"]>[0]>[0]
|
||||
|
||||
export interface Editor {
|
||||
readonly add: (strategy: Strategy) => void
|
||||
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
|
||||
export interface Interface {
|
||||
readonly list: (input: { projectID: Project.ID }) => Effect.Effect<List, Project.NotFoundError>
|
||||
// The plugin bridge supplies its registry so canonical-project setup can use registrations made so far.
|
||||
readonly create: (input: CreateInput, current?: WorktreeStrategies.Interface) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput, current?: WorktreeStrategies.Interface) => Effect.Effect<void, Error>
|
||||
readonly refresh: (
|
||||
input: { projectID: Project.ID },
|
||||
current?: WorktreeStrategies.Interface,
|
||||
) => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly list: () => Effect.Effect<List, Error>
|
||||
readonly create: (input?: CreateInput) => Effect.Effect<Info, Error>
|
||||
readonly remove: (input: RemoveInput) => Effect.Effect<void, Error>
|
||||
readonly refresh: () => Effect.Effect<RefreshResult, Error>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Worktree") {}
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Worktree") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
@@ -130,39 +116,28 @@ const layer = Layer.effect(
|
||||
const db = database.db
|
||||
const bus = yield* Bus.Service
|
||||
const processService = yield* AppProcess.Service
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const projectID = location.project.id
|
||||
|
||||
const local = location.workspaceID
|
||||
? Effect.fail(new UnsupportedLocationError({ directory: location.directory }))
|
||||
: Effect.void
|
||||
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const gitStrategy = yield* WorktreeGit.make
|
||||
const state = State.create({
|
||||
name: "worktree",
|
||||
initial: () => ({
|
||||
directory: AbsolutePath.make(path.join(global.data, "worktree", projectID.slice(0, 6))),
|
||||
strategies: new Map<StrategyID, Strategy>([[gitStrategy.id, gitStrategy]]),
|
||||
selected: gitStrategy.id,
|
||||
}),
|
||||
editor: (value): Editor => ({
|
||||
configure: (settings) => {
|
||||
value.directory = settings.directory
|
||||
},
|
||||
add: (strategy) => {
|
||||
value.strategies.delete(strategy.id)
|
||||
value.strategies.set(strategy.id, strategy)
|
||||
value.selected = strategy.id
|
||||
},
|
||||
}),
|
||||
const project = Effect.fnUntraced(function* (projectID: Project.ID) {
|
||||
const row = yield* db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get().pipe(Effect.orDie)
|
||||
if (!row) return yield* new Project.NotFoundError({ projectID })
|
||||
return row
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (update: boolean) {
|
||||
const load = Effect.fnUntraced(function* (directory: AbsolutePath, current?: WorktreeStrategies.Interface) {
|
||||
if (!(yield* fs.isDir(directory))) return yield* new DirectoryUnavailableError({ directory })
|
||||
if (current?.directory === directory) return current.get()
|
||||
const { Plugin } = yield* Effect.promise(() => import("./plugin.js"))
|
||||
const context = yield* locations.contextEffect(Location.Ref.make({ directory }))
|
||||
yield* Context.get(context, Plugin.Service).awaitActivation
|
||||
return Context.get(context, WorktreeStrategies.Service).get()
|
||||
})
|
||||
|
||||
const changed = Effect.fnUntraced(function* (projectID: Project.ID, update: boolean) {
|
||||
if (update) yield* bus.publish(Event.Updated, { projectID })
|
||||
})
|
||||
|
||||
const ops = {
|
||||
const inventory = (projectID: Project.ID) => ({
|
||||
list: Effect.fnUntraced(function* () {
|
||||
const rows = yield* db
|
||||
.select({ directory: WorktreeTable.directory, strategy: WorktreeTable.strategy })
|
||||
@@ -214,28 +189,30 @@ const layer = Layer.effect(
|
||||
Effect.orDie,
|
||||
Effect.map((row) => row !== undefined),
|
||||
),
|
||||
}
|
||||
})
|
||||
|
||||
const source = Effect.fnUntraced(function* (input: AbsolutePath | undefined) {
|
||||
const sourceDirectory = input ?? location.project.directory
|
||||
const source = Effect.fnUntraced(function* (projectID: Project.ID, sourceDirectory: AbsolutePath) {
|
||||
const resolved = yield* canonical(fs, sourceDirectory)
|
||||
if ((yield* ops.find(resolved)) === undefined)
|
||||
if ((yield* inventory(projectID).find(resolved)) === undefined)
|
||||
return yield* new SourceDirectoryNotFoundError({ projectID, directory: resolved })
|
||||
return resolved
|
||||
})
|
||||
|
||||
const getStrategy = Effect.fnUntraced(function* (id: StrategyID, strategies: ReadonlyMap<StrategyID, Strategy>) {
|
||||
const getStrategy = Effect.fnUntraced(function* (
|
||||
id: StrategyID,
|
||||
strategies: ReadonlyMap<StrategyID, WorktreeStrategies.Strategy>,
|
||||
) {
|
||||
const found = strategies.get(id)
|
||||
if (!found) return yield* new StrategyUnavailableError({ strategy: id })
|
||||
return found
|
||||
})
|
||||
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput = {}) {
|
||||
yield* local
|
||||
const current = state.get()
|
||||
const selected = yield* getStrategy(input.strategy ?? current.selected, current.strategies)
|
||||
const directory = input.directory ?? current.directory
|
||||
const sourceDirectory = yield* source(input.from)
|
||||
const create = Effect.fn("Worktree.create")(function* (input: CreateInput, current?: WorktreeStrategies.Interface) {
|
||||
const row = yield* project(input.projectID)
|
||||
const settings = yield* load(row.worktree, current)
|
||||
const selected = yield* getStrategy(settings.selected, settings.strategies)
|
||||
const directory = input.directory ?? settings.directory
|
||||
const sourceDirectory = yield* source(input.projectID, input.from ?? row.worktree)
|
||||
yield* fs.makeDirectory(directory, { recursive: true }).pipe(Effect.orDie)
|
||||
const name = input.name ?? Slug.create()
|
||||
let suffix = 1
|
||||
@@ -255,19 +232,14 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.mapError((error) => operationError(selected.id, "create", error)))
|
||||
const result = { directory: yield* canonical(fs, created.directory) }
|
||||
yield* changed(
|
||||
yield* ops.create({
|
||||
input.projectID,
|
||||
yield* inventory(input.projectID).create({
|
||||
directory: result.directory,
|
||||
strategy: selected.id,
|
||||
replace: true,
|
||||
}),
|
||||
)
|
||||
const project = yield* db
|
||||
.select({ commands: ProjectTable.commands })
|
||||
.from(ProjectTable)
|
||||
.where(eq(ProjectTable.id, projectID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
const command = project?.commands?.start?.trim()
|
||||
const command = row.commands?.start?.trim()
|
||||
if (command) {
|
||||
const windows = process.platform === "win32"
|
||||
yield* processService
|
||||
@@ -286,39 +258,66 @@ const layer = Layer.effect(
|
||||
.pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
}
|
||||
return result
|
||||
})
|
||||
}, Effect.scoped)
|
||||
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput) {
|
||||
yield* local
|
||||
const remove = Effect.fn("Worktree.remove")(function* (input: RemoveInput, current?: WorktreeStrategies.Interface) {
|
||||
const row = yield* project(input.projectID)
|
||||
const ops = inventory(input.projectID)
|
||||
const worktreeDirectory = yield* canonical(fs, input.directory)
|
||||
const stored = yield* ops.find(worktreeDirectory)
|
||||
if (!stored?.strategy) return yield* new InvalidDirectoryError({ directory: worktreeDirectory })
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), state.get().strategies)
|
||||
// Inspect only an already-loaded canonical registry. Removing must never boot config or plugins.
|
||||
const strategies =
|
||||
current?.directory === row.worktree
|
||||
? current.get().strategies
|
||||
: yield* locations.contextEffectOption(Location.Ref.make({ directory: row.worktree })).pipe(
|
||||
Effect.map(
|
||||
Option.match({
|
||||
onSome: (context) => Context.get(context, WorktreeStrategies.Service).get().strategies,
|
||||
onNone: () => new Map([[gitStrategy.id, gitStrategy]]),
|
||||
}),
|
||||
),
|
||||
)
|
||||
const strategy = yield* getStrategy(StrategyID.make(stored.strategy), strategies)
|
||||
yield* strategy
|
||||
.remove({
|
||||
directory: worktreeDirectory,
|
||||
force: input.force,
|
||||
})
|
||||
.pipe(Effect.mapError((error) => operationError(strategy.id, "remove", error)))
|
||||
yield* changed(yield* ops.remove(worktreeDirectory))
|
||||
})
|
||||
yield* changed(input.projectID, yield* ops.remove(worktreeDirectory))
|
||||
}, Effect.scoped)
|
||||
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* () {
|
||||
yield* local
|
||||
const refresh = Effect.fn("Worktree.refresh")(function* (
|
||||
input: { projectID: Project.ID },
|
||||
current?: WorktreeStrategies.Interface,
|
||||
) {
|
||||
const row = yield* project(input.projectID)
|
||||
const settings = yield* load(row.worktree, current)
|
||||
const ops = inventory(input.projectID)
|
||||
const stored = yield* ops.list()
|
||||
const checked = yield* Effect.forEach(
|
||||
stored,
|
||||
(item) => fs.isDir(item.directory).pipe(Effect.map((exists) => ({ ...item, exists }))),
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
const strategies = Array.from(state.get().strategies.values()).toReversed()
|
||||
const strategies = Array.from(settings.strategies.values()).toReversed()
|
||||
const discovered = new Map<AbsolutePath, StoredInput>()
|
||||
// A location's plugin instances only discover its own checkout, not sibling clones.
|
||||
if (checked.some((item) => item.directory === location.project.directory && item.exists)) {
|
||||
// Unowned rows are checkout/discovery roots. Managed children are enumerated by their backend.
|
||||
const roots = new Set([
|
||||
row.worktree,
|
||||
...checked.filter((item) => item.exists && !item.strategy).map((item) => item.directory),
|
||||
])
|
||||
for (const directory of roots) {
|
||||
if (!(yield* fs.isDir(directory))) continue
|
||||
for (const strategy of strategies) {
|
||||
const entries = yield* strategy.list(location.project.directory).pipe(
|
||||
const entries = yield* strategy.list(directory).pipe(
|
||||
Effect.mapError((error) => operationError(strategy.id, "list", error)),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.succeed([])),
|
||||
Effect.catch((error) =>
|
||||
Effect.logWarning("worktree discovery failed", { directory, strategy: strategy.id, error }).pipe(
|
||||
Effect.as([]),
|
||||
),
|
||||
),
|
||||
)
|
||||
for (const entry of entries) {
|
||||
const directory = yield* canonical(fs, entry.directory).pipe(
|
||||
@@ -343,16 +342,14 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
.pipe(Effect.orDie)
|
||||
yield* changed(changes.updated.length > 0 || changes.removed.length > 0)
|
||||
yield* changed(input.projectID, changes.updated.length > 0 || changes.removed.length > 0)
|
||||
return changes
|
||||
})
|
||||
}, Effect.scoped)
|
||||
|
||||
return Service.of({
|
||||
transform: state.transform,
|
||||
reload: state.reload,
|
||||
list: Effect.fn("Worktree.list")(function* () {
|
||||
yield* refresh()
|
||||
return yield* ops.list()
|
||||
list: Effect.fn("Worktree.list")(function* (input) {
|
||||
yield* project(input.projectID)
|
||||
return yield* inventory(input.projectID).list()
|
||||
}),
|
||||
create,
|
||||
remove,
|
||||
@@ -361,10 +358,10 @@ const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
export const node: LayerNode.Provider<Service, never, typeof Node.tags.values.global> = Node.makeGlobalNode({
|
||||
service: Service,
|
||||
layer: layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, Location.node, Global.node],
|
||||
layer,
|
||||
deps: [FSUtil.node, Git.node, Bus.node, Database.node, AppProcess.node, LocationServiceMap.node],
|
||||
})
|
||||
|
||||
function operationError(strategy: StrategyID, operation: string, error: unknown) {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { Worktree } from "@opencode/schema/worktree"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
import { Git } from "../git.js"
|
||||
import { canonical, DirectoryUnavailableError } from "./directory.js"
|
||||
import type { ListEntry, Strategy } from "../worktree.js"
|
||||
import type { Strategy } from "./strategies.js"
|
||||
|
||||
export const make = Effect.gen(function* () {
|
||||
const fs = yield* FSUtil.Service
|
||||
@@ -33,7 +33,7 @@ export const make = Effect.gen(function* () {
|
||||
Effect.map((directory) => ({ directory, type: entry.kind === "main" ? "root" : "worktree" }) as const),
|
||||
Effect.catchTag("Worktree.DirectoryUnavailableError", () => Effect.undefined),
|
||||
),
|
||||
).pipe(Effect.map((items) => items.filter((item): item is ListEntry => item !== undefined)))
|
||||
).pipe(Effect.map((items) => items.filter((item): item is Worktree.ListEntry => item !== undefined)))
|
||||
}),
|
||||
} satisfies Strategy
|
||||
})
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export * as WorktreeRefresh from "./refresh.js"
|
||||
|
||||
import { Effect, Layer } from "effect"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Location } from "../location.js"
|
||||
import { Plugin } from "../plugin.js"
|
||||
import { PluginSupervisor } from "../plugin/supervisor.js"
|
||||
import { Worktree } from "../worktree.js"
|
||||
|
||||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const plugins = yield* Plugin.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
if (location.workspaceID) return
|
||||
yield* plugins.awaitActivation.pipe(
|
||||
Effect.andThen(worktrees.refresh()),
|
||||
Effect.catchCause((cause) => Effect.logWarning("worktree refresh failed", { cause })),
|
||||
Effect.forkScoped,
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
name: "worktree-refresh",
|
||||
layer,
|
||||
deps: [Worktree.node, Location.node, Plugin.node, PluginSupervisor.node],
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
export * as WorktreeStrategies from "./strategies.js"
|
||||
|
||||
import { Context, Effect, Layer } from "effect"
|
||||
import path from "path"
|
||||
import { makeLocationNode } from "@opencode/util/effect/app-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { Worktree } from "@opencode/schema/worktree"
|
||||
import { AbsolutePath } from "../schema.js"
|
||||
import { Git } from "../git.js"
|
||||
import { Location } from "../location.js"
|
||||
import { State } from "../state.js"
|
||||
import { WorktreeGit } from "./git.js"
|
||||
import { FSUtil } from "@opencode/util/fs-util"
|
||||
|
||||
export interface Strategy {
|
||||
readonly id: Worktree.StrategyID
|
||||
readonly create: (input: {
|
||||
sourceDirectory: AbsolutePath
|
||||
directory: AbsolutePath
|
||||
branch?: string
|
||||
}) => Effect.Effect<Worktree.Info, unknown>
|
||||
readonly remove: (input: { directory: AbsolutePath; force: boolean }) => Effect.Effect<void, unknown>
|
||||
readonly list: (directory: AbsolutePath) => Effect.Effect<readonly Worktree.ListEntry[], unknown>
|
||||
}
|
||||
|
||||
export interface Editor {
|
||||
readonly add: (strategy: Strategy) => void
|
||||
readonly configure: (settings: { readonly directory: AbsolutePath }) => void
|
||||
}
|
||||
|
||||
export interface Interface extends State.Transformable<Editor> {
|
||||
readonly directory: AbsolutePath
|
||||
readonly get: () => {
|
||||
readonly directory: AbsolutePath
|
||||
readonly strategies: ReadonlyMap<Worktree.StrategyID, Strategy>
|
||||
readonly selected: Worktree.StrategyID
|
||||
}
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/WorktreeStrategies") {}
|
||||
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const location = yield* Location.Service
|
||||
const global = yield* Global.Service
|
||||
const git = yield* WorktreeGit.make
|
||||
const state = State.create({
|
||||
name: "worktree",
|
||||
initial: () => ({
|
||||
directory: AbsolutePath.make(path.join(global.data, "worktree", location.project.id.slice(0, 6))),
|
||||
strategies: new Map<Worktree.StrategyID, Strategy>([[git.id, git]]),
|
||||
selected: git.id,
|
||||
}),
|
||||
editor: (value): Editor => ({
|
||||
configure: (settings) => {
|
||||
value.directory = settings.directory
|
||||
},
|
||||
add: (strategy) => {
|
||||
value.strategies.delete(strategy.id)
|
||||
value.strategies.set(strategy.id, strategy)
|
||||
value.selected = strategy.id
|
||||
},
|
||||
}),
|
||||
})
|
||||
return Service.of({ ...state, directory: location.directory })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Location.node, Global.node, Git.node, FSUtil.node],
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { Effect } from "effect"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Git } from "@opencode/core/git"
|
||||
import { AbsolutePath, RelativePath } from "@opencode/core/schema"
|
||||
import { VcsPatch } from "@opencode/core/vcs/patch"
|
||||
import { branch, commit, initRepo, read, withRemote } from "./fixture/git"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
@@ -196,6 +197,42 @@ describe("Git trees", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("caps batched tree patches, keeps per-file stats past the cap, and matches non-ASCII names", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
(dir) => Effect.promise(() => dir[Symbol.asyncDispose]()),
|
||||
)
|
||||
yield* Effect.promise(() => initRepo(root.path))
|
||||
const git = yield* Git.Service
|
||||
const repository = yield* git.repo.discover(AbsolutePath.make(root.path))
|
||||
if (!repository) throw new Error("Repository not found")
|
||||
const before = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
const lines = Math.ceil(VcsPatch.MAX_TOTAL_PATCH_BYTES / 80) + 1
|
||||
yield* Effect.promise(async () => {
|
||||
await Bun.write(path.join(root.path, "a-small.txt"), "small\n")
|
||||
await Bun.write(path.join(root.path, "b-large.txt"), `${"x".repeat(79)}\n`.repeat(lines))
|
||||
await Bun.write(path.join(root.path, "c-binary.bin"), new Uint8Array([0, 1, 2, 3]))
|
||||
await Bun.write(path.join(root.path, "a-caf\u00e9.txt"), "caf\u00e9\n")
|
||||
})
|
||||
const after = yield* git.tree.capture({ repository, scopes: [RelativePath.make(".")] })
|
||||
|
||||
const diffs = yield* git.tree.diff({ repository, from: before, to: after, context: 0 })
|
||||
expect(diffs.map((item) => [item.file, item.status, item.additions, item.deletions])).toEqual([
|
||||
["a-caf\u00e9.txt", "added", 1, 0],
|
||||
["a-small.txt", "added", 1, 0],
|
||||
["b-large.txt", "added", lines, 0],
|
||||
["c-binary.bin", "added", 0, 0],
|
||||
])
|
||||
// Patch headers are not NUL-delimited; a quoted (octal-escaped) header would orphan this chunk.
|
||||
expect(diffs[0]?.patch).toContain("+caf\u00e9\n")
|
||||
expect(diffs[1]?.patch).toContain("+small\n")
|
||||
expect(diffs[2]?.patch).toBe(VcsPatch.emptyPatch("b-large.txt"))
|
||||
expect(diffs[3]?.patch).toBe("")
|
||||
expect(yield* git.tree.diff({ repository, from: before, to: after, paths: [] })).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("captures, compares, previews, and restores scoped trees", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
|
||||
@@ -66,7 +66,8 @@ export const registerToolPlugin = <R>(
|
||||
const context = host({
|
||||
...overrides,
|
||||
session: {
|
||||
hook: () => Effect.succeed({ dispose: Effect.void }),
|
||||
...overrides.session,
|
||||
hook: overrides.session?.hook ?? (() => Effect.succeed({ dispose: Effect.void })),
|
||||
},
|
||||
tool: {
|
||||
transform: tools.transform,
|
||||
|
||||
@@ -33,6 +33,7 @@ import { Tool } from "@opencode/core/tool"
|
||||
import { Vcs } from "@opencode/core/vcs"
|
||||
import { WebSearch } from "@opencode/core/websearch"
|
||||
import { Worktree } from "@opencode/core/worktree"
|
||||
import { WorktreeStrategies } from "@opencode/core/worktree/strategies"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { tempLocationLayer } from "../fixture/location"
|
||||
import { emptyMcpLayer } from "../fixture/mcp"
|
||||
@@ -96,6 +97,7 @@ export const PluginTestLayer = AppNodeBuilder.build(
|
||||
Watcher.node,
|
||||
WebSearch.node,
|
||||
Worktree.node,
|
||||
WorktreeStrategies.node,
|
||||
]),
|
||||
[
|
||||
Location.node.replace(tempLocationLayer),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
import { $ } from "bun"
|
||||
import { describe, expect } from "bun:test"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect } from "effect"
|
||||
import { Agent } from "@opencode/core/agent"
|
||||
import { Bus } from "@opencode/core/bus"
|
||||
import { Database } from "@opencode/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
|
||||
import { LocationServiceMap } from "@opencode/core/location-service-map"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Plugin } from "@opencode/core/plugin"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { SessionDiff } from "@opencode/core/session/diff"
|
||||
import { SessionEvent } from "@opencode/core/session/event"
|
||||
import { SessionExecution } from "@opencode/core/session/execution"
|
||||
import { SessionInbox } from "@opencode/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode/core/session/message"
|
||||
import { SessionProjector } from "@opencode/core/session/projector"
|
||||
import { Snapshot } from "@opencode/core/snapshot"
|
||||
import { Money } from "@opencode/schema/money"
|
||||
import { LayerNode } from "@opencode/util/effect/layer-node"
|
||||
import { Global } from "@opencode/util/global"
|
||||
import { tempGlobalLayer } from "./fixture/global"
|
||||
import { offlineModels } from "./fixture/models"
|
||||
import { tmpdirScoped } from "./fixture/tmpdir"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, Session.node, LocationServiceMap.node]),
|
||||
[Global.node.replace(tempGlobalLayer), SessionExecution.node.replace(SessionExecution.noopLayer), offlineModels],
|
||||
),
|
||||
)
|
||||
|
||||
const summarize = (file: { file: string; status: string; additions: number; deletions: number }) => [
|
||||
file.file,
|
||||
file.status,
|
||||
file.additions,
|
||||
file.deletions,
|
||||
]
|
||||
|
||||
describe("Session.diff", () => {
|
||||
it.live(
|
||||
"diffs the busy period containing a user message and ranges across later turns",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const directory = path.join(tmp.path, "project")
|
||||
const write = (name: string, content: string) => () => Bun.write(path.join(directory, name), content)
|
||||
yield* Effect.promise(async () => {
|
||||
await fs.mkdir(directory)
|
||||
await write("first.txt", "first\n")()
|
||||
await write("second.txt", "second\n")()
|
||||
await write("manual.txt", "manual\n")()
|
||||
await $`git init -q`.cwd(directory).quiet()
|
||||
await $`git -c core.fsmonitor=false add .`.cwd(directory).quiet()
|
||||
})
|
||||
const sessions = yield* Session.Service
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
const created = yield* sessions.create({ location: { directory: AbsolutePath.make(directory) } })
|
||||
const diff = (input?: { from?: SessionMessage.ID; to?: SessionMessage.ID }) =>
|
||||
sessions
|
||||
.diff({ sessionID: created.id, context: 0, ...input })
|
||||
.pipe(Effect.map((files) => files.map(summarize)))
|
||||
expect(yield* diff()).toEqual([])
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const plugins = yield* Plugin.Service
|
||||
yield* plugins.awaitActivation
|
||||
const snapshot = yield* Snapshot.Service
|
||||
const usage = {
|
||||
cost: Money.USD.zero,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
}
|
||||
const prompt = Effect.fn(function* (text: string) {
|
||||
const admitted = yield* sessions.prompt({ sessionID: created.id, text, resume: false })
|
||||
yield* SessionInbox.promote(database.db, bus, created.id, "steer")
|
||||
return admitted.id
|
||||
})
|
||||
const step = Effect.fn(function* (edit: () => Promise<unknown>, end: "recorded" | "unrecorded" | "running") {
|
||||
const before = yield* snapshot.capture()
|
||||
if (!before) throw new Error("Start snapshot missing")
|
||||
const assistantMessageID = SessionMessage.ID.create()
|
||||
yield* bus.publish(SessionEvent.Step.Started, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
agent: Agent.defaultID,
|
||||
model: { id: Model.ID.make("test-model"), providerID: Provider.ID.make("test-provider") },
|
||||
snapshot: before,
|
||||
})
|
||||
yield* Effect.promise(edit)
|
||||
if (end === "running") return assistantMessageID
|
||||
const after = end === "recorded" ? yield* snapshot.capture() : undefined
|
||||
yield* bus.publish(SessionEvent.Step.Ended, {
|
||||
sessionID: created.id,
|
||||
assistantMessageID,
|
||||
finish: "stop",
|
||||
...usage,
|
||||
snapshot: after,
|
||||
files: after && before ? yield* snapshot.files({ from: before, to: after }) : undefined,
|
||||
})
|
||||
return assistantMessageID
|
||||
})
|
||||
|
||||
const idle = (outcome: "succeeded" | "failed") =>
|
||||
outcome === "succeeded"
|
||||
? bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
|
||||
: bus.publish(SessionEvent.Execution.Failed, {
|
||||
sessionID: created.id,
|
||||
error: { type: "unknown", message: "failed" },
|
||||
})
|
||||
|
||||
// Before any idle marker exists, a prompt's turn ends at the next prompt.
|
||||
const first = yield* prompt("Edit the first file")
|
||||
const firstStep = yield* step(write("first.txt", "first edited\n"), "recorded")
|
||||
// Edits made while idle are not a turn's work, but a range spanning them still sees them.
|
||||
yield* Effect.promise(write("manual.txt", "manual edited\n"))
|
||||
const second = yield* prompt("Edit the second file")
|
||||
yield* step(write("second.txt", "second edited\n"), "recorded")
|
||||
expect(yield* diff()).toEqual([["second.txt", "modified", 1, 1]])
|
||||
expect(yield* diff({ from: first })).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Once markers exist, a turn spans a whole busy period, steers included; earlier history merges into the first one.
|
||||
yield* idle("succeeded")
|
||||
const third = yield* prompt("Add a third file")
|
||||
yield* step(write("third.txt", "third\n"), "recorded")
|
||||
const steer = yield* prompt("Also add a fourth file")
|
||||
yield* step(write("fourth.txt", "fourth\n"), "recorded")
|
||||
yield* idle("failed")
|
||||
const busy = [
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["third.txt", "added", 1, 0],
|
||||
]
|
||||
expect(yield* diff()).toEqual(busy)
|
||||
expect(yield* diff({ from: steer })).toEqual(busy)
|
||||
expect(yield* diff({ from: second })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
])
|
||||
expect(yield* diff({ from: first, to: third })).toEqual([
|
||||
["first.txt", "modified", 1, 1],
|
||||
["fourth.txt", "added", 1, 0],
|
||||
["manual.txt", "modified", 1, 1],
|
||||
["second.txt", "modified", 1, 1],
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
const full = yield* sessions.diff({ sessionID: created.id, from: first })
|
||||
expect(full[0]?.patch).toContain("-first\n+first edited\n")
|
||||
expect(yield* diff({ from: steer, to: second }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "to",
|
||||
})
|
||||
expect(yield* diff({ from: firstStep }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.TurnRangeError",
|
||||
field: "from",
|
||||
})
|
||||
expect(yield* diff({ from: SessionMessage.ID.create() }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
|
||||
// A completed step without an end snapshot falls back to the last recorded end.
|
||||
yield* prompt("Edit both files again")
|
||||
yield* step(write("first.txt", "first edited twice\n"), "recorded")
|
||||
yield* step(write("second.txt", "second edited twice\n"), "unrecorded")
|
||||
yield* idle("succeeded")
|
||||
expect(yield* diff()).toEqual([["first.txt", "modified", 1, 1]])
|
||||
|
||||
// Only a step still running in the active session compares against the working copy.
|
||||
yield* prompt("Delete the manual file")
|
||||
yield* step(() => fs.rm(path.join(directory, "manual.txt")), "running")
|
||||
expect(yield* diff()).toEqual([])
|
||||
const session = yield* sessions.get(created.id)
|
||||
const live = yield* SessionDiff.turn(database.db, locations, { session, active: true, context: 0 })
|
||||
expect(live.map(summarize)).toEqual([["manual.txt", "deleted", 0, 1]])
|
||||
|
||||
// Reverting removes later history, markers included; a fork keeps the copied turns.
|
||||
yield* sessions.revert.stage({ sessionID: created.id, messageID: steer, files: false })
|
||||
yield* sessions.revert.commit(created.id)
|
||||
expect(yield* diff()).toEqual([["third.txt", "added", 1, 0]])
|
||||
expect(yield* diff({ from: steer }).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageNotFoundError",
|
||||
})
|
||||
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
|
||||
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
|
||||
["third.txt", "added", 1, 0],
|
||||
])
|
||||
}).pipe(Effect.provide(LocationServiceMap.Service.get(created.location)))
|
||||
}),
|
||||
// Real Location/plugin startup and Git snapshots can exceed five seconds under CI load.
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
@@ -561,7 +561,9 @@ describe("SessionRestart background recovery", () => {
|
||||
expect(yield* restarted.pendingBackground).toEqual([])
|
||||
expect(yield* SessionInbox.list(database.db, sessionID)).toHaveLength(delivered ? 0 : 1)
|
||||
yield* SessionInbox.promote(database.db, bus, sessionID, "steer")
|
||||
expect(yield* sessions.messages({ sessionID })).toMatchObject([
|
||||
// Recovery ends a busy period, so an idle marker follows the notification.
|
||||
const messages = (yield* sessions.messages({ sessionID })).filter((message) => message.type !== "idle")
|
||||
expect(messages).toMatchObject([
|
||||
{
|
||||
id: background.notificationID,
|
||||
type: "synthetic",
|
||||
@@ -569,7 +571,6 @@ describe("SessionRestart background recovery", () => {
|
||||
metadata: { state: "completed" },
|
||||
},
|
||||
])
|
||||
expect(yield* sessions.messages({ sessionID })).toHaveLength(1)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import { Formatter } from "@opencode/core/formatter"
|
||||
import { FileMutation } from "@opencode/core/file-mutation"
|
||||
import { Location } from "@opencode/core/location"
|
||||
import { FileAccess } from "@opencode/core/file-access"
|
||||
import { Model } from "@opencode/core/model"
|
||||
import { Permission } from "@opencode/core/permission"
|
||||
import { Provider } from "@opencode/core/provider"
|
||||
import { AbsolutePath } from "@opencode/core/schema"
|
||||
import { Session } from "@opencode/core/session"
|
||||
import { Tool } from "@opencode/core/tool"
|
||||
import { PatchTool } from "@opencode/core/tool/plugin/patch"
|
||||
import type { SessionHooks } from "@opencode/plugin/effect/session"
|
||||
import { transformEnvironmentFiles } from "./fixture/environment"
|
||||
import { location } from "./fixture/location"
|
||||
import { tmpdir } from "./fixture/tmpdir"
|
||||
@@ -22,9 +25,20 @@ import { testEffect } from "./lib/effect"
|
||||
import { permissionLayer } from "./lib/permission"
|
||||
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
|
||||
|
||||
const sessionHooks = new Map<string, (event: SessionHooks["context"]) => Effect.Effect<void>>()
|
||||
const patchToolNode = makeLocationNode({
|
||||
name: "test/patch-tool-plugin",
|
||||
layer: Layer.effectDiscard(registerToolPlugin(PatchTool.Plugin)),
|
||||
layer: Layer.effectDiscard(
|
||||
registerToolPlugin(PatchTool.Plugin, {
|
||||
session: {
|
||||
hook: (name, callback) =>
|
||||
Effect.sync(() => {
|
||||
sessionHooks.set(name, callback as (event: SessionHooks["context"]) => Effect.Effect<void>)
|
||||
return { dispose: Effect.void }
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
deps: [
|
||||
Tool.node,
|
||||
FileAccess.node,
|
||||
@@ -153,6 +167,34 @@ const withTempTool = <A, E, R>(body: (directory: string, registry: Tool.Interfac
|
||||
)
|
||||
|
||||
describe("PatchTool", () => {
|
||||
it.live("selects the same edit tools for compaction and generate requests as the agent loop", () =>
|
||||
withTempTool(() =>
|
||||
Effect.gen(function* () {
|
||||
const event = (id: string): SessionHooks["context"] => ({
|
||||
sessionID,
|
||||
agent: toolIdentity.agent,
|
||||
model: Model.Ref.make({ providerID: Provider.ID.make("test"), id: Model.ID.make(id) }),
|
||||
system: [],
|
||||
messages: [],
|
||||
tools: Object.fromEntries(
|
||||
["patch", "edit", "write", "read"].map((name) => [name, { description: name, input: { type: "object" } }]),
|
||||
),
|
||||
options: {},
|
||||
})
|
||||
for (const name of ["context", "compaction", "generate"]) {
|
||||
const hook = sessionHooks.get(name)
|
||||
expect(hook).toBeDefined()
|
||||
const claude = event("claude-sonnet-4")
|
||||
yield* hook!(claude)
|
||||
expect(Object.keys(claude.tools)).toEqual(["edit", "write", "read"])
|
||||
const gpt = event("gpt-5")
|
||||
yield* hook!(gpt)
|
||||
expect(Object.keys(gpt.tools)).toEqual(["patch", "read"])
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("registers and sequentially applies add, update, and delete hunks", () =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Bus } from "@opencode/core/bus"
|
||||
import { Project } from "@opencode/core/project"
|
||||
import { ProjectTable } from "@opencode/core/project/sql"
|
||||
import { Worktree } from "@opencode/core/worktree"
|
||||
import { WorktreeStrategies } from "@opencode/core/worktree/strategies"
|
||||
import { WorktreeDirectory } from "@opencode/core/worktree/directory"
|
||||
import { WorktreeTable } from "@opencode/core/worktree/sql"
|
||||
import { WorktreeGit } from "@opencode/core/worktree/git"
|
||||
@@ -57,33 +58,51 @@ function worktreeLayer(
|
||||
data: string,
|
||||
workspaceID?: Workspace.ID,
|
||||
) {
|
||||
return AppNodeBuilder.build(LayerNode.group([Worktree.node, Git.node, FSUtil.node, Location.node, Global.node]), [
|
||||
Database.node.replace(Layer.succeed(Database.Service, database)),
|
||||
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
|
||||
Global.node.replace(Global.layerWith({ data })),
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory,
|
||||
workspaceID,
|
||||
project: { id: projectID, directory, canonical: directory },
|
||||
}),
|
||||
return AppNodeBuilder.build(
|
||||
LayerNode.group([Worktree.node, WorktreeStrategies.node, Git.node, FSUtil.node, Location.node, Global.node]),
|
||||
[
|
||||
Database.node.replace(Layer.succeed(Database.Service, database)),
|
||||
Bus.node.replace(Layer.succeed(Bus.Service, bus)),
|
||||
Global.node.replace(Global.layerWith({ data })),
|
||||
Location.node.replace(
|
||||
Layer.succeed(
|
||||
Location.Service,
|
||||
Location.Service.of({
|
||||
directory,
|
||||
workspaceID,
|
||||
project: { id: projectID, directory, canonical: directory },
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
]).pipe(Layer.fresh)
|
||||
],
|
||||
).pipe(Layer.fresh)
|
||||
}
|
||||
|
||||
function abs(input: string) {
|
||||
return AbsolutePath.make(input)
|
||||
}
|
||||
|
||||
const gitWorktree = Worktree.StrategyID.make("git")
|
||||
|
||||
const setup = Effect.fnUntraced(function* () {
|
||||
return yield* Fixture
|
||||
})
|
||||
|
||||
// Bind the fixture's project and already-registered plugin strategies for backend tests.
|
||||
const fixtureWorktree = Effect.fnUntraced(function* () {
|
||||
const input = yield* Fixture
|
||||
const service = yield* Worktree.Service
|
||||
const strategies = yield* WorktreeStrategies.Service
|
||||
return {
|
||||
transform: strategies.transform,
|
||||
reload: strategies.reload,
|
||||
list: () => service.list({ projectID: input.projectID }),
|
||||
create: (options: Omit<Worktree.CreateInput, "projectID"> = {}) =>
|
||||
service.create({ projectID: input.projectID, ...options }, strategies),
|
||||
remove: (options: Omit<Worktree.RemoveInput, "projectID">) =>
|
||||
service.remove({ projectID: input.projectID, ...options }, strategies),
|
||||
refresh: () => service.refresh({ projectID: input.projectID }, strategies),
|
||||
}
|
||||
})
|
||||
|
||||
function makeFixture() {
|
||||
return Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
@@ -154,19 +173,18 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("reports unavailable strategy ids", () =>
|
||||
it.live("reports unavailable recorded strategy ids", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const unavailable = Worktree.StrategyID.make("acme/missing")
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
strategy: unavailable,
|
||||
from: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-strategy`),
|
||||
name: "worktree",
|
||||
})
|
||||
.pipe(Effect.flip)
|
||||
yield* input.db
|
||||
.update(WorktreeTable)
|
||||
.set({ strategy: unavailable })
|
||||
.where(eq(WorktreeTable.project_id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const error = yield* worktree.remove({ directory: input.sourceDirectory, force: false }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.StrategyUnavailableError)
|
||||
if (error instanceof Worktree.StrategyUnavailableError) expect(error.strategy).toBe(unavailable)
|
||||
}),
|
||||
@@ -180,11 +198,10 @@ describe("Worktree", () => {
|
||||
.where(eq(WorktreeTable.project_id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: abs(`${input.root.path}-missing-source`),
|
||||
name: "worktree",
|
||||
@@ -199,7 +216,7 @@ describe("Worktree", () => {
|
||||
it.live("creates and removes a git worktree directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const bus = yield* Bus.Service
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-created"))
|
||||
@@ -211,7 +228,6 @@ describe("Worktree", () => {
|
||||
yield* Effect.yieldNow
|
||||
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
})
|
||||
@@ -234,17 +250,15 @@ describe("Worktree", () => {
|
||||
it.live("defaults to the TUI worktree directory and suffixes duplicate names", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const global = yield* Global.Service
|
||||
const parent = path.join(global.data, "worktree", "worktr")
|
||||
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
name: "task",
|
||||
})
|
||||
const duplicate = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
name: "task",
|
||||
})
|
||||
@@ -260,7 +274,7 @@ describe("Worktree", () => {
|
||||
it.live("runs the project setup script with worktree paths", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-setup"))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
@@ -276,7 +290,6 @@ describe("Worktree", () => {
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
})
|
||||
@@ -290,7 +303,7 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
projectIt.live("creates worktrees and runs setup from the selected clone", () =>
|
||||
projectIt.live("uses canonical configuration with an explicit source clone", () =>
|
||||
Effect.gen(function* () {
|
||||
const root = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir()),
|
||||
@@ -313,7 +326,7 @@ describe("Worktree", () => {
|
||||
const selected = yield* projects.resolve(clone)
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const context = yield* Layer.build(worktreeLayer(selected.directory, selected.id, database, bus, root.path))
|
||||
const context = yield* Layer.build(worktreeLayer(main, selected.id, database, bus, root.path))
|
||||
const worktrees = Context.get(context, Worktree.Service)
|
||||
const config = yield* Config.Test
|
||||
yield* config.setEntries([
|
||||
@@ -332,14 +345,17 @@ describe("Worktree", () => {
|
||||
},
|
||||
})
|
||||
|
||||
const created = yield* worktrees.create({
|
||||
strategy: gitWorktree,
|
||||
from: selected.canonical,
|
||||
name: "selected-clone",
|
||||
})
|
||||
const created = yield* worktrees.create(
|
||||
{
|
||||
projectID: initial.id,
|
||||
from: selected.canonical,
|
||||
name: "selected-clone",
|
||||
},
|
||||
Context.get(context, WorktreeStrategies.Service),
|
||||
)
|
||||
|
||||
expect(selected.id).toBe(initial.id)
|
||||
expect(created.directory).toBe(abs(path.join(clone, ".lane/trees/selected-clone")))
|
||||
expect(created.directory).toBe(abs(path.join(main, ".lane/trees/selected-clone")))
|
||||
expect((yield* projects.list()).find((project) => project.id === initial.id)?.canonical).toBe(main)
|
||||
expect(yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(created.directory).text())).toBe(
|
||||
yield* Effect.promise(() => $`git rev-parse HEAD`.cwd(clone).text()),
|
||||
@@ -355,7 +371,7 @@ describe("Worktree", () => {
|
||||
it.live("creates a git worktree from a selected branch", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const parent = abs(`${input.root.path}-branch-worktree`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
yield* Effect.promise(async () => {
|
||||
@@ -363,7 +379,6 @@ describe("Worktree", () => {
|
||||
})
|
||||
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
branch: "feature-base",
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -380,13 +395,12 @@ describe("Worktree", () => {
|
||||
it.live("does not interpret a branch as a git option", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const parent = abs(`${input.root.path}-option-worktree`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
strategy: gitWorktree,
|
||||
branch: "--no-checkout",
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -401,12 +415,11 @@ describe("Worktree", () => {
|
||||
it.live("rejects a missing source directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
strategy: gitWorktree,
|
||||
from: abs(path.join(temp, "does-not-exist")),
|
||||
directory: abs(`${input.root.path}-missing-directory`),
|
||||
name: "worktree",
|
||||
@@ -420,7 +433,7 @@ describe("Worktree", () => {
|
||||
it.live("creates from another managed worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const sourceParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-source"))
|
||||
const targetParent = abs(path.join(temp, path.basename(input.root.path) + "-managed-target"))
|
||||
@@ -431,7 +444,6 @@ describe("Worktree", () => {
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
const source = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: sourceParent,
|
||||
name: "source",
|
||||
@@ -443,7 +455,6 @@ describe("Worktree", () => {
|
||||
.pipe(Effect.orDie)
|
||||
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: source.directory,
|
||||
directory: targetParent,
|
||||
name: "target",
|
||||
@@ -458,12 +469,11 @@ describe("Worktree", () => {
|
||||
it.live("requires force to remove a dirty git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-dirty"))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -488,7 +498,7 @@ describe("Worktree", () => {
|
||||
it.live("preserves worktrees whose stored strategy is unavailable", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const unavailable = abs(`${input.root.path}-worktree-unavailable`)
|
||||
yield* Effect.promise(() => fs.mkdir(unavailable))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(unavailable, { recursive: true, force: true })))
|
||||
@@ -508,7 +518,7 @@ describe("Worktree", () => {
|
||||
it.live("adds a numeric suffix when a worktree directory already exists", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-suffix"))
|
||||
const target = abs(path.join(parent, "worktree-3"))
|
||||
@@ -517,7 +527,6 @@ describe("Worktree", () => {
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(parent, "worktree-2")))
|
||||
|
||||
const created = yield* worktree.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -538,7 +547,7 @@ describe("Worktree", () => {
|
||||
it.live("fails after ten worktree directory conflicts", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const temp = yield* Effect.promise(() => fs.realpath(path.dirname(input.root.path)))
|
||||
const parent = abs(path.join(temp, path.basename(input.root.path) + "-worktree-conflicts"))
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(parent, { recursive: true, force: true })))
|
||||
@@ -552,7 +561,6 @@ describe("Worktree", () => {
|
||||
|
||||
const error = yield* worktree
|
||||
.create({
|
||||
strategy: gitWorktree,
|
||||
from: input.sourceDirectory,
|
||||
directory: parent,
|
||||
name: "worktree",
|
||||
@@ -567,7 +575,7 @@ describe("Worktree", () => {
|
||||
|
||||
it.live("does not publish an event when refresh finds no directory changes", () =>
|
||||
Effect.gen(function* () {
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const bus = yield* Bus.Service
|
||||
const event = yield* bus.subscribe(Worktree.Event.Updated).pipe(
|
||||
Stream.take(1),
|
||||
@@ -589,7 +597,7 @@ describe("Worktree", () => {
|
||||
it.live("refresh discovers and prunes an externally managed git worktree", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const bus = yield* Bus.Service
|
||||
const target = abs(`${input.root.path}-worktree-external`)
|
||||
const unchanged = abs(`${input.root.path}-worktree-existing`)
|
||||
@@ -641,7 +649,7 @@ describe("Worktree", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
const stale = abs(`${input.root.path}-worktree-stale`)
|
||||
const target = abs(`${input.root.path}-worktree-after-stale`)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => fs.rm(target, { recursive: true, force: true })))
|
||||
@@ -666,7 +674,7 @@ describe("Worktree", () => {
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* Effect.promise(() => fs.rm(path.join(input.sourceDirectory, ".git"), { recursive: true }))
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
|
||||
yield* worktree.refresh()
|
||||
|
||||
@@ -674,7 +682,7 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("refresh with no roots is a no-op", () =>
|
||||
it.live("refresh seeds the canonical checkout when inventory is empty", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
yield* input.db
|
||||
@@ -682,10 +690,10 @@ describe("Worktree", () => {
|
||||
.where(eq(WorktreeTable.project_id, input.projectID))
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
|
||||
expect(yield* worktree.refresh()).toEqual({
|
||||
updated: [],
|
||||
updated: [input.sourceDirectory],
|
||||
removed: [],
|
||||
})
|
||||
}),
|
||||
@@ -700,7 +708,7 @@ describe("Worktree", () => {
|
||||
.values({ project_id: input.projectID, directory: missing })
|
||||
.run()
|
||||
.pipe(Effect.orDie)
|
||||
const worktree = yield* Worktree.Service
|
||||
const worktree = yield* fixtureWorktree()
|
||||
|
||||
expect(yield* worktree.refresh()).toEqual({ updated: [], removed: [missing] })
|
||||
|
||||
@@ -711,7 +719,7 @@ describe("Worktree", () => {
|
||||
it.live("defaults to Git and configured directory without depending on Config", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const parent = abs(path.join(input.root.path, "configured"))
|
||||
const registration = yield* worktrees.transform((editor) => editor.configure({ directory: parent }))
|
||||
const created = yield* worktrees.create({ name: "configured" })
|
||||
@@ -731,7 +739,7 @@ describe("Worktree", () => {
|
||||
it.live("selects the last active registration and restores earlier strategies on disposal", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const git = yield* WorktreeGit.make
|
||||
const parent = abs(path.join(input.root.path, "strategies"))
|
||||
const first = yield* worktrees.transform((editor) =>
|
||||
@@ -761,7 +769,7 @@ describe("Worktree", () => {
|
||||
it.live("does not fall back to Git when a registered strategy fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const git = yield* WorktreeGit.make
|
||||
yield* worktrees.transform((editor) =>
|
||||
editor.add({
|
||||
@@ -774,19 +782,13 @@ describe("Worktree", () => {
|
||||
const error = yield* worktrees.create({ directory: parent, name: "failure" }).pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.OperationError)
|
||||
expect(yield* stored(input.projectID)).toEqual([{ directory: input.sourceDirectory, strategy: null }])
|
||||
const explicit = yield* worktrees.create({
|
||||
directory: parent,
|
||||
name: "explicit",
|
||||
strategy: gitWorktree,
|
||||
})
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory: explicit.directory, strategy: "git" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects a source override belonging to another project", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const projects = yield* Project.Service
|
||||
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
|
||||
yield* Effect.promise(() => initRepo(other.path))
|
||||
@@ -798,9 +800,9 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("cannot remove a worktree from another project through the current location", () =>
|
||||
it.live("cannot remove a worktree belonging to another project", () =>
|
||||
Effect.gen(function* () {
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const projects = yield* Project.Service
|
||||
const other = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir()))
|
||||
yield* Effect.promise(() => initRepo(other.path))
|
||||
@@ -814,7 +816,7 @@ describe("Worktree", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("rejects workspace-qualified locations before running worktree operations", () =>
|
||||
it.live("list is independent of an ambient workspace-qualified location", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const database = yield* Database.Service
|
||||
@@ -831,23 +833,17 @@ describe("Worktree", () => {
|
||||
),
|
||||
)
|
||||
const worktrees = Context.get(context, Worktree.Service)
|
||||
const directory = abs(path.join(input.root.path, "not-created"))
|
||||
const errors = yield* Effect.all([
|
||||
worktrees.list().pipe(Effect.flip),
|
||||
worktrees.create({ directory, name: "task" }).pipe(Effect.flip),
|
||||
worktrees.remove({ directory: input.sourceDirectory, force: true }).pipe(Effect.flip),
|
||||
worktrees.refresh().pipe(Effect.flip),
|
||||
expect(yield* worktrees.list({ projectID: input.projectID })).toEqual([
|
||||
{ directory: input.sourceDirectory, strategy: undefined },
|
||||
])
|
||||
for (const error of errors) expect(error).toBeInstanceOf(Worktree.UnsupportedLocationError)
|
||||
expect(yield* fs.existsSafe(directory)).toBe(false)
|
||||
expect(yield* fs.isDir(input.sourceDirectory)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("list invokes the location's strategies before returning inventory", () =>
|
||||
it.live("only refresh discovers and prunes; list reads saved inventory", () =>
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const git = yield* WorktreeGit.make
|
||||
const directory = abs(path.join(input.root.path, "discovered"))
|
||||
yield* Effect.promise(() => fs.mkdir(directory))
|
||||
@@ -863,18 +859,24 @@ describe("Worktree", () => {
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(yield* worktrees.list()).not.toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
expect(sources).toEqual([])
|
||||
yield* worktrees.refresh()
|
||||
expect(yield* worktrees.list()).toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
expect(sources).toEqual([input.sourceDirectory])
|
||||
expect(yield* stored(input.projectID)).toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
yield* Effect.promise(() => fs.rmdir(directory))
|
||||
expect(yield* worktrees.list()).toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
yield* worktrees.refresh()
|
||||
expect(yield* worktrees.list()).not.toContainEqual({ directory, strategy: "discovered-copy" })
|
||||
expect(sources).toEqual([input.sourceDirectory, input.sourceDirectory])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("list surfaces strategy discovery failures", () =>
|
||||
it.live("a failed strategy does not block list or other discovery", () =>
|
||||
Effect.gen(function* () {
|
||||
const worktrees = yield* Worktree.Service
|
||||
const input = yield* setup()
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const git = yield* WorktreeGit.make
|
||||
yield* worktrees.transform((editor) =>
|
||||
editor.add({
|
||||
@@ -883,9 +885,10 @@ describe("Worktree", () => {
|
||||
list: () => Effect.fail(new Error("Cannot enumerate worktrees")),
|
||||
}),
|
||||
)
|
||||
const error = yield* worktrees.list().pipe(Effect.flip)
|
||||
expect(error).toBeInstanceOf(Worktree.OperationError)
|
||||
if (error instanceof Worktree.OperationError) expect(error.message).toContain("Cannot enumerate worktrees")
|
||||
const target = abs(path.join(input.root.path, "external"))
|
||||
yield* Effect.promise(() => $`git worktree add --detach ${target} HEAD`.cwd(input.root.path).quiet())
|
||||
yield* worktrees.refresh()
|
||||
expect(yield* worktrees.list()).toContainEqual({ directory: target, strategy: "git" })
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -893,9 +896,10 @@ describe("Worktree", () => {
|
||||
Effect.gen(function* () {
|
||||
const input = yield* setup()
|
||||
const config = yield* Config.Test
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const bus = yield* Bus.Service
|
||||
const reloaded = yield* Queue.unbounded<void>()
|
||||
const strategies = yield* WorktreeStrategies.Service
|
||||
const documents = [
|
||||
new Document({
|
||||
type: "document",
|
||||
@@ -914,8 +918,8 @@ describe("Worktree", () => {
|
||||
yield* ConfigWorktreePlugin.Plugin.effect(
|
||||
host({ event: { subscribe: () => bus.subscribe().pipe(Stream.filter(EventManifest.isServer)) } }),
|
||||
).pipe(
|
||||
Effect.provideService(Worktree.Service, {
|
||||
...worktrees,
|
||||
Effect.provideService(WorktreeStrategies.Service, {
|
||||
...strategies,
|
||||
reload: () => worktrees.reload().pipe(Effect.tap(() => Queue.offer(reloaded, undefined))),
|
||||
}),
|
||||
)
|
||||
@@ -941,7 +945,7 @@ describe("Worktree", () => {
|
||||
const config = yield* Config.Test
|
||||
const projects = yield* Project.Service
|
||||
const global = yield* Global.Service
|
||||
const worktrees = yield* Worktree.Service
|
||||
const worktrees = yield* fixtureWorktree()
|
||||
const linked = abs(path.join(input.root.path, "linked"))
|
||||
const nested = abs(path.join(linked, "src"))
|
||||
const home = abs(path.join(input.root.path, "home"))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode/desktop",
|
||||
"private": true,
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"homepage": "https://opencode.ai",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/enterprise",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode/function",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"name": "@opencode/http-recorder",
|
||||
"description": "Record and replay Effect HTTP and WebSocket traffic with deterministic cassettes",
|
||||
"type": "module",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/httpapi-codegen",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/latex",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/merman",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin-browser",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"description": "OpenCode's desktop browser plugin",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/plugin",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
+685
-405
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@opencode/protocol",
|
||||
"version": "2.0.2",
|
||||
"version": "2.0.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
|
||||
@@ -54,7 +54,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
|
||||
| HttpApiGroup.AddMiddleware<typeof PtyGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ShellGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ReferenceGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof WorktreeGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
|
||||
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
|
||||
|
||||
@@ -90,6 +89,7 @@ type ApiGroups<
|
||||
| typeof DebugGroup
|
||||
| typeof MigrationGroup
|
||||
| typeof WorkspaceGroup
|
||||
| typeof WorktreeGroup
|
||||
| typeof GenerateGroup
|
||||
| typeof PersistentPtyGroup
|
||||
| LocationGroups<LocationId>
|
||||
@@ -176,7 +176,7 @@ const makeApiFromGroup = <
|
||||
.add(PersistentPtyGroup)
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup.middleware(locationMiddleware))
|
||||
.add(WorktreeGroup)
|
||||
.add(WorkspaceGroup)
|
||||
.add(VcsGroup.middleware(locationMiddleware))
|
||||
.add(DebugGroup)
|
||||
|
||||
@@ -14,7 +14,7 @@ export const AgentGroup = HttpApiGroup.make("server.agent")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.agent.list",
|
||||
identifier: "agent.list",
|
||||
summary: "List agents",
|
||||
description: "Retrieve currently registered agents.",
|
||||
}),
|
||||
@@ -30,7 +30,7 @@ export const AgentGroup = HttpApiGroup.make("server.agent")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.agent.get",
|
||||
identifier: "agent.get",
|
||||
summary: "Get agent",
|
||||
description: "Retrieve a single currently registered agent.",
|
||||
}),
|
||||
|
||||
@@ -13,7 +13,7 @@ export const CommandGroup = HttpApiGroup.make("server.command")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.command.list",
|
||||
identifier: "command.list",
|
||||
summary: "List commands",
|
||||
description: "Retrieve currently registered commands.",
|
||||
}),
|
||||
|
||||
@@ -13,7 +13,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.get",
|
||||
identifier: "config.get",
|
||||
summary: "Get configuration",
|
||||
description:
|
||||
"Return configuration documents and discovery sources for the requested location, from lowest to highest priority.",
|
||||
@@ -25,7 +25,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
|
||||
success: Config.Preferences,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.preferences",
|
||||
identifier: "config.preferences",
|
||||
summary: "Get global preferences",
|
||||
description: "Return preferences from the highest-precedence global configuration document.",
|
||||
}),
|
||||
@@ -37,7 +37,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
|
||||
success: Config.Preferences,
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.updatePreferences",
|
||||
identifier: "config.updatePreferences",
|
||||
summary: "Update global preferences",
|
||||
description: "Patch preferences in the highest-precedence global configuration document.",
|
||||
}),
|
||||
@@ -48,7 +48,7 @@ export const ConfigGroup = HttpApiGroup.make("server.config")
|
||||
success: Schema.Array(ConfigShell.Option),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.config.shells",
|
||||
identifier: "config.shells",
|
||||
summary: "List available shells",
|
||||
description: "Return shells available to terminal and agent execution.",
|
||||
}),
|
||||
|
||||
@@ -14,7 +14,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.credential.update",
|
||||
identifier: "credential.update",
|
||||
summary: "Update credential",
|
||||
description: "Update a stored credential label.",
|
||||
}),
|
||||
@@ -30,7 +30,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.credential.activate",
|
||||
identifier: "credential.activate",
|
||||
summary: "Activate credential",
|
||||
description: "Activate a stored integration credential.",
|
||||
}),
|
||||
@@ -45,7 +45,7 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.credential.remove",
|
||||
identifier: "credential.remove",
|
||||
summary: "Remove credential",
|
||||
description: "Remove a stored integration credential.",
|
||||
}),
|
||||
|
||||
@@ -9,7 +9,7 @@ export const DebugGroup = HttpApiGroup.make("server.debug")
|
||||
success: Schema.Array(Location.Ref),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.debug.location.list",
|
||||
identifier: "debug.location.list",
|
||||
summary: "List loaded locations",
|
||||
description: "List locations currently loaded by the server.",
|
||||
}),
|
||||
@@ -23,7 +23,7 @@ export const DebugGroup = HttpApiGroup.make("server.debug")
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.debug.location.evict",
|
||||
identifier: "debug.location.evict",
|
||||
summary: "Evict a loaded location",
|
||||
description: "Dispose the requested location's cached services so its next use boots them fresh.",
|
||||
}),
|
||||
|
||||
@@ -45,7 +45,7 @@ const make = <const Definitions extends ReadonlyArray<Definition>>(definitions:
|
||||
success: HttpApiSchema.StreamSse({ data: EventSchema }),
|
||||
}).annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.event.subscribe",
|
||||
identifier: "event.subscribe",
|
||||
summary: "Subscribe to events",
|
||||
description:
|
||||
"Subscribe to native events and plugin RPC events across all server locations. Volatile by contract: a slow consumer overflows and fails the stream, and events during disconnection are missed.",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user