Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6314f09c14 | ||
|
|
4b4b7832aa | ||
|
|
4280307013 | ||
|
|
9b09a7e766 | ||
|
|
3fc0367b93 | ||
|
|
954a6ca88e | ||
|
|
0c03a3ee10 | ||
|
|
53330a518f | ||
|
|
892bdebaac | ||
|
|
18121300f3 | ||
|
|
d6d4446f46 | ||
|
|
26cc924ea2 | ||
|
|
4dd866d5c4 | ||
|
|
beab4cc2c2 | ||
|
|
567a91191a | ||
|
|
434d82bbe2 | ||
|
|
2929774acb | ||
|
|
6e61a46a84 | ||
|
|
2daf4b805a | ||
|
|
7342e650c0 | ||
|
|
8c2e2ecc95 | ||
|
|
25a2b739e6 | ||
|
|
85c16926c4 | ||
|
|
2e78fdec43 | ||
|
|
1fcb920eb4 | ||
|
|
b1e89c344b | ||
|
|
befbedacdc | ||
|
|
2cc738fb17 | ||
|
|
71b20698bb | ||
|
|
3df18dcde1 | ||
|
|
a898c2ea3a | ||
|
|
bf777298c8 | ||
|
|
93fad99f7f |
+3
-1
@@ -21,8 +21,10 @@ jayair
|
|||||||
kitlangton
|
kitlangton
|
||||||
kommander
|
kommander
|
||||||
-opencode2026
|
-opencode2026
|
||||||
|
-opencodeengineer bot that spams issues
|
||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
|
-robinmordasiewicz
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-OpenCodeEngineer bot that spams issues
|
-toastythebot
|
||||||
|
|||||||
@@ -100,6 +100,9 @@ jobs:
|
|||||||
run: bun --cwd packages/app test:e2e:local
|
run: bun --cwd packages/app test:e2e:local
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
|
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||||
|
OPENCODE_E2E_MODEL: opencode/claude-haiku-4-5
|
||||||
|
OPENCODE_E2E_REQUIRE_PAID: "true"
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
- name: Upload Playwright artifacts
|
- name: Upload Playwright artifacts
|
||||||
|
|||||||
@@ -1,22 +1,19 @@
|
|||||||
---
|
---
|
||||||
model: opencode/kimi-k2.5
|
model: opencode/gpt-5.4
|
||||||
---
|
---
|
||||||
|
|
||||||
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
Create `UPCOMING_CHANGELOG.md` from the structured changelog input below.
|
||||||
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
If `UPCOMING_CHANGELOG.md` already exists, ignore its current contents completely.
|
||||||
Do not preserve, merge, or reuse text from the existing file.
|
Do not preserve, merge, or reuse text from the existing file.
|
||||||
|
|
||||||
Any command arguments are passed directly to `bun script/changelog.ts`.
|
|
||||||
Use `--from` / `-f` and `--to` / `-t` to preview a specific release range.
|
|
||||||
|
|
||||||
The input already contains the exact commit range since the last non-draft release.
|
The input already contains the exact commit range since the last non-draft release.
|
||||||
The commits are already filtered to the release-relevant packages and grouped into
|
The commits are already filtered to the release-relevant packages and grouped into
|
||||||
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
the release sections. Do not fetch GitHub releases, PRs, or build your own commit list.
|
||||||
The input may also include a `## Community Contributors Input` section.
|
The input may also include a `## Community Contributors Input` section.
|
||||||
|
|
||||||
Before writing any entry you keep, inspect the real diff with
|
Before writing any entry you keep, inspect the real diff with
|
||||||
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so the
|
`git show --stat --format='' <hash>` or `git show --format='' <hash>` so you can
|
||||||
summary reflects the actual user-facing change and not just the commit message.
|
understand the actual code changes and not just the commit message (they may be misleading).
|
||||||
Do not use `git log` or author metadata when deciding attribution.
|
Do not use `git log` or author metadata when deciding attribution.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
@@ -38,7 +35,12 @@ Rules:
|
|||||||
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
- Do not add, remove, rewrite, or reorder contributor names or commit titles in that block
|
||||||
- Do not derive the thank-you section from the main summary bullets
|
- Do not derive the thank-you section from the main summary bullets
|
||||||
- Do not include the heading `## Community Contributors Input` in the final file
|
- Do not include the heading `## Community Contributors Input` in the final file
|
||||||
|
- Focus on writing the least words to get your point across - users will skim read the changelog, so we should be precise
|
||||||
|
|
||||||
## Changelog Input
|
**Importantly, the changelog is for users (who are at least slightly technical), they may use the TUI, Desktop, SDK, Plugins and so forth. Be thorough in understanding flow on effects may not be immediately apparent. e.g. a package upgrade looks internal but may patch a bug. Or a refactor may also stabilise some race condition that fixes bugs for users. The PR title/body + commit message will give you the authors context, usually containing the outcome not just technical detail**
|
||||||
|
|
||||||
!`bun script/changelog.ts $ARGUMENTS`
|
<changelog_input>
|
||||||
|
|
||||||
|
!`bun script/raw-changelog.ts $ARGUMENTS`
|
||||||
|
|
||||||
|
</changelog_input>
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
},
|
},
|
||||||
"packages/app": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/app": {
|
"packages/console/app": {
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@cloudflare/vite-plugin": "1.15.2",
|
"@cloudflare/vite-plugin": "1.15.2",
|
||||||
"@ibm/plex": "6.4.1",
|
"@ibm/plex": "6.4.1",
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/core": {
|
"packages/console/core": {
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-sts": "3.782.0",
|
"@aws-sdk/client-sts": "3.782.0",
|
||||||
"@jsx-email/render": "1.1.1",
|
"@jsx-email/render": "1.1.1",
|
||||||
@@ -140,7 +140,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/function": {
|
"packages/console/function": {
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "3.0.64",
|
"@ai-sdk/anthropic": "3.0.64",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
@@ -164,7 +164,7 @@
|
|||||||
},
|
},
|
||||||
"packages/console/mail": {
|
"packages/console/mail": {
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -221,7 +221,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop-electron": {
|
"packages/desktop-electron": {
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -252,7 +252,7 @@
|
|||||||
},
|
},
|
||||||
"packages/enterprise": {
|
"packages/enterprise": {
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
@@ -281,7 +281,7 @@
|
|||||||
},
|
},
|
||||||
"packages/function": {
|
"packages/function": {
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@octokit/auth-app": "8.0.1",
|
"@octokit/auth-app": "8.0.1",
|
||||||
"@octokit/rest": "catalog:",
|
"@octokit/rest": "catalog:",
|
||||||
@@ -297,7 +297,7 @@
|
|||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"bin": {
|
"bin": {
|
||||||
"opencode": "./bin/opencode",
|
"opencode": "./bin/opencode",
|
||||||
},
|
},
|
||||||
@@ -338,8 +338,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||||
"@opentui/core": "0.1.92",
|
"@opentui/core": "0.1.95",
|
||||||
"@opentui/solid": "0.1.92",
|
"@opentui/solid": "0.1.95",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
@@ -423,22 +423,22 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opentui/core": "0.1.92",
|
"@opentui/core": "0.1.95",
|
||||||
"@opentui/solid": "0.1.92",
|
"@opentui/solid": "0.1.95",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"@typescript/native-preview": "catalog:",
|
"@typescript/native-preview": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.92",
|
"@opentui/core": ">=0.1.95",
|
||||||
"@opentui/solid": ">=0.1.92",
|
"@opentui/solid": ">=0.1.95",
|
||||||
},
|
},
|
||||||
"optionalPeers": [
|
"optionalPeers": [
|
||||||
"@opentui/core",
|
"@opentui/core",
|
||||||
@@ -457,7 +457,7 @@
|
|||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -468,7 +468,7 @@
|
|||||||
},
|
},
|
||||||
"packages/slack": {
|
"packages/slack": {
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@slack/bolt": "^3.17.1",
|
"@slack/bolt": "^3.17.1",
|
||||||
@@ -503,7 +503,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
@@ -550,7 +550,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -561,7 +561,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@astrojs/cloudflare": "12.6.3",
|
"@astrojs/cloudflare": "12.6.3",
|
||||||
"@astrojs/markdown-remark": "6.3.1",
|
"@astrojs/markdown-remark": "6.3.1",
|
||||||
@@ -1461,21 +1461,21 @@
|
|||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
"@opentui/core": ["@opentui/core@0.1.92", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.92", "@opentui/core-darwin-x64": "0.1.92", "@opentui/core-linux-arm64": "0.1.92", "@opentui/core-linux-x64": "0.1.92", "@opentui/core-win32-arm64": "0.1.92", "@opentui/core-win32-x64": "0.1.92", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-c+KdYAIH3M8n24RYaor+t7AQtKZ3l84L7xdP7DEaN4xtuYH8W08E6Gi+wUal4g+HSai3HS9irox68yFf0VPAxw=="],
|
"@opentui/core": ["@opentui/core@0.1.95", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "marked": "17.0.1", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.95", "@opentui/core-darwin-x64": "0.1.95", "@opentui/core-linux-arm64": "0.1.95", "@opentui/core-linux-x64": "0.1.95", "@opentui/core-win32-arm64": "0.1.95", "@opentui/core-win32-x64": "0.1.95", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-Ha73I+PPSy6Jk8CTZgdGRHU+nnmrPAs7m6w0k6ge1/kWbcNcZB0lY67sWQMdoa6bSINQMNWg7SjbNCC9B/0exg=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.92", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NX/qFRuc7My0pazyOrw9fdTXmU7omXcZzQuHcsaVnwssljaT52UYMrJ7mCKhSo69RhHw0lnGCymTorvz3XBdsA=="],
|
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.95", "", { "os": "darwin", "cpu": "arm64" }, "sha512-92joqr0ucGaIBCl9uYhe5DwAPbgGMTaCsCeY8Yf3VQ72wjGbOTwnC1TvU5wC6bUmiyqfijCqMyuUnj83teIVVQ=="],
|
||||||
|
|
||||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.92", "", { "os": "darwin", "cpu": "x64" }, "sha512-Zb4jn33hOf167llINKLniOabQIycs14LPOBZnQ6l4khbeeTPVJdG8gy9PhlAyIQygDKmRTFncVlP0RP+L6C7og=="],
|
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.95", "", { "os": "darwin", "cpu": "x64" }, "sha512-+TLL3Kp3x7DTWEAkCAYe+RjRhl58QndoeXMstZNS8GQyrjSpUuivzwidzAz0HZK9SbZJfvaxZmXsToAIdI2fag=="],
|
||||||
|
|
||||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.92", "", { "os": "linux", "cpu": "arm64" }, "sha512-4VA1A91OTMPJ3LkAyaxKEZVJsk5jIc3Kz0gV2vip8p2aGLPpYHHpkFZpXP/FyzsnJzoSGftBeA6ya1GKa5bkXg=="],
|
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.95", "", { "os": "linux", "cpu": "arm64" }, "sha512-dAYeRqh7P8o0xFZleDDR1Abt4gSvCISqw6syOrbH3dl7pMbVdGgzA5stM9jqMgdPUVE7Ngumo17C23ehkGv93A=="],
|
||||||
|
|
||||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.92", "", { "os": "linux", "cpu": "x64" }, "sha512-tr7va8hfKS1uY+TBmulQBoBlwijzJk56K/U/L9/tbHfW7oJctqxPVwEFHIh1HDcOQ3/UhMMWGvMfeG6cFiK8/A=="],
|
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.95", "", { "os": "linux", "cpu": "x64" }, "sha512-O54TCgK8E7j2NKrDXUOTZqO4sb8JjeAfnhrStxAMMEw4RFCGWx3p3wLesqR16uKfFFJFDyoh2OWZ698tO88EAA=="],
|
||||||
|
|
||||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.92", "", { "os": "win32", "cpu": "arm64" }, "sha512-34YM3uPtDjzUVeSnJWIK2J8mxyduzV7f3mYc4Hub0glNpUdM1jjzF2HvvvnrKK5ElzTsIcno3c3lOYT8yvG1Zg=="],
|
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.95", "", { "os": "win32", "cpu": "arm64" }, "sha512-T1RlZ6U/95eYDN6rUm4SLOVA5LBR7iL3TcBroQhV/883bVczXIBPhriEXQayup5FsAemnQba1BzMNvy6128SUw=="],
|
||||||
|
|
||||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.92", "", { "os": "win32", "cpu": "x64" }, "sha512-uk442kA2Vn0mmJHHqk5sPM+Zai/AN9sgl7egekhoEOUx2VK3gxftKsVlx2YVpCHTvTE/S+vnD2WpQaJk2SNjww=="],
|
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.95", "", { "os": "win32", "cpu": "x64" }, "sha512-lH2FHO0HSP2xWT+ccoz0BkLYFsMm7e6OYOh63BUHHh5b7ispnzP4aTyxiaLWrfJwdL0M9rp5cLIY32bhBKF2oA=="],
|
||||||
|
|
||||||
"@opentui/solid": ["@opentui/solid@0.1.92", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.92", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-0Sx1+6zRpmMJ5oDEY0JS9b9+eGd/Q0fPndNllrQNnp7w2FCjpXmvHdBdq+pFI6kFp01MHq2ZOkUU5zX5/9YMSQ=="],
|
"@opentui/solid": ["@opentui/solid@0.1.95", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.95", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.10", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.11" } }, "sha512-iotYCvULgDurLXv3vgOzTLnEOySHFOa/6cEDex76jBt+gkniOEh2cjxxIVt6lkfTsk6UNTk6yCdwNK3nca/j+Q=="],
|
||||||
|
|
||||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||||
|
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-5w+DwEvUrCly9LHZuTa1yTSD45X56cGJG8sds/N29mU=",
|
"x86_64-linux": "sha256-g6zIq2w5nM+UPMsOzqB5EbCi4a9xkarin27WwTHqzHo=",
|
||||||
"aarch64-linux": "sha256-pLhyzajYinBlFyGWwPypyC8gHEU8S7fVXIs6aqgBmhg=",
|
"aarch64-linux": "sha256-ivRoB8jrPtMGaphkIQuZ4G5E83/iPjoMGQGgxIp6p8Q=",
|
||||||
"aarch64-darwin": "sha256-vN0sXYs7pLtpq7U9SorR2z6st/wMfHA3dybOnwIh1pU=",
|
"aarch64-darwin": "sha256-0oW0PQpcXsNOGA868BjGRAAJWKcZJ8GVVkQueJfz6ng=",
|
||||||
"x86_64-darwin": "sha256-P8fgyBcZJmY5VbNxNer/EL4r/F28dNxaqheaqNZH488="
|
"x86_64-darwin": "sha256-7Jm198RsxBC9yewcTlV6HXLtT3+GRFThi+5vL/R8d18="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ import { createSdk, dirSlug, getWorktree, sessionPath } from "./utils"
|
|||||||
|
|
||||||
export const settingsKey = "settings.v3"
|
export const settingsKey = "settings.v3"
|
||||||
|
|
||||||
|
const seedModel = (() => {
|
||||||
|
const [providerID = "opencode", modelID = "big-pickle"] = (
|
||||||
|
process.env.OPENCODE_E2E_MODEL ?? "opencode/big-pickle"
|
||||||
|
).split("/")
|
||||||
|
return {
|
||||||
|
providerID: providerID || "opencode",
|
||||||
|
modelID: modelID || "big-pickle",
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
type TestFixtures = {
|
type TestFixtures = {
|
||||||
sdk: ReturnType<typeof createSdk>
|
sdk: ReturnType<typeof createSdk>
|
||||||
gotoSession: (sessionID?: string) => Promise<void>
|
gotoSession: (sessionID?: string) => Promise<void>
|
||||||
@@ -125,7 +135,7 @@ export const test = base.extend<TestFixtures, WorkerFixtures>({
|
|||||||
|
|
||||||
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
async function seedStorage(page: Page, input: { directory: string; extra?: string[] }) {
|
||||||
await seedProjects(page, input)
|
await seedProjects(page, input)
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript((model: { providerID: string; modelID: string }) => {
|
||||||
const win = window as E2EWindow
|
const win = window as E2EWindow
|
||||||
win.__opencode_e2e = {
|
win.__opencode_e2e = {
|
||||||
...win.__opencode_e2e,
|
...win.__opencode_e2e,
|
||||||
@@ -143,12 +153,12 @@ async function seedStorage(page: Page, input: { directory: string; extra?: strin
|
|||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"opencode.global.dat:model",
|
"opencode.global.dat:model",
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
recent: [{ providerID: "opencode", modelID: "big-pickle" }],
|
recent: [model],
|
||||||
user: [],
|
user: [],
|
||||||
variant: {},
|
variant: {},
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
})
|
}, seedModel)
|
||||||
}
|
}
|
||||||
|
|
||||||
export { expect }
|
export { expect }
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ async function fileOverflow(page: Parameters<typeof test>[0]["page"]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
test("review applies inline comment clicks without horizontal overflow", async ({ page, withProject }) => {
|
||||||
|
test.skip(true, "Flaky in CI for now.")
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-comment-${Date.now()}`
|
const tag = `review-comment-${Date.now()}`
|
||||||
@@ -283,6 +284,7 @@ test("review applies inline comment clicks without horizontal overflow", async (
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
test("review file comments submit on click without clipping actions", async ({ page, withProject }) => {
|
||||||
|
test.skip(true, "Flaky in CI for now.")
|
||||||
test.setTimeout(180_000)
|
test.setTimeout(180_000)
|
||||||
|
|
||||||
const tag = `review-file-comment-${Date.now()}`
|
const tag = `review-file-comment-${Date.now()}`
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const serverEnv = {
|
|||||||
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
OPENCODE_E2E_PROJECT_DIR: repoDir,
|
||||||
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
OPENCODE_E2E_SESSION_TITLE: "E2E Session",
|
||||||
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
OPENCODE_E2E_MESSAGE: "Seeded for UI e2e",
|
||||||
OPENCODE_E2E_MODEL: "opencode/gpt-5-nano",
|
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano",
|
||||||
OPENCODE_CLIENT: "app",
|
OPENCODE_CLIENT: "app",
|
||||||
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
OPENCODE_STRICT_CONFIG_DEPS: "true",
|
||||||
} satisfies Record<string, string>
|
} satisfies Record<string, string>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-app",
|
"name": "@opencode-ai/console-app",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/console-core",
|
"name": "@opencode-ai/console-core",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ if (!stage) throw new Error("Stage is required")
|
|||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
|
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
const lines = ret.split("\n")
|
const lines = ret.split("\n")
|
||||||
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
const value = lines.find((line) => line.startsWith("ZEN_LIMITS"))?.split("=")[1]
|
||||||
if (!value) throw new Error("ZEN_LIMITS not found")
|
if (!value) throw new Error("ZEN_LIMITS not found")
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const root = path.resolve(process.cwd(), "..", "..", "..")
|
|||||||
const PARTS = 30
|
const PARTS = 30
|
||||||
|
|
||||||
// read the secret
|
// read the secret
|
||||||
const ret = await $`bun sst secret list`.cwd(root).text()
|
const ret = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
const lines = ret.split("\n")
|
const lines = ret.split("\n")
|
||||||
const values = Array.from({ length: PARTS }, (_, i) => {
|
const values = Array.from({ length: PARTS }, (_, i) => {
|
||||||
const value = lines
|
const value = lines
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os from "os"
|
|||||||
import { Subscription } from "../src/subscription"
|
import { Subscription } from "../src/subscription"
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
const secrets = await $`bun sst secret list`.cwd(root).text()
|
const secrets = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
|
|
||||||
// read value
|
// read value
|
||||||
const lines = secrets.split("\n")
|
const lines = secrets.split("\n")
|
||||||
@@ -25,4 +25,4 @@ const newValue = JSON.stringify(JSON.parse(await tempFile.text()))
|
|||||||
Subscription.validate(JSON.parse(newValue))
|
Subscription.validate(JSON.parse(newValue))
|
||||||
|
|
||||||
// update the secret
|
// update the secret
|
||||||
await $`bun sst secret set ZEN_LIMITS ${newValue}`
|
await $`bun sst secret set ZEN_LIMITS ${newValue} --stage frank`.cwd(root)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import os from "os"
|
|||||||
import { ZenData } from "../src/model"
|
import { ZenData } from "../src/model"
|
||||||
|
|
||||||
const root = path.resolve(process.cwd(), "..", "..", "..")
|
const root = path.resolve(process.cwd(), "..", "..", "..")
|
||||||
const models = await $`bun sst secret list`.cwd(root).text()
|
const models = await $`bun sst secret list --stage frank`.cwd(root).text()
|
||||||
const PARTS = 30
|
const PARTS = 30
|
||||||
|
|
||||||
// read the line starting with "ZEN_MODELS"
|
// read the line starting with "ZEN_MODELS"
|
||||||
@@ -40,4 +40,4 @@ const newValues = Array.from({ length: PARTS }, (_, i) =>
|
|||||||
|
|
||||||
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
const envFile = Bun.file(path.join(os.tmpdir(), `models-${Date.now()}.env`))
|
||||||
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
await envFile.write(newValues.map((v, i) => `ZEN_MODELS${i + 1}="${v.replace(/"/g, '\\"')}"`).join("\n"))
|
||||||
await $`bun sst secret load ${envFile.name}`.cwd(root)
|
await $`bun sst secret load ${envFile.name} --stage frank`.cwd(root)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-function",
|
"name": "@opencode-ai/console-function",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/console-mail",
|
"name": "@opencode-ai/console-mail",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@jsx-email/all": "2.2.3",
|
"@jsx-email/all": "2.2.3",
|
||||||
"@jsx-email/cli": "1.4.3",
|
"@jsx-email/cli": "1.4.3",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop-electron",
|
"name": "@opencode-ai/desktop-electron",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://opencode.ai",
|
"homepage": "https://opencode.ai",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/enterprise",
|
"name": "@opencode-ai/enterprise",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "opencode"
|
id = "opencode"
|
||||||
name = "OpenCode"
|
name = "OpenCode"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.3.9"
|
version = "1.3.13"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/anomalyco/opencode"
|
repository = "https://github.com/anomalyco/opencode"
|
||||||
@@ -11,26 +11,26 @@ name = "OpenCode"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[agent_servers.opencode.targets.darwin-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-darwin-arm64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-arm64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[agent_servers.opencode.targets.darwin-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-darwin-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-darwin-x64.zip"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[agent_servers.opencode.targets.linux-aarch64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-linux-arm64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-arm64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[agent_servers.opencode.targets.linux-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-linux-x64.tar.gz"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-linux-x64.tar.gz"
|
||||||
cmd = "./opencode"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[agent_servers.opencode.targets.windows-x86_64]
|
||||||
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.9/opencode-windows-x64.zip"
|
archive = "https://github.com/anomalyco/opencode/releases/download/v1.3.13/opencode-windows-x64.zip"
|
||||||
cmd = "./opencode.exe"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/function",
|
"name": "@opencode-ai/function",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
@@ -102,8 +102,8 @@
|
|||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.3.3",
|
"@openrouter/ai-sdk-provider": "2.3.3",
|
||||||
"@opentui/core": "0.1.92",
|
"@opentui/core": "0.1.95",
|
||||||
"@opentui/solid": "0.1.92",
|
"@opentui/solid": "0.1.95",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
"@pierre/diffs": "catalog:",
|
"@pierre/diffs": "catalog:",
|
||||||
"@solid-primitives/event-bus": "1.1.2",
|
"@solid-primitives/event-bus": "1.1.2",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ const dir = process.env.OPENCODE_E2E_PROJECT_DIR ?? process.cwd()
|
|||||||
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
const title = process.env.OPENCODE_E2E_SESSION_TITLE ?? "E2E Session"
|
||||||
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
|
const text = process.env.OPENCODE_E2E_MESSAGE ?? "Seeded for UI e2e"
|
||||||
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
|
const model = process.env.OPENCODE_E2E_MODEL ?? "opencode/gpt-5-nano"
|
||||||
|
const requirePaid = process.env.OPENCODE_E2E_REQUIRE_PAID === "true"
|
||||||
const parts = model.split("/")
|
const parts = model.split("/")
|
||||||
const providerID = parts[0] ?? "opencode"
|
const providerID = parts[0] ?? "opencode"
|
||||||
const modelID = parts[1] ?? "gpt-5-nano"
|
const modelID = parts[1] ?? "gpt-5-nano"
|
||||||
@@ -11,6 +12,7 @@ const seed = async () => {
|
|||||||
const { Instance } = await import("../src/project/instance")
|
const { Instance } = await import("../src/project/instance")
|
||||||
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
const { InstanceBootstrap } = await import("../src/project/bootstrap")
|
||||||
const { Config } = await import("../src/config/config")
|
const { Config } = await import("../src/config/config")
|
||||||
|
const { Provider } = await import("../src/provider/provider")
|
||||||
const { Session } = await import("../src/session")
|
const { Session } = await import("../src/session")
|
||||||
const { MessageID, PartID } = await import("../src/session/schema")
|
const { MessageID, PartID } = await import("../src/session/schema")
|
||||||
const { Project } = await import("../src/project/project")
|
const { Project } = await import("../src/project/project")
|
||||||
@@ -25,6 +27,19 @@ const seed = async () => {
|
|||||||
await Config.waitForDependencies()
|
await Config.waitForDependencies()
|
||||||
await ToolRegistry.ids()
|
await ToolRegistry.ids()
|
||||||
|
|
||||||
|
if (requirePaid && providerID === "opencode" && !process.env.OPENCODE_API_KEY) {
|
||||||
|
throw new Error("OPENCODE_API_KEY is required when OPENCODE_E2E_REQUIRE_PAID=true")
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await Provider.getModel(ProviderID.make(providerID), ModelID.make(modelID))
|
||||||
|
if (requirePaid) {
|
||||||
|
const paid =
|
||||||
|
info.cost.input > 0 || info.cost.output > 0 || info.cost.cache.read > 0 || info.cost.cache.write > 0
|
||||||
|
if (!paid) {
|
||||||
|
throw new Error(`OPENCODE_E2E_MODEL must resolve to a paid model: ${providerID}/${modelID}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const session = await Session.create({ title })
|
const session = await Session.create({ title })
|
||||||
const messageID = MessageID.ascending()
|
const messageID = MessageID.ascending()
|
||||||
const partID = PartID.ascending()
|
const partID = PartID.ascending()
|
||||||
|
|||||||
@@ -210,15 +210,13 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||||||
- [x] `Vcs` — `project/vcs.ts`
|
- [x] `Vcs` — `project/vcs.ts`
|
||||||
- [x] `Worktree` — `worktree/index.ts`
|
- [x] `Worktree` — `worktree/index.ts`
|
||||||
|
|
||||||
Still open and likely worth migrating:
|
|
||||||
|
|
||||||
- [x] `Session` — `session/index.ts`
|
- [x] `Session` — `session/index.ts`
|
||||||
- [ ] `SessionProcessor` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionProcessor` — `session/processor.ts`
|
||||||
- [ ] `SessionPrompt` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionPrompt` — `session/prompt.ts`
|
||||||
- [ ] `SessionCompaction` — blocked by AI SDK v6 PR (#18433)
|
- [x] `SessionCompaction` — `session/compaction.ts`
|
||||||
- [ ] `Provider` — blocked by AI SDK v6 PR (#18433)
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
|
|
||||||
Other services not yet migrated:
|
Still open:
|
||||||
|
|
||||||
- [ ] `SessionSummary` — `session/summary.ts`
|
- [ ] `SessionSummary` — `session/summary.ts`
|
||||||
- [ ] `SessionTodo` — `session/todo.ts`
|
- [ ] `SessionTodo` — `session/todo.ts`
|
||||||
@@ -235,7 +233,7 @@ Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `i
|
|||||||
|
|
||||||
1. Migrate each tool to return Effects
|
1. Migrate each tool to return Effects
|
||||||
2. Update `Tool.define()` factory to work with Effects
|
2. Update `Tool.define()` factory to work with Effects
|
||||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing — blocked by AI SDK v6 PR (#18433)
|
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
||||||
|
|
||||||
Individual tools, ordered by value:
|
Individual tools, ordered by value:
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ export default plugin
|
|||||||
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
|
- If package `exports` exists, loader only resolves `./tui` or `./server`; it never falls back to `exports["."]`.
|
||||||
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
|
- For npm package specs, TUI does not use `package.json` `main` as a fallback entry.
|
||||||
- `package.json` `main` is only used for server plugin entrypoint resolution.
|
- `package.json` `main` is only used for server plugin entrypoint resolution.
|
||||||
|
- If a configured plugin has no target-specific entrypoint, it is skipped with a warning (not a load failure).
|
||||||
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
- If a package supports both server and TUI, use separate files and package `exports` (`./server` and `./tui`) so each target resolves to a target-only module.
|
||||||
- File/path plugins must export a non-empty `id`.
|
- File/path plugins must export a non-empty `id`.
|
||||||
- npm plugins may omit `id`; package `name` is used.
|
- npm plugins may omit `id`; package `name` is used.
|
||||||
@@ -100,7 +101,10 @@ export default plugin
|
|||||||
|
|
||||||
## Package manifest and install
|
## Package manifest and install
|
||||||
|
|
||||||
Package manifest is read from `package.json` field `oc-plugin`.
|
Install target detection is inferred from `package.json` entrypoints:
|
||||||
|
|
||||||
|
- `server` target when `exports["./server"]` exists or `main` is set.
|
||||||
|
- `tui` target when `exports["./tui"]` exists.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|
||||||
@@ -108,14 +112,20 @@ Example:
|
|||||||
{
|
{
|
||||||
"name": "@acme/opencode-plugin",
|
"name": "@acme/opencode-plugin",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/server.js",
|
||||||
|
"exports": {
|
||||||
|
"./server": {
|
||||||
|
"import": "./dist/server.js",
|
||||||
|
"config": { "custom": true }
|
||||||
|
},
|
||||||
|
"./tui": {
|
||||||
|
"import": "./dist/tui.js",
|
||||||
|
"config": { "compact": true }
|
||||||
|
}
|
||||||
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"opencode": "^1.0.0"
|
"opencode": "^1.0.0"
|
||||||
},
|
}
|
||||||
"oc-plugin": [
|
|
||||||
["server", { "custom": true }],
|
|
||||||
["tui", { "compact": true }]
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -144,12 +154,16 @@ npm plugins can declare a version compatibility range in `package.json` using th
|
|||||||
- Local installs resolve target dir inside `patchPluginConfig`.
|
- Local installs resolve target dir inside `patchPluginConfig`.
|
||||||
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
- For local scope, path is `<worktree>/.opencode` only when VCS is git and `worktree !== "/"`; otherwise `<directory>/.opencode`.
|
||||||
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
- Root-worktree fallback (`worktree === "/"` uses `<directory>/.opencode`) is covered by regression tests.
|
||||||
- `patchPluginConfig` applies all declared manifest targets (`server` and/or `tui`) in one call.
|
- `patchPluginConfig` applies all detected targets (`server` and/or `tui`) in one call.
|
||||||
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
- `patchPluginConfig` returns structured result unions (`ok`, `code`, fields by error kind) instead of custom thrown errors.
|
||||||
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
- `patchPluginConfig` serializes per-target config writes with `Flock.acquire(...)`.
|
||||||
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
- `patchPluginConfig` uses targeted `jsonc-parser` edits, so existing JSONC comments are preserved when plugin entries are added or replaced.
|
||||||
|
- npm plugin package installs are executed with `--ignore-scripts`, so package `install` / `postinstall` lifecycle scripts are not run.
|
||||||
|
- `exports["./server"].config` and `exports["./tui"].config` can provide default plugin options written on first install.
|
||||||
- Without `--force`, an already-configured npm package name is a no-op.
|
- Without `--force`, an already-configured npm package name is a no-op.
|
||||||
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
- With `--force`, replacement matches by package name. If the existing row is `[spec, options]`, those tuple options are kept.
|
||||||
|
- Explicit npm specs with a version suffix (for example `pkg@1.2.3`) are pinned. Runtime install requests that exact version and does not run stale/latest checks for newer registry versions.
|
||||||
|
- Bare npm specs (`pkg`) are treated as `latest` and can refresh when the cached version is stale.
|
||||||
- Tuple targets in `oc-plugin` provide default options written into config.
|
- Tuple targets in `oc-plugin` provide default options written into config.
|
||||||
- A package can target `server`, `tui`, or both.
|
- A package can target `server`, `tui`, or both.
|
||||||
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
|
- If a package targets both, each target must still resolve to a separate target-only module. Do not export `{ server, tui }` from one module.
|
||||||
@@ -317,7 +331,6 @@ Slot notes:
|
|||||||
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
- `api.plugins.install(spec, { global? })` runs install -> manifest read -> config patch using the same helper flow as CLI install.
|
||||||
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
- `api.plugins.install(...)` returns either `{ ok: false, message, missing? }` or `{ ok: true, dir, tui }`.
|
||||||
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
- `api.plugins.install(...)` does not load plugins into the current session. Call `api.plugins.add(spec)` to load after install.
|
||||||
- For packages that declare a tuple `tui` target in `oc-plugin`, `api.plugins.install(...)` stages those tuple options so a following `api.plugins.add(spec)` uses them.
|
|
||||||
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
- If activation fails, the plugin can remain `enabled=true` and `active=false`.
|
||||||
- `api.lifecycle.signal` is aborted before cleanup runs.
|
- `api.lifecycle.signal` is aborted before cleanup runs.
|
||||||
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
- `api.lifecycle.onDispose(fn)` registers cleanup and returns an unregister function.
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export namespace BunProc {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function install(pkg: string, version = "latest") {
|
export async function install(pkg: string, version = "latest", opts?: { ignoreScripts?: boolean }) {
|
||||||
// Use lock to ensure only one install at a time
|
// Use lock to ensure only one install at a time
|
||||||
using _ = await Lock.write("bun-install")
|
using _ = await Lock.write("bun-install")
|
||||||
|
|
||||||
@@ -82,6 +82,7 @@ export namespace BunProc {
|
|||||||
"add",
|
"add",
|
||||||
"--force",
|
"--force",
|
||||||
"--exact",
|
"--exact",
|
||||||
|
...(opts?.ignoreScripts ? ["--ignore-scripts"] : []),
|
||||||
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
// TODO: get rid of this case (see: https://github.com/oven-sh/bun/issues/19936)
|
||||||
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
...(proxied() || process.env.CI ? ["--no-cache"] : []),
|
||||||
"--cwd",
|
"--cwd",
|
||||||
|
|||||||
@@ -90,8 +90,9 @@ export namespace Bus {
|
|||||||
if (ps) yield* PubSub.publish(ps, payload)
|
if (ps) yield* PubSub.publish(ps, payload)
|
||||||
yield* PubSub.publish(state.wildcard, payload)
|
yield* PubSub.publish(state.wildcard, payload)
|
||||||
|
|
||||||
|
const dir = yield* InstanceState.directory
|
||||||
GlobalBus.emit("event", {
|
GlobalBus.emit("event", {
|
||||||
directory: Instance.directory,
|
directory: dir,
|
||||||
payload,
|
payload,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -114,8 +114,8 @@ export function createPlugTask(input: PlugInput, dep: PlugDeps = defaultPlugDeps
|
|||||||
|
|
||||||
if (manifest.code === "manifest_no_targets") {
|
if (manifest.code === "manifest_no_targets") {
|
||||||
inspect.stop("No plugin targets found", 1)
|
inspect.stop("No plugin targets found", 1)
|
||||||
dep.log.error(`"${mod}" does not declare supported targets in package.json`)
|
dep.log.error(`"${mod}" does not expose plugin entrypoints in package.json`)
|
||||||
dep.log.info('Expected: "oc-plugin": ["server", "tui"] or tuples like [["tui", { ... }]].')
|
dep.log.info('Expected one of: exports["./tui"], exports["./server"], or package.json main for server.')
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ import { DialogVariant } from "./component/dialog-variant"
|
|||||||
|
|
||||||
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
function rendererConfig(_config: TuiConfig.Info): CliRendererConfig {
|
||||||
return {
|
return {
|
||||||
|
externalOutputMode: "passthrough",
|
||||||
targetFps: 60,
|
targetFps: 60,
|
||||||
gatherStats: false,
|
gatherStats: false,
|
||||||
exitOnCtrlC: false,
|
exitOnCtrlC: false,
|
||||||
@@ -250,7 +251,6 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const dimensions = useTerminalDimensions()
|
const dimensions = useTerminalDimensions()
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
renderer.disableStdoutInterception()
|
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const local = useLocal()
|
const local = useLocal()
|
||||||
const kv = useKV()
|
const kv = useKV()
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
"light": "frappeText"
|
"light": "frappeText"
|
||||||
},
|
},
|
||||||
"textMuted": {
|
"textMuted": {
|
||||||
"dark": "frappeSubtext1",
|
"dark": "frappeOverlay2",
|
||||||
"light": "frappeSubtext1"
|
"light": "frappeOverlay2"
|
||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"dark": "frappeBase",
|
"dark": "frappeBase",
|
||||||
|
|||||||
@@ -62,8 +62,8 @@
|
|||||||
"light": "macText"
|
"light": "macText"
|
||||||
},
|
},
|
||||||
"textMuted": {
|
"textMuted": {
|
||||||
"dark": "macSubtext1",
|
"dark": "macOverlay2",
|
||||||
"light": "macSubtext1"
|
"light": "macOverlay2"
|
||||||
},
|
},
|
||||||
"background": {
|
"background": {
|
||||||
"dark": "macBase",
|
"dark": "macBase",
|
||||||
|
|||||||
@@ -63,7 +63,7 @@
|
|||||||
"success": { "dark": "darkGreen", "light": "lightGreen" },
|
"success": { "dark": "darkGreen", "light": "lightGreen" },
|
||||||
"info": { "dark": "darkTeal", "light": "lightTeal" },
|
"info": { "dark": "darkTeal", "light": "lightTeal" },
|
||||||
"text": { "dark": "darkText", "light": "lightText" },
|
"text": { "dark": "darkText", "light": "lightText" },
|
||||||
"textMuted": { "dark": "darkSubtext1", "light": "lightSubtext1" },
|
"textMuted": { "dark": "darkOverlay2", "light": "lightOverlay2" },
|
||||||
"background": { "dark": "darkBase", "light": "lightBase" },
|
"background": { "dark": "darkBase", "light": "lightBase" },
|
||||||
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
|
"backgroundPanel": { "dark": "darkMantle", "light": "lightMantle" },
|
||||||
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },
|
"backgroundElement": { "dark": "darkCrust", "light": "lightCrust" },
|
||||||
|
|||||||
@@ -87,6 +87,11 @@ function fail(message: string, data: Record<string, unknown>) {
|
|||||||
console.error(`[tui.plugin] ${text}`, next)
|
console.error(`[tui.plugin] ${text}`, next)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function warn(message: string, data: Record<string, unknown>) {
|
||||||
|
log.warn(message, data)
|
||||||
|
console.warn(`[tui.plugin] ${message}`, data)
|
||||||
|
}
|
||||||
|
|
||||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||||
|
|
||||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||||
@@ -229,6 +234,15 @@ async function loadExternalPlugin(cfg: TuiConfig.PluginRecord, retry = false): P
|
|||||||
log.info("loading tui plugin", { path: plan.spec, retry })
|
log.info("loading tui plugin", { path: plan.spec, retry })
|
||||||
const resolved = await PluginLoader.resolve(plan, "tui")
|
const resolved = await PluginLoader.resolve(plan, "tui")
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
|
if (resolved.stage === "missing") {
|
||||||
|
warn("tui plugin has no entrypoint", {
|
||||||
|
path: plan.spec,
|
||||||
|
retry,
|
||||||
|
message: resolved.message,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (resolved.stage === "install") {
|
if (resolved.stage === "install") {
|
||||||
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
|
fail("failed to resolve tui plugin", { path: plan.spec, retry, error: resolved.error })
|
||||||
return
|
return
|
||||||
@@ -753,7 +767,6 @@ async function addPluginBySpec(state: RuntimeState | undefined, raw: string) {
|
|||||||
return [] as PluginLoad[]
|
return [] as PluginLoad[]
|
||||||
})
|
})
|
||||||
if (!ready.length) {
|
if (!ready.length) {
|
||||||
fail("failed to add tui plugin", { path: next })
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,7 +837,7 @@ async function installPluginBySpec(
|
|||||||
if (manifest.code === "manifest_no_targets") {
|
if (manifest.code === "manifest_no_targets") {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: `"${spec}" does not declare supported targets in package.json`,
|
message: `"${spec}" does not expose plugin entrypoints in package.json`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -121,7 +121,10 @@ export namespace Config {
|
|||||||
const gitignore = path.join(dir, ".gitignore")
|
const gitignore = path.join(dir, ".gitignore")
|
||||||
const ignore = await Filesystem.exists(gitignore)
|
const ignore = await Filesystem.exists(gitignore)
|
||||||
if (!ignore) {
|
if (!ignore) {
|
||||||
await Filesystem.write(gitignore, ["node_modules", "package.json", "bun.lock", ".gitignore"].join("\n"))
|
await Filesystem.write(
|
||||||
|
gitignore,
|
||||||
|
["node_modules", "package.json", "package-lock.json", "bun.lock", ".gitignore"].join("\n"),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
// Bun can race cache writes on Windows when installs run in parallel across dirs.
|
||||||
@@ -1483,7 +1486,8 @@ export namespace Config {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const update = Effect.fn("Config.update")(function* (config: Info) {
|
const update = Effect.fn("Config.update")(function* (config: Info) {
|
||||||
const file = path.join(Instance.directory, "config.json")
|
const dir = yield* InstanceState.directory
|
||||||
|
const file = path.join(dir, "config.json")
|
||||||
const existing = yield* loadFile(file)
|
const existing = yield* loadFile(file)
|
||||||
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
yield* fs.writeFileString(file, JSON.stringify(mergeDeep(existing, config), null, 2)).pipe(Effect.orDie)
|
||||||
yield* Effect.promise(() => Instance.dispose())
|
yield* Effect.promise(() => Instance.dispose())
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { ServiceMap } from "effect"
|
||||||
|
import type { InstanceContext } from "@/project/instance"
|
||||||
|
|
||||||
|
export const InstanceRef = ServiceMap.Reference<InstanceContext | undefined>("~opencode/InstanceRef", {
|
||||||
|
defaultValue: () => undefined,
|
||||||
|
})
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Effect, ScopedCache, Scope } from "effect"
|
import { Effect, Fiber, ScopedCache, Scope, ServiceMap } from "effect"
|
||||||
import { Instance, type InstanceContext } from "@/project/instance"
|
import { Instance, type InstanceContext } from "@/project/instance"
|
||||||
|
import { Context } from "@/util/context"
|
||||||
|
import { InstanceRef } from "./instance-ref"
|
||||||
import { registerDisposer } from "./instance-registry"
|
import { registerDisposer } from "./instance-registry"
|
||||||
|
|
||||||
const TypeId = "~opencode/InstanceState"
|
const TypeId = "~opencode/InstanceState"
|
||||||
@@ -10,13 +12,34 @@ export interface InstanceState<A, E = never, R = never> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace InstanceState {
|
export namespace InstanceState {
|
||||||
|
export const bind = <F extends (...args: any[]) => any>(fn: F): F => {
|
||||||
|
try {
|
||||||
|
return Instance.bind(fn)
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof Context.NotFound)) throw err
|
||||||
|
}
|
||||||
|
const fiber = Fiber.getCurrent()
|
||||||
|
const ctx = fiber ? ServiceMap.getReferenceUnsafe(fiber.services, InstanceRef) : undefined
|
||||||
|
if (!ctx) return fn
|
||||||
|
return ((...args: any[]) => Instance.restore(ctx, () => fn(...args))) as F
|
||||||
|
}
|
||||||
|
|
||||||
|
export const context = Effect.fnUntraced(function* () {
|
||||||
|
return (yield* InstanceRef) ?? Instance.current
|
||||||
|
})()
|
||||||
|
|
||||||
|
export const directory = Effect.map(context, (ctx) => ctx.directory)
|
||||||
|
|
||||||
export const make = <A, E = never, R = never>(
|
export const make = <A, E = never, R = never>(
|
||||||
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
|
init: (ctx: InstanceContext) => Effect.Effect<A, E, R | Scope.Scope>,
|
||||||
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
): Effect.Effect<InstanceState<A, E, Exclude<R, Scope.Scope>>, never, R | Scope.Scope> =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const cache = yield* ScopedCache.make<string, A, E, R>({
|
const cache = yield* ScopedCache.make<string, A, E, R>({
|
||||||
capacity: Number.POSITIVE_INFINITY,
|
capacity: Number.POSITIVE_INFINITY,
|
||||||
lookup: () => init(Instance.current),
|
lookup: () =>
|
||||||
|
Effect.fnUntraced(function* () {
|
||||||
|
return yield* init(yield* context)
|
||||||
|
})(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
const off = registerDisposer((directory) => Effect.runPromise(ScopedCache.invalidate(cache, directory)))
|
||||||
@@ -29,7 +52,9 @@ export namespace InstanceState {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
export const get = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
Effect.suspend(() => ScopedCache.get(self.cache, Instance.directory))
|
Effect.gen(function* () {
|
||||||
|
return yield* ScopedCache.get(self.cache, yield* directory)
|
||||||
|
})
|
||||||
|
|
||||||
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
export const use = <A, E, R, B>(self: InstanceState<A, E, R>, select: (value: A) => B) =>
|
||||||
Effect.map(get(self), select)
|
Effect.map(get(self), select)
|
||||||
@@ -40,8 +65,18 @@ export namespace InstanceState {
|
|||||||
) => Effect.flatMap(get(self), select)
|
) => Effect.flatMap(get(self), select)
|
||||||
|
|
||||||
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
export const has = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
Effect.suspend(() => ScopedCache.has(self.cache, Instance.directory))
|
Effect.gen(function* () {
|
||||||
|
return yield* ScopedCache.has(self.cache, yield* directory)
|
||||||
|
})
|
||||||
|
|
||||||
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
export const invalidate = <A, E, R>(self: InstanceState<A, E, R>) =>
|
||||||
Effect.suspend(() => ScopedCache.invalidate(self.cache, Instance.directory))
|
Effect.gen(function* () {
|
||||||
|
return yield* ScopedCache.invalidate(self.cache, yield* directory)
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect finalizers run on the fiber scheduler after the original async
|
||||||
|
* boundary, so ALS reads like Instance.directory can be gone by then.
|
||||||
|
*/
|
||||||
|
export const withALS = <T>(fn: () => T) => Effect.map(context, (ctx) => Instance.restore(ctx, fn))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import * as ServiceMap from "effect/ServiceMap"
|
import * as ServiceMap from "effect/ServiceMap"
|
||||||
|
import { Instance } from "@/project/instance"
|
||||||
|
import { Context } from "@/util/context"
|
||||||
|
import { InstanceRef } from "./instance-ref"
|
||||||
|
|
||||||
export const memoMap = Layer.makeMemoMapUnsafe()
|
export const memoMap = Layer.makeMemoMapUnsafe()
|
||||||
|
|
||||||
|
function attach<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R> {
|
||||||
|
try {
|
||||||
|
const ctx = Instance.current
|
||||||
|
return Effect.provideService(effect, InstanceRef, ctx)
|
||||||
|
} catch (err) {
|
||||||
|
if (!(err instanceof Context.NotFound)) throw err
|
||||||
|
}
|
||||||
|
return effect
|
||||||
|
}
|
||||||
|
|
||||||
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
export function makeRuntime<I, S, E>(service: ServiceMap.Service<I, S>, layer: Layer.Layer<I, E>) {
|
||||||
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
let rt: ManagedRuntime.ManagedRuntime<I, E> | undefined
|
||||||
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
const getRuntime = () => (rt ??= ManagedRuntime.make(layer, { memoMap }))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(service.use(fn)),
|
runSync: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runSync(attach(service.use(fn))),
|
||||||
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
runPromiseExit: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
getRuntime().runPromiseExit(service.use(fn), options),
|
getRuntime().runPromiseExit(attach(service.use(fn)), options),
|
||||||
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
runPromise: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>, options?: Effect.RunOptions) =>
|
||||||
getRuntime().runPromise(service.use(fn), options),
|
getRuntime().runPromise(attach(service.use(fn)), options),
|
||||||
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(service.use(fn)),
|
runFork: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runFork(attach(service.use(fn))),
|
||||||
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) => getRuntime().runCallback(service.use(fn)),
|
runCallback: <A, Err>(fn: (svc: S) => Effect.Effect<A, Err, I>) =>
|
||||||
|
getRuntime().runCallback(attach(service.use(fn))),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,10 +108,11 @@ export namespace Format {
|
|||||||
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
for (const item of yield* Effect.promise(() => getFormatter(ext))) {
|
||||||
log.info("running", { command: item.command })
|
log.info("running", { command: item.command })
|
||||||
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
const cmd = item.command.map((x) => x.replace("$FILE", filepath))
|
||||||
|
const dir = yield* InstanceState.directory
|
||||||
const code = yield* spawner
|
const code = yield* spawner
|
||||||
.spawn(
|
.spawn(
|
||||||
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
ChildProcess.make(cmd[0]!, cmd.slice(1), {
|
||||||
cwd: Instance.directory,
|
cwd: dir,
|
||||||
env: item.environment,
|
env: item.environment,
|
||||||
extendEnv: true,
|
extendEnv: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import z from "zod"
|
|||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { CHANNEL as channel, VERSION as version } from "./meta"
|
||||||
declare global {
|
|
||||||
const OPENCODE_VERSION: string
|
|
||||||
const OPENCODE_CHANNEL: string
|
|
||||||
}
|
|
||||||
|
|
||||||
import semver from "semver"
|
import semver from "semver"
|
||||||
|
|
||||||
@@ -60,8 +56,8 @@ export namespace Installation {
|
|||||||
})
|
})
|
||||||
export type Info = z.infer<typeof Info>
|
export type Info = z.infer<typeof Info>
|
||||||
|
|
||||||
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
export const VERSION = version
|
||||||
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
export const CHANNEL = channel
|
||||||
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
export const USER_AGENT = `opencode/${CHANNEL}/${VERSION}/${Flag.OPENCODE_CLIENT}`
|
||||||
|
|
||||||
export function isPreview() {
|
export function isPreview() {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
declare global {
|
||||||
|
const OPENCODE_VERSION: string
|
||||||
|
const OPENCODE_CHANNEL: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VERSION = typeof OPENCODE_VERSION === "string" ? OPENCODE_VERSION : "local"
|
||||||
|
export const CHANNEL = typeof OPENCODE_CHANNEL === "string" ? OPENCODE_CHANNEL : "local"
|
||||||
@@ -375,38 +375,6 @@ export async function CodexAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
delete provider.models[modelId]
|
delete provider.models[modelId]
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!provider.models["gpt-5.3-codex"]) {
|
|
||||||
const model = {
|
|
||||||
id: ModelID.make("gpt-5.3-codex"),
|
|
||||||
providerID: ProviderID.openai,
|
|
||||||
api: {
|
|
||||||
id: "gpt-5.3-codex",
|
|
||||||
url: "https://chatgpt.com/backend-api/codex",
|
|
||||||
npm: "@ai-sdk/openai",
|
|
||||||
},
|
|
||||||
name: "GPT-5.3 Codex",
|
|
||||||
capabilities: {
|
|
||||||
temperature: false,
|
|
||||||
reasoning: true,
|
|
||||||
attachment: true,
|
|
||||||
toolcall: true,
|
|
||||||
input: { text: true, audio: false, image: true, video: false, pdf: false },
|
|
||||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
|
||||||
interleaved: false,
|
|
||||||
},
|
|
||||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
|
||||||
limit: { context: 400_000, input: 272_000, output: 128_000 },
|
|
||||||
status: "active" as const,
|
|
||||||
options: {},
|
|
||||||
headers: {},
|
|
||||||
release_date: "2026-02-05",
|
|
||||||
variants: {} as Record<string, Record<string, any>>,
|
|
||||||
family: "gpt-codex",
|
|
||||||
}
|
|
||||||
model.variants = ProviderTransform.variants(model)
|
|
||||||
provider.models["gpt-5.3-codex"] = model
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zero out costs for Codex (included with ChatGPT subscription)
|
// Zero out costs for Codex (included with ChatGPT subscription)
|
||||||
for (const model of Object.values(provider.models)) {
|
for (const model of Object.values(provider.models)) {
|
||||||
model.cost = {
|
model.cost = {
|
||||||
|
|||||||
@@ -157,6 +157,14 @@ export namespace Plugin {
|
|||||||
|
|
||||||
const resolved = await PluginLoader.resolve(plan, "server")
|
const resolved = await PluginLoader.resolve(plan, "server")
|
||||||
if (!resolved.ok) {
|
if (!resolved.ok) {
|
||||||
|
if (resolved.stage === "missing") {
|
||||||
|
log.warn("plugin has no server entrypoint", {
|
||||||
|
path: plan.spec,
|
||||||
|
message: resolved.message,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const cause =
|
const cause =
|
||||||
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
|
resolved.error instanceof Error ? (resolved.error.cause ?? resolved.error) : resolved.error
|
||||||
const message = errorMessage(cause)
|
const message = errorMessage(cause)
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ConfigPaths } from "@/config/paths"
|
|||||||
import { Global } from "@/global"
|
import { Global } from "@/global"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Flock } from "@/util/flock"
|
import { Flock } from "@/util/flock"
|
||||||
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
|
import { parsePluginSpecifier, readPluginPackage, resolvePluginTarget } from "./shared"
|
||||||
|
|
||||||
@@ -101,28 +102,60 @@ function pluginList(data: unknown) {
|
|||||||
return item.plugin
|
return item.plugin
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTarget(item: unknown): Target | undefined {
|
function exportValue(value: unknown): string | undefined {
|
||||||
if (item === "server" || item === "tui") return { kind: item }
|
if (typeof value === "string") {
|
||||||
if (!Array.isArray(item)) return
|
const next = value.trim()
|
||||||
if (item[0] !== "server" && item[0] !== "tui") return
|
if (next) return next
|
||||||
if (item.length < 2) return { kind: item[0] }
|
return
|
||||||
const opt = item[1]
|
}
|
||||||
if (!opt || typeof opt !== "object" || Array.isArray(opt)) return { kind: item[0] }
|
if (!isRecord(value)) return
|
||||||
return {
|
for (const key of ["import", "default"]) {
|
||||||
kind: item[0],
|
const next = value[key]
|
||||||
opts: opt,
|
if (typeof next !== "string") continue
|
||||||
|
const hit = next.trim()
|
||||||
|
if (!hit) continue
|
||||||
|
return hit
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTargets(raw: unknown) {
|
function exportOptions(value: unknown): Record<string, unknown> | undefined {
|
||||||
if (!Array.isArray(raw)) return []
|
if (!isRecord(value)) return
|
||||||
const map = new Map<Kind, Target>()
|
const config = value.config
|
||||||
for (const item of raw) {
|
if (!isRecord(config)) return
|
||||||
const hit = parseTarget(item)
|
return config
|
||||||
if (!hit) continue
|
}
|
||||||
map.set(hit.kind, hit)
|
|
||||||
|
function exportTarget(pkg: Record<string, unknown>, kind: Kind) {
|
||||||
|
const exports = pkg.exports
|
||||||
|
if (!isRecord(exports)) return
|
||||||
|
const value = exports[`./${kind}`]
|
||||||
|
const entry = exportValue(value)
|
||||||
|
if (!entry) return
|
||||||
|
return {
|
||||||
|
opts: exportOptions(value),
|
||||||
}
|
}
|
||||||
return [...map.values()]
|
}
|
||||||
|
|
||||||
|
function hasMainTarget(pkg: Record<string, unknown>) {
|
||||||
|
const main = pkg.main
|
||||||
|
if (typeof main !== "string") return false
|
||||||
|
return Boolean(main.trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function packageTargets(pkg: Record<string, unknown>) {
|
||||||
|
const targets: Target[] = []
|
||||||
|
const server = exportTarget(pkg, "server")
|
||||||
|
if (server) {
|
||||||
|
targets.push({ kind: "server", opts: server.opts })
|
||||||
|
} else if (hasMainTarget(pkg)) {
|
||||||
|
targets.push({ kind: "server" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const tui = exportTarget(pkg, "tui")
|
||||||
|
if (tui) {
|
||||||
|
targets.push({ kind: "tui", opts: tui.opts })
|
||||||
|
}
|
||||||
|
return targets
|
||||||
}
|
}
|
||||||
|
|
||||||
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
|
function patch(text: string, path: Array<string | number>, value: unknown, insert = false) {
|
||||||
@@ -260,7 +293,7 @@ export async function readPluginManifest(target: string): Promise<ManifestResult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const targets = parseTargets(pkg.item.json["oc-plugin"])
|
const targets = packageTargets(pkg.item.json)
|
||||||
if (!targets.length) {
|
if (!targets.length) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -330,7 +363,7 @@ async function patchOne(dir: string, target: Target, spec: string, force: boolea
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = pluginList(data)
|
const list = pluginList(data)
|
||||||
const item = target.opts ? [spec, target.opts] : spec
|
const item = target.opts ? ([spec, target.opts] as const) : spec
|
||||||
const out = patchPluginList(text, list, spec, item, force)
|
const out = patchPluginList(text, list, spec, item, force)
|
||||||
if (out.mode === "noop") {
|
if (out.mode === "noop") {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export namespace PluginLoader {
|
|||||||
plan: Plan,
|
plan: Plan,
|
||||||
kind: PluginKind,
|
kind: PluginKind,
|
||||||
): Promise<
|
): Promise<
|
||||||
{ ok: true; value: Resolved } | { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
| { ok: true; value: Resolved }
|
||||||
|
| { ok: false; stage: "missing"; message: string }
|
||||||
|
| { ok: false; stage: "install" | "entry" | "compatibility"; error: unknown }
|
||||||
> {
|
> {
|
||||||
let target = ""
|
let target = ""
|
||||||
try {
|
try {
|
||||||
@@ -77,8 +79,8 @@ export namespace PluginLoader {
|
|||||||
if (!base.entry) {
|
if (!base.entry) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
stage: "entry",
|
stage: "missing",
|
||||||
error: new Error(`Plugin ${plan.spec} entry is empty`),
|
message: `Plugin ${plan.spec} does not expose a ${kind} entrypoint`,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export type PluginEntry = {
|
|||||||
source: PluginSource
|
source: PluginSource
|
||||||
target: string
|
target: string
|
||||||
pkg?: PluginPackage
|
pkg?: PluginPackage
|
||||||
entry: string
|
entry?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
|
const INDEX_FILES = ["index.ts", "index.tsx", "index.js", "index.mjs", "index.cjs"]
|
||||||
@@ -128,13 +128,8 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
|
|||||||
if (index) return pathToFileURL(index).href
|
if (index) return pathToFileURL(index).href
|
||||||
}
|
}
|
||||||
|
|
||||||
if (source === "npm") {
|
if (source === "npm") return
|
||||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"]`)
|
if (dir) return
|
||||||
}
|
|
||||||
|
|
||||||
if (dir) {
|
|
||||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./tui"] or include index file`)
|
|
||||||
}
|
|
||||||
|
|
||||||
return target
|
return target
|
||||||
}
|
}
|
||||||
@@ -145,7 +140,7 @@ async function resolvePluginEntrypoint(spec: string, target: string, kind: Plugi
|
|||||||
if (index) return pathToFileURL(index).href
|
if (index) return pathToFileURL(index).href
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new TypeError(`Plugin ${spec} must define package.json exports["./server"] or package.json main`)
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return target
|
return target
|
||||||
@@ -189,7 +184,7 @@ export async function checkPluginCompatibility(target: string, opencodeVersion:
|
|||||||
|
|
||||||
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
export async function resolvePluginTarget(spec: string, parsed = parsePluginSpecifier(spec)) {
|
||||||
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
|
if (isPathPluginSpec(spec)) return resolvePathPluginTarget(spec)
|
||||||
return BunProc.install(parsed.pkg, parsed.version)
|
return BunProc.install(parsed.pkg, parsed.version, { ignoreScripts: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
export async function readPluginPackage(target: string): Promise<PluginPackage> {
|
||||||
|
|||||||
@@ -114,6 +114,14 @@ export const Instance = {
|
|||||||
const ctx = context.use()
|
const ctx = context.use()
|
||||||
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
return ((...args: any[]) => context.provide(ctx, () => fn(...args))) as F
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* Run a synchronous function within the given instance context ALS.
|
||||||
|
* Use this to bridge from Effect (where InstanceRef carries context)
|
||||||
|
* back to sync code that reads Instance.directory from ALS.
|
||||||
|
*/
|
||||||
|
restore<R>(ctx: InstanceContext, fn: () => R): R {
|
||||||
|
return context.provide(ctx, fn)
|
||||||
|
},
|
||||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||||
return State.create(() => Instance.directory, init, dispose)
|
return State.create(() => Instance.directory, init, dispose)
|
||||||
},
|
},
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -280,6 +280,7 @@ export namespace ProviderTransform {
|
|||||||
msgs = normalizeMessages(msgs, model, options)
|
msgs = normalizeMessages(msgs, model, options)
|
||||||
if (
|
if (
|
||||||
(model.providerID === "anthropic" ||
|
(model.providerID === "anthropic" ||
|
||||||
|
model.providerID === "google-vertex-anthropic" ||
|
||||||
model.api.id.includes("anthropic") ||
|
model.api.id.includes("anthropic") ||
|
||||||
model.api.id.includes("claude") ||
|
model.api.id.includes("claude") ||
|
||||||
model.id.includes("anthropic") ||
|
model.id.includes("anthropic") ||
|
||||||
@@ -292,7 +293,7 @@ export namespace ProviderTransform {
|
|||||||
|
|
||||||
// Remap providerOptions keys from stored providerID to expected SDK key
|
// Remap providerOptions keys from stored providerID to expected SDK key
|
||||||
const key = sdkKey(model.api.npm)
|
const key = sdkKey(model.api.npm)
|
||||||
if (key && key !== model.providerID && model.api.npm !== "@ai-sdk/azure") {
|
if (key && key !== model.providerID) {
|
||||||
const remap = (opts: Record<string, any> | undefined) => {
|
const remap = (opts: Record<string, any> | undefined) => {
|
||||||
if (!opts) return opts
|
if (!opts) return opts
|
||||||
if (!(model.providerID in opts)) return opts
|
if (!(model.providerID in opts)) return opts
|
||||||
|
|||||||
@@ -436,13 +436,13 @@ export const SessionRoutes = lazy(() =>
|
|||||||
validator(
|
validator(
|
||||||
"param",
|
"param",
|
||||||
z.object({
|
z.object({
|
||||||
sessionID: SessionSummary.diff.schema.shape.sessionID,
|
sessionID: SessionSummary.DiffInput.shape.sessionID,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
validator(
|
validator(
|
||||||
"query",
|
"query",
|
||||||
z.object({
|
z.object({
|
||||||
messageID: SessionSummary.diff.schema.shape.messageID,
|
messageID: SessionSummary.DiffInput.shape.messageID,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { NotFoundError } from "@/storage/db"
|
|||||||
import { ModelID, ProviderID } from "@/provider/schema"
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
import { Effect, Layer, ServiceMap } from "effect"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { isOverflow as overflow } from "./overflow"
|
import { isOverflow as overflow } from "./overflow"
|
||||||
|
|
||||||
export namespace SessionCompaction {
|
export namespace SessionCompaction {
|
||||||
@@ -213,6 +214,7 @@ When constructing the summary, try to stick to this template:
|
|||||||
const msgs = structuredClone(messages)
|
const msgs = structuredClone(messages)
|
||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
|
const modelMessages = yield* Effect.promise(() => MessageV2.toModelMessages(msgs, model, { stripMedia: true }))
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
const msg: MessageV2.Assistant = {
|
const msg: MessageV2.Assistant = {
|
||||||
id: MessageID.ascending(),
|
id: MessageID.ascending(),
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
@@ -223,8 +225,8 @@ When constructing the summary, try to stick to this template:
|
|||||||
variant: userMessage.variant,
|
variant: userMessage.variant,
|
||||||
summary: true,
|
summary: true,
|
||||||
path: {
|
path: {
|
||||||
cwd: Instance.directory,
|
cwd: ctx.directory,
|
||||||
root: Instance.worktree,
|
root: ctx.worktree,
|
||||||
},
|
},
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: {
|
tokens: {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { Log } from "../util/log"
|
|||||||
import { updateSchema } from "../util/update-schema"
|
import { updateSchema } from "../util/update-schema"
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { SessionPrompt } from "./prompt"
|
import { SessionPrompt } from "./prompt"
|
||||||
import { fn } from "@/util/fn"
|
import { fn } from "@/util/fn"
|
||||||
import { Command } from "../command"
|
import { Command } from "../command"
|
||||||
@@ -257,6 +258,9 @@ export namespace Session {
|
|||||||
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
|
const cacheReadInputTokens = safe(input.usage.cachedInputTokens ?? 0)
|
||||||
const cacheWriteInputTokens = safe(
|
const cacheWriteInputTokens = safe(
|
||||||
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
(input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ??
|
||||||
|
// google-vertex-anthropic returns metadata under "vertex" key
|
||||||
|
// (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages')
|
||||||
|
input.metadata?.["vertex"]?.["cacheCreationInputTokens"] ??
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
input.metadata?.["bedrock"]?.["usage"]?.["cacheWriteInputTokens"] ??
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -379,11 +383,12 @@ export namespace Session {
|
|||||||
directory: string
|
directory: string
|
||||||
permission?: Permission.Ruleset
|
permission?: Permission.Ruleset
|
||||||
}) {
|
}) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
const result: Info = {
|
const result: Info = {
|
||||||
id: SessionID.descending(input.id),
|
id: SessionID.descending(input.id),
|
||||||
slug: Slug.create(),
|
slug: Slug.create(),
|
||||||
version: Installation.VERSION,
|
version: Installation.VERSION,
|
||||||
projectID: Instance.project.id,
|
projectID: ctx.project.id,
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
workspaceID: input.workspaceID,
|
workspaceID: input.workspaceID,
|
||||||
parentID: input.parentID,
|
parentID: input.parentID,
|
||||||
@@ -441,12 +446,12 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
const children = Effect.fn("Session.children")(function* (parentID: SessionID) {
|
||||||
const project = Instance.project
|
const ctx = yield* InstanceState.context
|
||||||
const rows = yield* db((d) =>
|
const rows = yield* db((d) =>
|
||||||
d
|
d
|
||||||
.select()
|
.select()
|
||||||
.from(SessionTable)
|
.from(SessionTable)
|
||||||
.where(and(eq(SessionTable.project_id, project.id), eq(SessionTable.parent_id, parentID)))
|
.where(and(eq(SessionTable.project_id, ctx.project.id), eq(SessionTable.parent_id, parentID)))
|
||||||
.all(),
|
.all(),
|
||||||
)
|
)
|
||||||
return rows.map(fromRow)
|
return rows.map(fromRow)
|
||||||
@@ -493,9 +498,10 @@ export namespace Session {
|
|||||||
permission?: Permission.Ruleset
|
permission?: Permission.Ruleset
|
||||||
workspaceID?: WorkspaceID
|
workspaceID?: WorkspaceID
|
||||||
}) {
|
}) {
|
||||||
|
const directory = yield* InstanceState.directory
|
||||||
return yield* createNext({
|
return yield* createNext({
|
||||||
parentID: input?.parentID,
|
parentID: input?.parentID,
|
||||||
directory: Instance.directory,
|
directory,
|
||||||
title: input?.title,
|
title: input?.title,
|
||||||
permission: input?.permission,
|
permission: input?.permission,
|
||||||
workspaceID: input?.workspaceID,
|
workspaceID: input?.workspaceID,
|
||||||
@@ -503,10 +509,11 @@ export namespace Session {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
const fork = Effect.fn("Session.fork")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||||
|
const directory = yield* InstanceState.directory
|
||||||
const original = yield* get(input.sessionID)
|
const original = yield* get(input.sessionID)
|
||||||
const title = getForkedTitle(original.title)
|
const title = getForkedTitle(original.title)
|
||||||
const session = yield* createNext({
|
const session = yield* createNext({
|
||||||
directory: Instance.directory,
|
directory,
|
||||||
workspaceID: original.workspaceID,
|
workspaceID: original.workspaceID,
|
||||||
title,
|
title,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -53,32 +53,22 @@ export namespace LLM {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
return Service.of({
|
return Service.of({
|
||||||
stream(input) {
|
stream(input) {
|
||||||
const stream: Stream.Stream<Event, unknown> = Stream.scoped(
|
return Stream.scoped(
|
||||||
Stream.unwrap(
|
Stream.unwrap(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const ctrl = yield* Effect.acquireRelease(
|
const ctrl = yield* Effect.acquireRelease(
|
||||||
Effect.sync(() => new AbortController()),
|
Effect.sync(() => new AbortController()),
|
||||||
(ctrl) => Effect.sync(() => ctrl.abort()),
|
(ctrl) => Effect.sync(() => ctrl.abort()),
|
||||||
)
|
)
|
||||||
const queue = yield* Queue.unbounded<Event, unknown | Cause.Done>()
|
|
||||||
|
|
||||||
yield* Effect.promise(async () => {
|
const result = yield* Effect.promise(() => LLM.stream({ ...input, abort: ctrl.signal }))
|
||||||
const result = await LLM.stream({ ...input, abort: ctrl.signal })
|
|
||||||
for await (const event of result.fullStream) {
|
return Stream.fromAsyncIterable(result.fullStream, (e) =>
|
||||||
if (!Queue.offerUnsafe(queue, event)) break
|
e instanceof Error ? e : new Error(String(e)),
|
||||||
}
|
|
||||||
Queue.endUnsafe(queue)
|
|
||||||
}).pipe(
|
|
||||||
Effect.catchCause((cause) => Effect.sync(() => void Queue.failCauseUnsafe(queue, cause))),
|
|
||||||
Effect.onInterrupt(() => Effect.sync(() => ctrl.abort())),
|
|
||||||
Effect.forkScoped,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return Stream.fromQueue(queue)
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
return stream
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -294,12 +294,10 @@ export namespace SessionProcessor {
|
|||||||
}
|
}
|
||||||
ctx.snapshot = undefined
|
ctx.snapshot = undefined
|
||||||
}
|
}
|
||||||
yield* Effect.promise(() =>
|
SessionSummary.summarize({
|
||||||
SessionSummary.summarize({
|
sessionID: ctx.sessionID,
|
||||||
sessionID: ctx.sessionID,
|
messageID: ctx.assistantMessage.parentID,
|
||||||
messageID: ctx.assistantMessage.parentID,
|
})
|
||||||
}),
|
|
||||||
).pipe(Effect.ignoreCause({ log: true, message: "session summary failed" }), Effect.forkDetach)
|
|
||||||
if (
|
if (
|
||||||
!ctx.assistantMessage.summary &&
|
!ctx.assistantMessage.summary &&
|
||||||
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
isOverflow({ cfg: yield* config.get(), tokens: usage.tokens, model: ctx.model })
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export namespace SessionPrompt {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
const resolvePromptParts = Effect.fn("SessionPrompt.resolvePromptParts")(function* (template: string) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
const parts: PromptInput["parts"] = [{ type: "text", text: template }]
|
const parts: PromptInput["parts"] = [{ type: "text", text: template }]
|
||||||
const files = ConfigMarkdown.files(template)
|
const files = ConfigMarkdown.files(template)
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
@@ -159,7 +160,7 @@ export namespace SessionPrompt {
|
|||||||
seen.add(name)
|
seen.add(name)
|
||||||
const filepath = name.startsWith("~/")
|
const filepath = name.startsWith("~/")
|
||||||
? path.join(os.homedir(), name.slice(2))
|
? path.join(os.homedir(), name.slice(2))
|
||||||
: path.resolve(Instance.worktree, name)
|
: path.resolve(ctx.worktree, name)
|
||||||
|
|
||||||
const info = yield* fsys.stat(filepath).pipe(Effect.option)
|
const info = yield* fsys.stat(filepath).pipe(Effect.option)
|
||||||
if (Option.isNone(info)) {
|
if (Option.isNone(info)) {
|
||||||
@@ -403,7 +404,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Effect.runPromise(
|
Effect.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const match = input.processor.partFromToolCall(options.toolCallId)
|
const match = input.processor.partFromToolCall(options.toolCallId)
|
||||||
if (!match || match.state.status !== "running") return
|
if (!match || !["running", "pending"].includes(match.state.status)) return
|
||||||
yield* sessions.updatePart({
|
yield* sessions.updatePart({
|
||||||
...match,
|
...match,
|
||||||
state: {
|
state: {
|
||||||
@@ -553,6 +554,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
msgs: MessageV2.WithParts[]
|
msgs: MessageV2.WithParts[]
|
||||||
}) {
|
}) {
|
||||||
const { task, model, lastUser, sessionID, session, msgs } = input
|
const { task, model, lastUser, sessionID, session, msgs } = input
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
const taskTool = yield* Effect.promise(() => TaskTool.init())
|
const taskTool = yield* Effect.promise(() => TaskTool.init())
|
||||||
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
const taskModel = task.model ? yield* getModel(task.model.providerID, task.model.modelID, sessionID) : model
|
||||||
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
const assistantMessage: MessageV2.Assistant = yield* sessions.updateMessage({
|
||||||
@@ -563,7 +565,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
mode: task.agent,
|
mode: task.agent,
|
||||||
agent: task.agent,
|
agent: task.agent,
|
||||||
variant: lastUser.variant,
|
variant: lastUser.variant,
|
||||||
path: { cwd: Instance.directory, root: Instance.worktree },
|
path: { cwd: ctx.directory, root: ctx.worktree },
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
modelID: taskModel.id,
|
modelID: taskModel.id,
|
||||||
@@ -734,6 +736,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
})
|
})
|
||||||
|
|
||||||
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
|
const shellImpl = Effect.fn("SessionPrompt.shellImpl")(function* (input: ShellInput, signal: AbortSignal) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
const session = yield* sessions.get(input.sessionID)
|
const session = yield* sessions.get(input.sessionID)
|
||||||
if (session.revert) {
|
if (session.revert) {
|
||||||
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
yield* Effect.promise(() => SessionRevert.cleanup(session))
|
||||||
@@ -773,7 +776,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
mode: input.agent,
|
mode: input.agent,
|
||||||
agent: input.agent,
|
agent: input.agent,
|
||||||
cost: 0,
|
cost: 0,
|
||||||
path: { cwd: Instance.directory, root: Instance.worktree },
|
path: { cwd: ctx.directory, root: ctx.worktree },
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
@@ -832,7 +835,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}
|
}
|
||||||
|
|
||||||
const args = (invocations[shellName] ?? invocations[""]).args
|
const args = (invocations[shellName] ?? invocations[""]).args
|
||||||
const cwd = Instance.directory
|
const cwd = ctx.directory
|
||||||
const shellEnv = yield* plugin.trigger(
|
const shellEnv = yield* plugin.trigger(
|
||||||
"shell.env",
|
"shell.env",
|
||||||
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
{ cwd, sessionID: input.sessionID, callID: part.callID },
|
||||||
@@ -976,7 +979,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
variant,
|
variant,
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* Effect.addFinalizer(() => Effect.sync(() => InstructionPrompt.clear(info.id)))
|
yield* Effect.addFinalizer(() => InstanceState.withALS(() => InstructionPrompt.clear(info.id)))
|
||||||
|
|
||||||
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
type Draft<T> = T extends MessageV2.Part ? Omit<T, "id"> & { id?: string } : never
|
||||||
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
|
const assign = (part: Draft<MessageV2.Part>): MessageV2.Part => ({
|
||||||
@@ -1330,6 +1333,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
|
|
||||||
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
|
const runLoop: (sessionID: SessionID) => Effect.Effect<MessageV2.WithParts> = Effect.fn("SessionPrompt.run")(
|
||||||
function* (sessionID: SessionID) {
|
function* (sessionID: SessionID) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
let structured: unknown | undefined
|
let structured: unknown | undefined
|
||||||
let step = 0
|
let step = 0
|
||||||
const session = yield* sessions.get(sessionID)
|
const session = yield* sessions.get(sessionID)
|
||||||
@@ -1421,7 +1425,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
mode: agent.name,
|
mode: agent.name,
|
||||||
agent: agent.name,
|
agent: agent.name,
|
||||||
variant: lastUser.variant,
|
variant: lastUser.variant,
|
||||||
path: { cwd: Instance.directory, root: Instance.worktree },
|
path: { cwd: ctx.directory, root: ctx.worktree },
|
||||||
cost: 0,
|
cost: 0,
|
||||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
modelID: model.id,
|
modelID: model.id,
|
||||||
@@ -1538,7 +1542,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
}),
|
}),
|
||||||
Effect.fnUntraced(function* (exit) {
|
Effect.fnUntraced(function* (exit) {
|
||||||
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
|
if (Exit.isFailure(exit) && Cause.hasInterruptsOnly(exit.cause)) yield* handle.abort()
|
||||||
InstructionPrompt.clear(handle.message.id)
|
yield* InstanceState.withALS(() => InstructionPrompt.clear(handle.message.id))
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
if (outcome === "break") break
|
if (outcome === "break") break
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
You are OpenCode, an interactive general AI agent running on a user's computer.
|
||||||
|
|
||||||
|
Your primary goal is to help users with software engineering tasks by taking action — use the tools available to you to make real changes on the user's system. You should also answer questions when asked. Always adhere strictly to the following system instructions and the user's requirements.
|
||||||
|
|
||||||
|
# Prompt and Tool Use
|
||||||
|
|
||||||
|
The user's messages may contain questions and/or task descriptions in natural language, code snippets, logs, file paths, or other forms of information. Read them, understand them and do what the user requested. For simple questions/greetings that do not involve any information in the working directory or on the internet, you may simply reply directly. For anything else, default to taking action with tools. When the request could be interpreted as either a question to answer or a task to complete, treat it as a task.
|
||||||
|
|
||||||
|
When handling the user's request, if it involves creating, modifying, or running code or files, you MUST use the appropriate tools to make actual changes — do not just describe the solution in text. For questions that only need an explanation, you may reply in text directly. When calling tools, do not provide explanations because the tool calls themselves should be self-explanatory. You MUST follow the description of each tool and its parameters when calling tools.
|
||||||
|
|
||||||
|
If the `task` tool is available, you can use it to delegate a focused subtask to a subagent instance. When delegating, provide a complete prompt with all necessary context because a newly created subagent does not automatically see your current context.
|
||||||
|
|
||||||
|
You have the capability to output any number of tool calls in a single response. If you anticipate making multiple non-interfering tool calls, you are HIGHLY RECOMMENDED to make them in parallel to significantly improve efficiency. This is very important to your performance.
|
||||||
|
|
||||||
|
The results of the tool calls will be returned to you in a tool message. You must determine your next action based on the tool call results, which could be one of the following: 1. Continue working on the task, 2. Inform the user that the task is completed or has failed, or 3. Ask the user for more information.
|
||||||
|
|
||||||
|
Tool results and user messages may include `<system-reminder>` tags. These are authoritative system directives that you MUST follow. They bear no direct relation to the specific tool results or user messages in which they appear. Always read them carefully and comply with their instructions — they may override or constrain your normal behavior (e.g., restricting you to read-only actions during plan mode).
|
||||||
|
|
||||||
|
When responding to the user, you MUST use the SAME language as the user, unless explicitly instructed to do otherwise.
|
||||||
|
|
||||||
|
# General Guidelines for Coding
|
||||||
|
|
||||||
|
When building something from scratch, you should:
|
||||||
|
|
||||||
|
- Understand the user's requirements.
|
||||||
|
- Ask the user for clarification if there is anything unclear.
|
||||||
|
- Design the architecture and make a plan for the implementation.
|
||||||
|
- Write the code in a modular and maintainable way.
|
||||||
|
|
||||||
|
Always use tools to implement your code changes:
|
||||||
|
|
||||||
|
- Use `write`/`edit` to create or modify source files. Code that only appears in your text response is NOT saved to the file system and will not take effect.
|
||||||
|
- Use `bash` to run and test your code after writing it.
|
||||||
|
- Iterate: if tests fail, read the error, fix the code with `write`/`edit`, and re-test with `bash`.
|
||||||
|
|
||||||
|
When working on an existing codebase, you should:
|
||||||
|
|
||||||
|
- Understand the codebase by reading it with tools (`read`, `glob`, `grep`) before making changes. Identify the ultimate goal and the most important criteria to achieve the goal.
|
||||||
|
- For a bug fix, you typically need to check error logs or failed tests, scan over the codebase to find the root cause, and figure out a fix. If user mentioned any failed tests, you should make sure they pass after the changes.
|
||||||
|
- For a feature, you typically need to design the architecture, and write the code in a modular and maintainable way, with minimal intrusions to existing code. Add new tests if the project already has tests.
|
||||||
|
- For a code refactoring, you typically need to update all the places that call the code you are refactoring if the interface changes. DO NOT change any existing logic especially in tests, focus only on fixing any errors caused by the interface changes.
|
||||||
|
- Make MINIMAL changes to achieve the goal. This is very important to your performance.
|
||||||
|
- Follow the coding style of existing code in the project.
|
||||||
|
|
||||||
|
DO NOT run `git commit`, `git push`, `git reset`, `git rebase` and/or do any other git mutations unless explicitly asked to do so. Ask for confirmation each time when you need to do git mutations, even if the user has confirmed in earlier conversations.
|
||||||
|
|
||||||
|
# General Guidelines for Research and Data Processing
|
||||||
|
|
||||||
|
The user may ask you to research on certain topics, process or generate certain multimedia files. When doing such tasks, you must:
|
||||||
|
|
||||||
|
- Understand the user's requirements thoroughly, ask for clarification before you start if needed.
|
||||||
|
- Make plans before doing deep or wide research, to ensure you are always on track.
|
||||||
|
- Search on the Internet if possible, with carefully-designed search queries to improve efficiency and accuracy.
|
||||||
|
- Use proper tools or shell commands or Python packages to process or generate images, videos, PDFs, docs, spreadsheets, presentations, or other multimedia files. Detect if there are already such tools in the environment. If you have to install third-party tools/packages, you MUST ensure that they are installed in a virtual/isolated environment.
|
||||||
|
- Once you generate or edit any images, videos or other media files, try to read it again before proceed, to ensure that the content is as expected.
|
||||||
|
- Avoid installing or deleting anything to/from outside of the current working directory. If you have to do so, ask the user for confirmation.
|
||||||
|
|
||||||
|
# Working Environment
|
||||||
|
|
||||||
|
## Operating System
|
||||||
|
|
||||||
|
The operating environment is not in a sandbox. Any actions you do will immediately affect the user's system. So you MUST be extremely cautious. Unless being explicitly instructed to do so, you should never access (read/write/execute) files outside of the working directory.
|
||||||
|
|
||||||
|
## Working Directory
|
||||||
|
|
||||||
|
The working directory should be considered as the project root if you are instructed to perform tasks on the project. Every file system operation will be relative to the working directory if you do not explicitly specify the absolute path. Tools may require absolute paths for some parameters, IF SO, YOU MUST use absolute paths for these parameters.
|
||||||
|
|
||||||
|
# Project Information
|
||||||
|
|
||||||
|
Markdown files named `AGENTS.md` usually contain the background, structure, coding styles, user preferences and other relevant information about the project. You should use this information to understand the project and the user's preferences. `AGENTS.md` files may exist at different locations in the project, but typically there is one in the project root.
|
||||||
|
|
||||||
|
> Why `AGENTS.md`?
|
||||||
|
>
|
||||||
|
> `README.md` files are for humans: quick starts, project descriptions, and contribution guidelines. `AGENTS.md` complements this by containing the extra, sometimes detailed context coding agents need: build steps, tests, and conventions that might clutter a README or aren’t relevant to human contributors.
|
||||||
|
>
|
||||||
|
> We intentionally kept it separate to:
|
||||||
|
>
|
||||||
|
> - Give agents a clear, predictable place for instructions.
|
||||||
|
> - Keep `README`s concise and focused on human contributors.
|
||||||
|
> - Provide precise, agent-focused guidance that complements existing `README` and docs.
|
||||||
|
If the `AGENTS.md` is empty or insufficient, you may check `README`/`README.md` files or `AGENTS.md` files in subdirectories for more information about specific parts of the project.
|
||||||
|
|
||||||
|
If you modified any files/styles/structures/configurations/workflows/... mentioned in `AGENTS.md` files, you MUST update the corresponding `AGENTS.md` files to keep them up-to-date.
|
||||||
|
|
||||||
|
# Skills
|
||||||
|
|
||||||
|
Skills are reusable, composable capabilities that enhance your abilities. Each skill is a self-contained directory with a `SKILL.md` file that contains instructions, examples, and/or reference material.
|
||||||
|
|
||||||
|
## What are skills?
|
||||||
|
|
||||||
|
Skills are modular extensions that provide:
|
||||||
|
|
||||||
|
- Specialized knowledge: Domain-specific expertise (e.g., PDF processing, data analysis)
|
||||||
|
- Workflow patterns: Best practices for common tasks
|
||||||
|
- Tool integrations: Pre-configured tool chains for specific operations
|
||||||
|
- Reference material: Documentation, templates, and examples
|
||||||
|
|
||||||
|
## How to use skills
|
||||||
|
|
||||||
|
Identify the skills that are likely to be useful for the tasks you are currently working on, use the `skill` tool to load a skill for detailed instructions, guidelines, scripts and more.
|
||||||
|
|
||||||
|
Only load skill details when needed to conserve the context window.
|
||||||
|
|
||||||
|
# Ultimate Reminders
|
||||||
|
|
||||||
|
At any time, you should be HELPFUL, CONCISE, and ACCURATE. Be thorough in your actions — test what you build, verify what you change — not in your explanations.
|
||||||
|
|
||||||
|
- Never diverge from the requirements and the goals of the task you work on. Stay on track.
|
||||||
|
- Never give the user more than what they want.
|
||||||
|
- Try your best to avoid any hallucination. Do fact checking before providing any factual information.
|
||||||
|
- Think about the best approach, then take action decisively.
|
||||||
|
- Do not give up too early.
|
||||||
|
- ALWAYS, keep it stupidly simple. Do not overcomplicate things.
|
||||||
|
- When the task requires creating or modifying files, always use tools to do so. Never treat displaying code in your response as a substitute for actually writing it to the file system.
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { SessionID, MessageID, PartID } from "./schema"
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
import { Snapshot } from "../snapshot"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import { MessageV2 } from "./message-v2"
|
|
||||||
import { Session } from "."
|
|
||||||
import { Log } from "../util/log"
|
|
||||||
import { SyncEvent } from "../sync"
|
|
||||||
import { Storage } from "@/storage/storage"
|
|
||||||
import { Bus } from "../bus"
|
import { Bus } from "../bus"
|
||||||
|
import { Snapshot } from "../snapshot"
|
||||||
|
import { Storage } from "@/storage/storage"
|
||||||
|
import { SyncEvent } from "../sync"
|
||||||
|
import { Log } from "../util/log"
|
||||||
|
import { Session } from "."
|
||||||
|
import { MessageV2 } from "./message-v2"
|
||||||
|
import { SessionID, MessageID, PartID } from "./schema"
|
||||||
import { SessionPrompt } from "./prompt"
|
import { SessionPrompt } from "./prompt"
|
||||||
import { SessionSummary } from "./summary"
|
import { SessionSummary } from "./summary"
|
||||||
|
|
||||||
@@ -20,116 +22,152 @@ export namespace SessionRevert {
|
|||||||
})
|
})
|
||||||
export type RevertInput = z.infer<typeof RevertInput>
|
export type RevertInput = z.infer<typeof RevertInput>
|
||||||
|
|
||||||
export async function revert(input: RevertInput) {
|
export interface Interface {
|
||||||
await SessionPrompt.assertNotBusy(input.sessionID)
|
readonly revert: (input: RevertInput) => Effect.Effect<Session.Info>
|
||||||
const all = await Session.messages({ sessionID: input.sessionID })
|
readonly unrevert: (input: { sessionID: SessionID }) => Effect.Effect<Session.Info>
|
||||||
let lastUser: MessageV2.User | undefined
|
readonly cleanup: (session: Session.Info) => Effect.Effect<void>
|
||||||
const session = await Session.get(input.sessionID)
|
}
|
||||||
|
|
||||||
let revert: Session.Info["revert"]
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionRevert") {}
|
||||||
const patches: Snapshot.Patch[] = []
|
|
||||||
for (const msg of all) {
|
|
||||||
if (msg.info.role === "user") lastUser = msg.info
|
|
||||||
const remaining = []
|
|
||||||
for (const part of msg.parts) {
|
|
||||||
if (revert) {
|
|
||||||
if (part.type === "patch") {
|
|
||||||
patches.push(part)
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!revert) {
|
export const layer = Layer.effect(
|
||||||
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
Service,
|
||||||
// if no useful parts left in message, same as reverting whole message
|
Effect.gen(function* () {
|
||||||
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
const sessions = yield* Session.Service
|
||||||
revert = {
|
const snap = yield* Snapshot.Service
|
||||||
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
const storage = yield* Storage.Service
|
||||||
partID,
|
const bus = yield* Bus.Service
|
||||||
|
|
||||||
|
const revert = Effect.fn("SessionRevert.revert")(function* (input: RevertInput) {
|
||||||
|
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
||||||
|
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||||
|
let lastUser: MessageV2.User | undefined
|
||||||
|
const session = yield* sessions.get(input.sessionID)
|
||||||
|
|
||||||
|
let rev: Session.Info["revert"]
|
||||||
|
const patches: Snapshot.Patch[] = []
|
||||||
|
for (const msg of all) {
|
||||||
|
if (msg.info.role === "user") lastUser = msg.info
|
||||||
|
const remaining = []
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (rev) {
|
||||||
|
if (part.type === "patch") patches.push(part)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rev) {
|
||||||
|
if ((msg.info.id === input.messageID && !input.partID) || part.id === input.partID) {
|
||||||
|
const partID = remaining.some((item) => ["text", "tool"].includes(item.type)) ? input.partID : undefined
|
||||||
|
rev = {
|
||||||
|
messageID: !partID && lastUser ? lastUser.id : msg.info.id,
|
||||||
|
partID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
remaining.push(part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
remaining.push(part)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (revert) {
|
if (!rev) return session
|
||||||
const session = await Session.get(input.sessionID)
|
|
||||||
revert.snapshot = session.revert?.snapshot ?? (await Snapshot.track())
|
rev.snapshot = session.revert?.snapshot ?? (yield* snap.track())
|
||||||
await Snapshot.revert(patches)
|
yield* snap.revert(patches)
|
||||||
if (revert.snapshot) revert.diff = await Snapshot.diff(revert.snapshot)
|
if (rev.snapshot) rev.diff = yield* snap.diff(rev.snapshot as string)
|
||||||
const rangeMessages = all.filter((msg) => msg.info.id >= revert!.messageID)
|
const range = all.filter((msg) => msg.info.id >= rev!.messageID)
|
||||||
const diffs = await SessionSummary.computeDiff({ messages: rangeMessages })
|
const diffs = yield* Effect.promise(() => SessionSummary.computeDiff({ messages: range }))
|
||||||
await Storage.write(["session_diff", input.sessionID], diffs)
|
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||||
Bus.publish(Session.Event.Diff, {
|
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||||
sessionID: input.sessionID,
|
yield* sessions.setRevert({
|
||||||
diff: diffs,
|
sessionID: input.sessionID,
|
||||||
|
revert: rev,
|
||||||
|
summary: {
|
||||||
|
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||||
|
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||||
|
files: diffs.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return yield* sessions.get(input.sessionID)
|
||||||
})
|
})
|
||||||
return Session.setRevert({
|
|
||||||
sessionID: input.sessionID,
|
const unrevert = Effect.fn("SessionRevert.unrevert")(function* (input: { sessionID: SessionID }) {
|
||||||
revert,
|
log.info("unreverting", input)
|
||||||
summary: {
|
yield* Effect.promise(() => SessionPrompt.assertNotBusy(input.sessionID))
|
||||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
const session = yield* sessions.get(input.sessionID)
|
||||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
if (!session.revert) return session
|
||||||
files: diffs.length,
|
if (session.revert.snapshot) yield* snap.restore(session.revert!.snapshot!)
|
||||||
},
|
yield* sessions.clearRevert(input.sessionID)
|
||||||
|
return yield* sessions.get(input.sessionID)
|
||||||
})
|
})
|
||||||
}
|
|
||||||
return session
|
const cleanup = Effect.fn("SessionRevert.cleanup")(function* (session: Session.Info) {
|
||||||
|
if (!session.revert) return
|
||||||
|
const sessionID = session.id
|
||||||
|
const msgs = yield* sessions.messages({ sessionID })
|
||||||
|
const messageID = session.revert.messageID
|
||||||
|
const remove = [] as MessageV2.WithParts[]
|
||||||
|
let target: MessageV2.WithParts | undefined
|
||||||
|
for (const msg of msgs) {
|
||||||
|
if (msg.info.id < messageID) continue
|
||||||
|
if (msg.info.id > messageID) {
|
||||||
|
remove.push(msg)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (session.revert.partID) {
|
||||||
|
target = msg
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remove.push(msg)
|
||||||
|
}
|
||||||
|
for (const msg of remove) {
|
||||||
|
SyncEvent.run(MessageV2.Event.Removed, {
|
||||||
|
sessionID,
|
||||||
|
messageID: msg.info.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (session.revert.partID && target) {
|
||||||
|
const partID = session.revert.partID
|
||||||
|
const idx = target.parts.findIndex((part) => part.id === partID)
|
||||||
|
if (idx >= 0) {
|
||||||
|
const removeParts = target.parts.slice(idx)
|
||||||
|
target.parts = target.parts.slice(0, idx)
|
||||||
|
for (const part of removeParts) {
|
||||||
|
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
||||||
|
sessionID,
|
||||||
|
messageID: target.info.id,
|
||||||
|
partID: part.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yield* sessions.clearRevert(sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ revert, unrevert, cleanup })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = Layer.unwrap(
|
||||||
|
Effect.sync(() =>
|
||||||
|
layer.pipe(
|
||||||
|
Layer.provide(Session.defaultLayer),
|
||||||
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
|
Layer.provide(Storage.defaultLayer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export async function revert(input: RevertInput) {
|
||||||
|
return runPromise((svc) => svc.revert(input))
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function unrevert(input: { sessionID: SessionID }) {
|
export async function unrevert(input: { sessionID: SessionID }) {
|
||||||
log.info("unreverting", input)
|
return runPromise((svc) => svc.unrevert(input))
|
||||||
await SessionPrompt.assertNotBusy(input.sessionID)
|
|
||||||
const session = await Session.get(input.sessionID)
|
|
||||||
if (!session.revert) return session
|
|
||||||
if (session.revert.snapshot) await Snapshot.restore(session.revert.snapshot)
|
|
||||||
return Session.clearRevert(input.sessionID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function cleanup(session: Session.Info) {
|
export async function cleanup(session: Session.Info) {
|
||||||
if (!session.revert) return
|
return runPromise((svc) => svc.cleanup(session))
|
||||||
const sessionID = session.id
|
|
||||||
const msgs = await Session.messages({ sessionID })
|
|
||||||
const messageID = session.revert.messageID
|
|
||||||
const remove = [] as MessageV2.WithParts[]
|
|
||||||
let target: MessageV2.WithParts | undefined
|
|
||||||
for (const msg of msgs) {
|
|
||||||
if (msg.info.id < messageID) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (msg.info.id > messageID) {
|
|
||||||
remove.push(msg)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (session.revert.partID) {
|
|
||||||
target = msg
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
remove.push(msg)
|
|
||||||
}
|
|
||||||
for (const msg of remove) {
|
|
||||||
SyncEvent.run(MessageV2.Event.Removed, {
|
|
||||||
sessionID: sessionID,
|
|
||||||
messageID: msg.info.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (session.revert.partID && target) {
|
|
||||||
const partID = session.revert.partID
|
|
||||||
const removeStart = target.parts.findIndex((part) => part.id === partID)
|
|
||||||
if (removeStart >= 0) {
|
|
||||||
const preserveParts = target.parts.slice(0, removeStart)
|
|
||||||
const removeParts = target.parts.slice(removeStart)
|
|
||||||
target.parts = preserveParts
|
|
||||||
for (const part of removeParts) {
|
|
||||||
SyncEvent.run(MessageV2.Event.PartRemoved, {
|
|
||||||
sessionID: sessionID,
|
|
||||||
messageID: target.info.id,
|
|
||||||
partID: part.id,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await Session.clearRevert(sessionID)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { fn } from "@/util/fn"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Effect, Layer, ServiceMap } from "effect"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { Snapshot } from "@/snapshot"
|
||||||
|
import { Storage } from "@/storage/storage"
|
||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
|
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
import { SessionID, MessageID } from "./schema"
|
import { SessionID, MessageID } from "./schema"
|
||||||
import { Snapshot } from "@/snapshot"
|
|
||||||
|
|
||||||
import { Storage } from "@/storage/storage"
|
|
||||||
import { Bus } from "@/bus"
|
|
||||||
import { NotFoundError } from "@/storage/db"
|
|
||||||
|
|
||||||
export namespace SessionSummary {
|
export namespace SessionSummary {
|
||||||
function unquoteGitPath(input: string) {
|
function unquoteGitPath(input: string) {
|
||||||
@@ -67,103 +65,117 @@ export namespace SessionSummary {
|
|||||||
return Buffer.from(bytes).toString()
|
return Buffer.from(bytes).toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
export const summarize = fn(
|
export interface Interface {
|
||||||
z.object({
|
readonly summarize: (input: { sessionID: SessionID; messageID: MessageID }) => Effect.Effect<void>
|
||||||
sessionID: SessionID.zod,
|
readonly diff: (input: { sessionID: SessionID; messageID?: MessageID }) => Effect.Effect<Snapshot.FileDiff[]>
|
||||||
messageID: MessageID.zod,
|
readonly computeDiff: (input: { messages: MessageV2.WithParts[] }) => Effect.Effect<Snapshot.FileDiff[]>
|
||||||
}),
|
|
||||||
async (input) => {
|
|
||||||
await Session.messages({ sessionID: input.sessionID })
|
|
||||||
.then((all) =>
|
|
||||||
Promise.all([
|
|
||||||
summarizeSession({ sessionID: input.sessionID, messages: all }),
|
|
||||||
summarizeMessage({ messageID: input.messageID, messages: all }),
|
|
||||||
]),
|
|
||||||
)
|
|
||||||
.catch((err) => {
|
|
||||||
if (NotFoundError.isInstance(err)) return
|
|
||||||
throw err
|
|
||||||
})
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async function summarizeSession(input: { sessionID: SessionID; messages: MessageV2.WithParts[] }) {
|
|
||||||
const diffs = await computeDiff({ messages: input.messages })
|
|
||||||
await Session.setSummary({
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
summary: {
|
|
||||||
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
|
||||||
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
|
||||||
files: diffs.length,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
await Storage.write(["session_diff", input.sessionID], diffs)
|
|
||||||
Bus.publish(Session.Event.Diff, {
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
diff: diffs,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function summarizeMessage(input: { messageID: string; messages: MessageV2.WithParts[] }) {
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/SessionSummary") {}
|
||||||
const messages = input.messages.filter(
|
|
||||||
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
|
||||||
)
|
|
||||||
const msgWithParts = messages.find((m) => m.info.id === input.messageID)
|
|
||||||
if (!msgWithParts || msgWithParts.info.role !== "user") return
|
|
||||||
const userMsg = msgWithParts.info
|
|
||||||
const diffs = await computeDiff({ messages })
|
|
||||||
userMsg.summary = {
|
|
||||||
...userMsg.summary,
|
|
||||||
diffs,
|
|
||||||
}
|
|
||||||
await Session.updateMessage(userMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const diff = fn(
|
export const layer = Layer.effect(
|
||||||
z.object({
|
Service,
|
||||||
sessionID: SessionID.zod,
|
Effect.gen(function* () {
|
||||||
messageID: MessageID.zod.optional(),
|
const sessions = yield* Session.Service
|
||||||
}),
|
const snapshot = yield* Snapshot.Service
|
||||||
async (input) => {
|
const storage = yield* Storage.Service
|
||||||
const diffs = await Storage.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID]).catch(() => [])
|
const bus = yield* Bus.Service
|
||||||
const next = diffs.map((item) => {
|
|
||||||
const file = unquoteGitPath(item.file)
|
|
||||||
if (file === item.file) return item
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
file,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
|
||||||
if (changed) Storage.write(["session_diff", input.sessionID], next).catch(() => {})
|
|
||||||
return next
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
|
const computeDiff = Effect.fn("SessionSummary.computeDiff")(function* (input: {
|
||||||
let from: string | undefined
|
messages: MessageV2.WithParts[]
|
||||||
let to: string | undefined
|
}) {
|
||||||
|
let from: string | undefined
|
||||||
// scan assistant messages to find earliest from and latest to
|
let to: string | undefined
|
||||||
// snapshot
|
for (const item of input.messages) {
|
||||||
for (const item of input.messages) {
|
if (!from) {
|
||||||
if (!from) {
|
for (const part of item.parts) {
|
||||||
for (const part of item.parts) {
|
if (part.type === "step-start" && part.snapshot) {
|
||||||
if (part.type === "step-start" && part.snapshot) {
|
from = part.snapshot
|
||||||
from = part.snapshot
|
break
|
||||||
break
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const part of item.parts) {
|
||||||
|
if (part.type === "step-finish" && part.snapshot) to = part.snapshot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (from && to) return yield* snapshot.diffFull(from, to)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
for (const part of item.parts) {
|
const summarize = Effect.fn("SessionSummary.summarize")(function* (input: {
|
||||||
if (part.type === "step-finish" && part.snapshot) {
|
sessionID: SessionID
|
||||||
to = part.snapshot
|
messageID: MessageID
|
||||||
}
|
}) {
|
||||||
}
|
const all = yield* sessions.messages({ sessionID: input.sessionID })
|
||||||
}
|
if (!all.length) return
|
||||||
|
|
||||||
if (from && to) return Snapshot.diffFull(from, to)
|
const diffs = yield* computeDiff({ messages: all })
|
||||||
return []
|
yield* sessions.setSummary({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
summary: {
|
||||||
|
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||||
|
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||||
|
files: diffs.length,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
yield* storage.write(["session_diff", input.sessionID], diffs).pipe(Effect.ignore)
|
||||||
|
yield* bus.publish(Session.Event.Diff, { sessionID: input.sessionID, diff: diffs })
|
||||||
|
|
||||||
|
const messages = all.filter(
|
||||||
|
(m) => m.info.id === input.messageID || (m.info.role === "assistant" && m.info.parentID === input.messageID),
|
||||||
|
)
|
||||||
|
const target = messages.find((m) => m.info.id === input.messageID)
|
||||||
|
if (!target || target.info.role !== "user") return
|
||||||
|
const msgDiffs = yield* computeDiff({ messages })
|
||||||
|
target.info.summary = { ...target.info.summary, diffs: msgDiffs }
|
||||||
|
yield* sessions.updateMessage(target.info)
|
||||||
|
})
|
||||||
|
|
||||||
|
const diff = Effect.fn("SessionSummary.diff")(function* (input: { sessionID: SessionID; messageID?: MessageID }) {
|
||||||
|
const diffs = yield* storage
|
||||||
|
.read<Snapshot.FileDiff[]>(["session_diff", input.sessionID])
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed([] as Snapshot.FileDiff[])))
|
||||||
|
const next = diffs.map((item) => {
|
||||||
|
const file = unquoteGitPath(item.file)
|
||||||
|
if (file === item.file) return item
|
||||||
|
return { ...item, file }
|
||||||
|
})
|
||||||
|
const changed = next.some((item, i) => item.file !== diffs[i]?.file)
|
||||||
|
if (changed) yield* storage.write(["session_diff", input.sessionID], next).pipe(Effect.ignore)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({ summarize, diff, computeDiff })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = Layer.unwrap(
|
||||||
|
Effect.sync(() =>
|
||||||
|
layer.pipe(
|
||||||
|
Layer.provide(Session.defaultLayer),
|
||||||
|
Layer.provide(Snapshot.defaultLayer),
|
||||||
|
Layer.provide(Storage.defaultLayer),
|
||||||
|
Layer.provide(Bus.layer),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
|
export const summarize = (input: { sessionID: SessionID; messageID: MessageID }) =>
|
||||||
|
void runPromise((svc) => svc.summarize(input)).catch(() => {})
|
||||||
|
|
||||||
|
export const DiffInput = z.object({
|
||||||
|
sessionID: SessionID.zod,
|
||||||
|
messageID: MessageID.zod.optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export async function diff(input: z.infer<typeof DiffInput>) {
|
||||||
|
return runPromise((svc) => svc.diff(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function computeDiff(input: { messages: MessageV2.WithParts[] }) {
|
||||||
|
return runPromise((svc) => svc.computeDiff(input))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import PROMPT_DEFAULT from "./prompt/default.txt"
|
|||||||
import PROMPT_BEAST from "./prompt/beast.txt"
|
import PROMPT_BEAST from "./prompt/beast.txt"
|
||||||
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
import PROMPT_GEMINI from "./prompt/gemini.txt"
|
||||||
import PROMPT_GPT from "./prompt/gpt.txt"
|
import PROMPT_GPT from "./prompt/gpt.txt"
|
||||||
|
import PROMPT_KIMI from "./prompt/kimi.txt"
|
||||||
|
|
||||||
import PROMPT_CODEX from "./prompt/codex.txt"
|
import PROMPT_CODEX from "./prompt/codex.txt"
|
||||||
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
import PROMPT_TRINITY from "./prompt/trinity.txt"
|
||||||
@@ -28,6 +29,7 @@ export namespace SystemPrompt {
|
|||||||
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
|
if (model.api.id.includes("gemini-")) return [PROMPT_GEMINI]
|
||||||
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
|
if (model.api.id.includes("claude")) return [PROMPT_ANTHROPIC]
|
||||||
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
|
if (model.api.id.toLowerCase().includes("trinity")) return [PROMPT_TRINITY]
|
||||||
|
if (model.api.id.toLowerCase().includes("kimi")) return [PROMPT_KIMI]
|
||||||
return [PROMPT_DEFAULT]
|
return [PROMPT_DEFAULT]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { readFileSync, readdirSync, existsSync } from "fs"
|
import { readFileSync, readdirSync, existsSync } from "fs"
|
||||||
import { Installation } from "../installation"
|
|
||||||
import { Flag } from "../flag/flag"
|
import { Flag } from "../flag/flag"
|
||||||
|
import { CHANNEL } from "../installation/meta"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { init } from "#db"
|
import { init } from "#db"
|
||||||
|
|
||||||
@@ -28,10 +29,9 @@ const log = Log.create({ service: "db" })
|
|||||||
|
|
||||||
export namespace Database {
|
export namespace Database {
|
||||||
export function getChannelPath() {
|
export function getChannelPath() {
|
||||||
const channel = Installation.CHANNEL
|
if (["latest", "beta"].includes(CHANNEL) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
||||||
if (["latest", "beta"].includes(channel) || Flag.OPENCODE_DISABLE_CHANNEL_DB)
|
|
||||||
return path.join(Global.Path.data, "opencode.db")
|
return path.join(Global.Path.data, "opencode.db")
|
||||||
const safe = channel.replace(/[^a-zA-Z0-9._-]/g, "-")
|
const safe = CHANNEL.replace(/[^a-zA-Z0-9._-]/g, "-")
|
||||||
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
return path.join(Global.Path.data, `opencode-${safe}.db`)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,10 +142,11 @@ export namespace Database {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function effect(fn: () => any | Promise<any>) {
|
export function effect(fn: () => any | Promise<any>) {
|
||||||
|
const bound = InstanceState.bind(fn)
|
||||||
try {
|
try {
|
||||||
ctx.use().effects.push(fn)
|
ctx.use().effects.push(bound)
|
||||||
} catch {
|
} catch {
|
||||||
fn()
|
bound()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,12 +163,8 @@ export namespace Database {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof Context.NotFound) {
|
if (err instanceof Context.NotFound) {
|
||||||
const effects: (() => void | Promise<void>)[] = []
|
const effects: (() => void | Promise<void>)[] = []
|
||||||
const result = Client().transaction(
|
const txCallback = InstanceState.bind((tx: TxOrDb) => ctx.provide({ tx, effects }, () => callback(tx)))
|
||||||
(tx: TxOrDb) => {
|
const result = Client().transaction(txCallback, { behavior: options?.behavior })
|
||||||
return ctx.provide({ tx, effects }, () => callback(tx))
|
|
||||||
},
|
|
||||||
{ behavior: options?.behavior },
|
|
||||||
)
|
|
||||||
for (const effect of effects) effect()
|
for (const effect of effects) effect()
|
||||||
return result as NotPromise<T>
|
return result as NotPromise<T>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
import { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import fs from "fs/promises"
|
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { lazy } from "../util/lazy"
|
|
||||||
import { Lock } from "../util/lock"
|
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Glob } from "../util/glob"
|
|
||||||
import { git } from "@/util/git"
|
import { git } from "@/util/git"
|
||||||
|
import { AppFileSystem } from "@/filesystem"
|
||||||
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
|
import { Effect, Exit, Layer, Option, RcMap, Schema, ServiceMap, TxReentrantLock } from "effect"
|
||||||
|
|
||||||
export namespace Storage {
|
export namespace Storage {
|
||||||
const log = Log.create({ service: "storage" })
|
const log = Log.create({ service: "storage" })
|
||||||
|
|
||||||
type Migration = (dir: string) => Promise<void>
|
type Migration = (dir: string, fs: AppFileSystem.Interface) => Effect.Effect<void, AppFileSystem.Error>
|
||||||
|
|
||||||
export const NotFoundError = NamedError.create(
|
export const NotFoundError = NamedError.create(
|
||||||
"NotFoundError",
|
"NotFoundError",
|
||||||
@@ -22,36 +20,101 @@ export namespace Storage {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export type Error = AppFileSystem.Error | InstanceType<typeof NotFoundError>
|
||||||
|
|
||||||
|
const RootFile = Schema.Struct({
|
||||||
|
path: Schema.optional(
|
||||||
|
Schema.Struct({
|
||||||
|
root: Schema.optional(Schema.String),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
const SessionFile = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const MessageFile = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DiffFile = Schema.Struct({
|
||||||
|
additions: Schema.Number,
|
||||||
|
deletions: Schema.Number,
|
||||||
|
})
|
||||||
|
|
||||||
|
const SummaryFile = Schema.Struct({
|
||||||
|
id: Schema.String,
|
||||||
|
projectID: Schema.String,
|
||||||
|
summary: Schema.Struct({ diffs: Schema.Array(DiffFile) }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const decodeRoot = Schema.decodeUnknownOption(RootFile)
|
||||||
|
const decodeSession = Schema.decodeUnknownOption(SessionFile)
|
||||||
|
const decodeMessage = Schema.decodeUnknownOption(MessageFile)
|
||||||
|
const decodeSummary = Schema.decodeUnknownOption(SummaryFile)
|
||||||
|
|
||||||
|
export interface Interface {
|
||||||
|
readonly remove: (key: string[]) => Effect.Effect<void, AppFileSystem.Error>
|
||||||
|
readonly read: <T>(key: string[]) => Effect.Effect<T, Error>
|
||||||
|
readonly update: <T>(key: string[], fn: (draft: T) => void) => Effect.Effect<T, Error>
|
||||||
|
readonly write: <T>(key: string[], content: T) => Effect.Effect<void, AppFileSystem.Error>
|
||||||
|
readonly list: (prefix: string[]) => Effect.Effect<string[][], AppFileSystem.Error>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends ServiceMap.Service<Service, Interface>()("@opencode/Storage") {}
|
||||||
|
|
||||||
|
function file(dir: string, key: string[]) {
|
||||||
|
return path.join(dir, ...key) + ".json"
|
||||||
|
}
|
||||||
|
|
||||||
|
function missing(err: unknown) {
|
||||||
|
if (!err || typeof err !== "object") return false
|
||||||
|
if ("code" in err && err.code === "ENOENT") return true
|
||||||
|
if ("reason" in err && err.reason && typeof err.reason === "object" && "_tag" in err.reason) {
|
||||||
|
return err.reason._tag === "NotFound"
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMigration(text: string) {
|
||||||
|
const value = Number.parseInt(text, 10)
|
||||||
|
return Number.isNaN(value) ? 0 : value
|
||||||
|
}
|
||||||
|
|
||||||
const MIGRATIONS: Migration[] = [
|
const MIGRATIONS: Migration[] = [
|
||||||
async (dir) => {
|
Effect.fn("Storage.migration.1")(function* (dir: string, fs: AppFileSystem.Interface) {
|
||||||
const project = path.resolve(dir, "../project")
|
const project = path.resolve(dir, "../project")
|
||||||
if (!(await Filesystem.isDir(project))) return
|
if (!(yield* fs.isDir(project))) return
|
||||||
const projectDirs = await Glob.scan("*", {
|
const projectDirs = yield* fs.glob("*", {
|
||||||
cwd: project,
|
cwd: project,
|
||||||
include: "all",
|
include: "all",
|
||||||
})
|
})
|
||||||
for (const projectDir of projectDirs) {
|
for (const projectDir of projectDirs) {
|
||||||
const fullPath = path.join(project, projectDir)
|
const full = path.join(project, projectDir)
|
||||||
if (!(await Filesystem.isDir(fullPath))) continue
|
if (!(yield* fs.isDir(full))) continue
|
||||||
log.info(`migrating project ${projectDir}`)
|
log.info(`migrating project ${projectDir}`)
|
||||||
let projectID = projectDir
|
let projectID = projectDir
|
||||||
const fullProjectDir = path.join(project, projectDir)
|
|
||||||
let worktree = "/"
|
let worktree = "/"
|
||||||
|
|
||||||
if (projectID !== "global") {
|
if (projectID !== "global") {
|
||||||
for (const msgFile of await Glob.scan("storage/session/message/*/*.json", {
|
for (const msgFile of yield* fs.glob("storage/session/message/*/*.json", {
|
||||||
cwd: path.join(project, projectDir),
|
cwd: full,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const json = await Filesystem.readJson<any>(msgFile)
|
const json = decodeRoot(yield* fs.readJson(msgFile), { onExcessProperty: "preserve" })
|
||||||
worktree = json.path?.root
|
const root = Option.isSome(json) ? json.value.path?.root : undefined
|
||||||
if (worktree) break
|
if (!root) continue
|
||||||
|
worktree = root
|
||||||
|
break
|
||||||
}
|
}
|
||||||
if (!worktree) continue
|
if (!worktree) continue
|
||||||
if (!(await Filesystem.isDir(worktree))) continue
|
if (!(yield* fs.isDir(worktree))) continue
|
||||||
const result = await git(["rev-list", "--max-parents=0", "--all"], {
|
const result = yield* Effect.promise(() =>
|
||||||
cwd: worktree,
|
git(["rev-list", "--max-parents=0", "--all"], {
|
||||||
})
|
cwd: worktree,
|
||||||
|
}),
|
||||||
|
)
|
||||||
const [id] = result
|
const [id] = result
|
||||||
.text()
|
.text()
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -61,157 +124,230 @@ export namespace Storage {
|
|||||||
if (!id) continue
|
if (!id) continue
|
||||||
projectID = id
|
projectID = id
|
||||||
|
|
||||||
await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), {
|
yield* fs.writeWithDirs(
|
||||||
id,
|
path.join(dir, "project", projectID + ".json"),
|
||||||
vcs: "git",
|
JSON.stringify(
|
||||||
worktree,
|
{
|
||||||
time: {
|
id,
|
||||||
created: Date.now(),
|
vcs: "git",
|
||||||
initialized: Date.now(),
|
worktree,
|
||||||
},
|
time: {
|
||||||
})
|
created: Date.now(),
|
||||||
|
initialized: Date.now(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
log.info(`migrating sessions for project ${projectID}`)
|
log.info(`migrating sessions for project ${projectID}`)
|
||||||
for (const sessionFile of await Glob.scan("storage/session/info/*.json", {
|
for (const sessionFile of yield* fs.glob("storage/session/info/*.json", {
|
||||||
cwd: fullProjectDir,
|
cwd: full,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
|
const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
|
||||||
log.info("copying", {
|
log.info("copying", { sessionFile, dest })
|
||||||
sessionFile,
|
const session = yield* fs.readJson(sessionFile)
|
||||||
dest,
|
const info = decodeSession(session, { onExcessProperty: "preserve" })
|
||||||
})
|
yield* fs.writeWithDirs(dest, JSON.stringify(session, null, 2))
|
||||||
const session = await Filesystem.readJson<any>(sessionFile)
|
if (Option.isNone(info)) continue
|
||||||
await Filesystem.writeJson(dest, session)
|
log.info(`migrating messages for session ${info.value.id}`)
|
||||||
log.info(`migrating messages for session ${session.id}`)
|
for (const msgFile of yield* fs.glob(`storage/session/message/${info.value.id}/*.json`, {
|
||||||
for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, {
|
cwd: full,
|
||||||
cwd: fullProjectDir,
|
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const dest = path.join(dir, "message", session.id, path.basename(msgFile))
|
const next = path.join(dir, "message", info.value.id, path.basename(msgFile))
|
||||||
log.info("copying", {
|
log.info("copying", {
|
||||||
msgFile,
|
msgFile,
|
||||||
dest,
|
dest: next,
|
||||||
})
|
})
|
||||||
const message = await Filesystem.readJson<any>(msgFile)
|
const message = yield* fs.readJson(msgFile)
|
||||||
await Filesystem.writeJson(dest, message)
|
const item = decodeMessage(message, { onExcessProperty: "preserve" })
|
||||||
|
yield* fs.writeWithDirs(next, JSON.stringify(message, null, 2))
|
||||||
|
if (Option.isNone(item)) continue
|
||||||
|
|
||||||
log.info(`migrating parts for message ${message.id}`)
|
log.info(`migrating parts for message ${item.value.id}`)
|
||||||
for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, {
|
for (const partFile of yield* fs.glob(`storage/session/part/${info.value.id}/${item.value.id}/*.json`, {
|
||||||
cwd: fullProjectDir,
|
cwd: full,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const dest = path.join(dir, "part", message.id, path.basename(partFile))
|
const out = path.join(dir, "part", item.value.id, path.basename(partFile))
|
||||||
const part = await Filesystem.readJson(partFile)
|
const part = yield* fs.readJson(partFile)
|
||||||
log.info("copying", {
|
log.info("copying", {
|
||||||
partFile,
|
partFile,
|
||||||
dest,
|
dest: out,
|
||||||
})
|
})
|
||||||
await Filesystem.writeJson(dest, part)
|
yield* fs.writeWithDirs(out, JSON.stringify(part, null, 2))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
async (dir) => {
|
Effect.fn("Storage.migration.2")(function* (dir: string, fs: AppFileSystem.Interface) {
|
||||||
for (const item of await Glob.scan("session/*/*.json", {
|
for (const item of yield* fs.glob("session/*/*.json", {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
})) {
|
})) {
|
||||||
const session = await Filesystem.readJson<any>(item)
|
const raw = yield* fs.readJson(item)
|
||||||
if (!session.projectID) continue
|
const session = decodeSummary(raw, { onExcessProperty: "preserve" })
|
||||||
if (!session.summary?.diffs) continue
|
if (Option.isNone(session)) continue
|
||||||
const { diffs } = session.summary
|
const diffs = session.value.summary.diffs
|
||||||
await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs))
|
yield* fs.writeWithDirs(
|
||||||
await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), {
|
path.join(dir, "session_diff", session.value.id + ".json"),
|
||||||
...session,
|
JSON.stringify(diffs, null, 2),
|
||||||
summary: {
|
)
|
||||||
additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
|
yield* fs.writeWithDirs(
|
||||||
deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
|
path.join(dir, "session", session.value.projectID, session.value.id + ".json"),
|
||||||
},
|
JSON.stringify(
|
||||||
})
|
{
|
||||||
|
...(raw as Record<string, unknown>),
|
||||||
|
summary: {
|
||||||
|
additions: diffs.reduce((sum, x) => sum + x.additions, 0),
|
||||||
|
deletions: diffs.reduce((sum, x) => sum + x.deletions, 0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
]
|
]
|
||||||
|
|
||||||
const state = lazy(async () => {
|
export const layer = Layer.effect(
|
||||||
const dir = path.join(Global.Path.data, "storage")
|
Service,
|
||||||
const migration = await Filesystem.readJson<string>(path.join(dir, "migration"))
|
Effect.gen(function* () {
|
||||||
.then((x) => parseInt(x))
|
const fs = yield* AppFileSystem.Service
|
||||||
.catch(() => 0)
|
const locks = yield* RcMap.make({
|
||||||
for (let index = migration; index < MIGRATIONS.length; index++) {
|
lookup: () => TxReentrantLock.make(),
|
||||||
log.info("running migration", { index })
|
idleTimeToLive: 0,
|
||||||
const migration = MIGRATIONS[index]
|
})
|
||||||
await migration(dir).catch(() => log.error("failed to run migration", { index }))
|
const state = yield* Effect.cached(
|
||||||
await Filesystem.write(path.join(dir, "migration"), (index + 1).toString())
|
Effect.gen(function* () {
|
||||||
}
|
const dir = path.join(Global.Path.data, "storage")
|
||||||
return {
|
const marker = path.join(dir, "migration")
|
||||||
dir,
|
const migration = yield* fs.readFileString(marker).pipe(
|
||||||
}
|
Effect.map(parseMigration),
|
||||||
})
|
Effect.catchIf(missing, () => Effect.succeed(0)),
|
||||||
|
Effect.orElseSucceed(() => 0),
|
||||||
|
)
|
||||||
|
for (let i = migration; i < MIGRATIONS.length; i++) {
|
||||||
|
log.info("running migration", { index: i })
|
||||||
|
const step = MIGRATIONS[i]!
|
||||||
|
const exit = yield* Effect.exit(step(dir, fs))
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
log.error("failed to run migration", { index: i, cause: exit.cause })
|
||||||
|
break
|
||||||
|
}
|
||||||
|
yield* fs.writeWithDirs(marker, String(i + 1))
|
||||||
|
}
|
||||||
|
return { dir }
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const fail = (target: string): Effect.Effect<never, InstanceType<typeof NotFoundError>> =>
|
||||||
|
Effect.fail(new NotFoundError({ message: `Resource not found: ${target}` }))
|
||||||
|
|
||||||
|
const wrap = <A>(target: string, body: Effect.Effect<A, AppFileSystem.Error>) =>
|
||||||
|
body.pipe(Effect.catchIf(missing, () => fail(target)))
|
||||||
|
|
||||||
|
const writeJson = Effect.fnUntraced(function* (target: string, content: unknown) {
|
||||||
|
yield* fs.writeWithDirs(target, JSON.stringify(content, null, 2))
|
||||||
|
})
|
||||||
|
|
||||||
|
const withResolved = <A, E>(
|
||||||
|
key: string[],
|
||||||
|
fn: (target: string, rw: TxReentrantLock.TxReentrantLock) => Effect.Effect<A, E>,
|
||||||
|
): Effect.Effect<A, E | AppFileSystem.Error> =>
|
||||||
|
Effect.scoped(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const target = file((yield* state).dir, key)
|
||||||
|
return yield* fn(target, yield* RcMap.get(locks, target))
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const remove: Interface["remove"] = Effect.fn("Storage.remove")(function* (key: string[]) {
|
||||||
|
yield* withResolved(key, (target, rw) =>
|
||||||
|
TxReentrantLock.withWriteLock(rw, fs.remove(target).pipe(Effect.catchIf(missing, () => Effect.void))),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const read: Interface["read"] = <T>(key: string[]) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = yield* withResolved(key, (target, rw) =>
|
||||||
|
TxReentrantLock.withReadLock(rw, wrap(target, fs.readJson(target))),
|
||||||
|
)
|
||||||
|
return value as T
|
||||||
|
})
|
||||||
|
|
||||||
|
const update: Interface["update"] = <T>(key: string[], fn: (draft: T) => void) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const value = yield* withResolved(key, (target, rw) =>
|
||||||
|
TxReentrantLock.withWriteLock(
|
||||||
|
rw,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const content = yield* wrap(target, fs.readJson(target))
|
||||||
|
fn(content as T)
|
||||||
|
yield* writeJson(target, content)
|
||||||
|
return content
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return value as T
|
||||||
|
})
|
||||||
|
|
||||||
|
const write: Interface["write"] = (key: string[], content: unknown) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
yield* withResolved(key, (target, rw) => TxReentrantLock.withWriteLock(rw, writeJson(target, content)))
|
||||||
|
})
|
||||||
|
|
||||||
|
const list: Interface["list"] = Effect.fn("Storage.list")(function* (prefix: string[]) {
|
||||||
|
const dir = (yield* state).dir
|
||||||
|
const cwd = path.join(dir, ...prefix)
|
||||||
|
const result = yield* fs
|
||||||
|
.glob("**/*", {
|
||||||
|
cwd,
|
||||||
|
include: "file",
|
||||||
|
})
|
||||||
|
.pipe(Effect.catch(() => Effect.succeed<string[]>([])))
|
||||||
|
return result
|
||||||
|
.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])
|
||||||
|
.toSorted((a, b) => a.join("/").localeCompare(b.join("/")))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
remove,
|
||||||
|
read,
|
||||||
|
update,
|
||||||
|
write,
|
||||||
|
list,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|
||||||
export async function remove(key: string[]) {
|
export async function remove(key: string[]) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.remove(key))
|
||||||
const target = path.join(dir, ...key) + ".json"
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
await fs.unlink(target).catch(() => {})
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function read<T>(key: string[]) {
|
export async function read<T>(key: string[]) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.read<T>(key))
|
||||||
const target = path.join(dir, ...key) + ".json"
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
using _ = await Lock.read(target)
|
|
||||||
const result = await Filesystem.readJson<T>(target)
|
|
||||||
return result as T
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function update<T>(key: string[], fn: (draft: T) => void) {
|
export async function update<T>(key: string[], fn: (draft: T) => void) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.update<T>(key, fn))
|
||||||
const target = path.join(dir, ...key) + ".json"
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
using _ = await Lock.write(target)
|
|
||||||
const content = await Filesystem.readJson<T>(target)
|
|
||||||
fn(content as T)
|
|
||||||
await Filesystem.writeJson(target, content)
|
|
||||||
return content
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function write<T>(key: string[], content: T) {
|
export async function write<T>(key: string[], content: T) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.write(key, content))
|
||||||
const target = path.join(dir, ...key) + ".json"
|
|
||||||
return withErrorHandling(async () => {
|
|
||||||
using _ = await Lock.write(target)
|
|
||||||
await Filesystem.writeJson(target, content)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function withErrorHandling<T>(body: () => Promise<T>) {
|
|
||||||
return body().catch((e) => {
|
|
||||||
if (!(e instanceof Error)) throw e
|
|
||||||
const errnoException = e as NodeJS.ErrnoException
|
|
||||||
if (errnoException.code === "ENOENT") {
|
|
||||||
throw new NotFoundError({ message: `Resource not found: ${errnoException.path}` })
|
|
||||||
}
|
|
||||||
throw e
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function list(prefix: string[]) {
|
export async function list(prefix: string[]) {
|
||||||
const dir = await state().then((x) => x.dir)
|
return runPromise((svc) => svc.list(prefix))
|
||||||
try {
|
|
||||||
const result = await Glob.scan("**/*", {
|
|
||||||
cwd: path.join(dir, ...prefix),
|
|
||||||
include: "file",
|
|
||||||
}).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
|
|
||||||
result.sort()
|
|
||||||
return result
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
import { makeRuntime } from "@/effect/run-service"
|
||||||
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "@/effect/cross-spawn-spawner"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
|
||||||
export namespace Worktree {
|
export namespace Worktree {
|
||||||
const log = Log.create({ service: "worktree" })
|
const log = Log.create({ service: "worktree" })
|
||||||
@@ -199,6 +200,7 @@ export namespace Worktree {
|
|||||||
|
|
||||||
const MAX_NAME_ATTEMPTS = 26
|
const MAX_NAME_ATTEMPTS = 26
|
||||||
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
const candidate = Effect.fn("Worktree.candidate")(function* (root: string, base?: string) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
for (const attempt of Array.from({ length: MAX_NAME_ATTEMPTS }, (_, i) => i)) {
|
||||||
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
const name = base ? (attempt === 0 ? base : `${base}-${Slug.create()}`) : Slug.create()
|
||||||
const branch = `opencode/${name}`
|
const branch = `opencode/${name}`
|
||||||
@@ -207,7 +209,7 @@ export namespace Worktree {
|
|||||||
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
if (yield* fs.exists(directory).pipe(Effect.orDie)) continue
|
||||||
|
|
||||||
const ref = `refs/heads/${branch}`
|
const ref = `refs/heads/${branch}`
|
||||||
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: Instance.worktree })
|
const branchCheck = yield* git(["show-ref", "--verify", "--quiet", ref], { cwd: ctx.worktree })
|
||||||
if (branchCheck.code === 0) continue
|
if (branchCheck.code === 0) continue
|
||||||
|
|
||||||
return Info.parse({ name, branch, directory })
|
return Info.parse({ name, branch, directory })
|
||||||
@@ -216,11 +218,12 @@ export namespace Worktree {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
const makeWorktreeInfo = Effect.fn("Worktree.makeWorktreeInfo")(function* (name?: string) {
|
||||||
if (Instance.project.vcs !== "git") {
|
const ctx = yield* InstanceState.context
|
||||||
|
if (ctx.project.vcs !== "git") {
|
||||||
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
throw new NotGitError({ message: "Worktrees are only supported for git projects" })
|
||||||
}
|
}
|
||||||
|
|
||||||
const root = pathSvc.join(Global.Path.data, "worktree", Instance.project.id)
|
const root = pathSvc.join(Global.Path.data, "worktree", ctx.project.id)
|
||||||
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
yield* fs.makeDirectory(root, { recursive: true }).pipe(Effect.orDie)
|
||||||
|
|
||||||
const base = name ? slugify(name) : ""
|
const base = name ? slugify(name) : ""
|
||||||
@@ -228,18 +231,20 @@ export namespace Worktree {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const setup = Effect.fnUntraced(function* (info: Info) {
|
const setup = Effect.fnUntraced(function* (info: Info) {
|
||||||
|
const ctx = yield* InstanceState.context
|
||||||
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
const created = yield* git(["worktree", "add", "--no-checkout", "-b", info.branch, info.directory], {
|
||||||
cwd: Instance.worktree,
|
cwd: ctx.worktree,
|
||||||
})
|
})
|
||||||
if (created.code !== 0) {
|
if (created.code !== 0) {
|
||||||
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
throw new CreateFailedError({ message: created.stderr || created.text || "Failed to create git worktree" })
|
||||||
}
|
}
|
||||||
|
|
||||||
yield* project.addSandbox(Instance.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
yield* project.addSandbox(ctx.project.id, info.directory).pipe(Effect.catch(() => Effect.void))
|
||||||
})
|
})
|
||||||
|
|
||||||
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
const boot = Effect.fnUntraced(function* (info: Info, startCommand?: string) {
|
||||||
const projectID = Instance.project.id
|
const ctx = yield* InstanceState.context
|
||||||
|
const projectID = ctx.project.id
|
||||||
const extra = startCommand?.trim()
|
const extra = startCommand?.trim()
|
||||||
|
|
||||||
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
const populated = yield* git(["reset", "--hard"], { cwd: info.directory })
|
||||||
|
|||||||
@@ -16,21 +16,21 @@ const truncate = Layer.effectDiscard(
|
|||||||
|
|
||||||
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
const it = testEffect(Layer.merge(AccountRepo.layer, truncate))
|
||||||
|
|
||||||
it.effect("list returns empty when no accounts exist", () =>
|
it.live("list returns empty when no accounts exist", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const accounts = yield* AccountRepo.use((r) => r.list())
|
const accounts = yield* AccountRepo.use((r) => r.list())
|
||||||
expect(accounts).toEqual([])
|
expect(accounts).toEqual([])
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("active returns none when no accounts exist", () =>
|
it.live("active returns none when no accounts exist", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const active = yield* AccountRepo.use((r) => r.active())
|
const active = yield* AccountRepo.use((r) => r.active())
|
||||||
expect(Option.isNone(active)).toBe(true)
|
expect(Option.isNone(active)).toBe(true)
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistAccount inserts and getRow retrieves", () =>
|
it.live("persistAccount inserts and getRow retrieves", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
@@ -56,7 +56,7 @@ it.effect("persistAccount inserts and getRow retrieves", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistAccount sets the active account and org", () =>
|
it.live("persistAccount sets the active account and org", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -93,7 +93,7 @@ it.effect("persistAccount sets the active account and org", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("list returns all accounts", () =>
|
it.live("list returns all accounts", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -128,7 +128,7 @@ it.effect("list returns all accounts", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("remove deletes an account", () =>
|
it.live("remove deletes an account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -151,7 +151,7 @@ it.effect("remove deletes an account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("use stores the selected org and marks the account active", () =>
|
it.live("use stores the selected org and marks the account active", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id1 = AccountID.make("user-1")
|
const id1 = AccountID.make("user-1")
|
||||||
const id2 = AccountID.make("user-2")
|
const id2 = AccountID.make("user-2")
|
||||||
@@ -191,7 +191,7 @@ it.effect("use stores the selected org and marks the account active", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistToken updates token fields", () =>
|
it.live("persistToken updates token fields", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ it.effect("persistToken updates token fields", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistToken with no expiry sets token_expiry to null", () =>
|
it.live("persistToken with no expiry sets token_expiry to null", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -255,7 +255,7 @@ it.effect("persistToken with no expiry sets token_expiry to null", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("persistAccount upserts on conflict", () =>
|
it.live("persistAccount upserts on conflict", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -295,7 +295,7 @@ it.effect("persistAccount upserts on conflict", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("remove clears active state when deleting the active account", () =>
|
it.live("remove clears active state when deleting the active account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -318,7 +318,7 @@ it.effect("remove clears active state when deleting the active account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("getRow returns none for nonexistent account", () =>
|
it.live("getRow returns none for nonexistent account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
const row = yield* AccountRepo.use((r) => r.getRow(AccountID.make("nope")))
|
||||||
expect(Option.isNone(row)).toBe(true)
|
expect(Option.isNone(row)).toBe(true)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const deviceTokenClient = (body: unknown, status = 400) =>
|
|||||||
const poll = (body: unknown, status = 400) =>
|
const poll = (body: unknown, status = 400) =>
|
||||||
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
Account.Service.use((s) => s.poll(login())).pipe(Effect.provide(live(deviceTokenClient(body, status))))
|
||||||
|
|
||||||
it.effect("orgsByAccount groups orgs per account", () =>
|
it.live("orgsByAccount groups orgs per account", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
yield* AccountRepo.use((r) =>
|
yield* AccountRepo.use((r) =>
|
||||||
r.persistAccount({
|
r.persistAccount({
|
||||||
@@ -107,7 +107,7 @@ it.effect("orgsByAccount groups orgs per account", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("token refresh persists the new token", () =>
|
it.live("token refresh persists the new token", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ it.effect("token refresh persists the new token", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("config sends the selected org header", () =>
|
it.live("config sends the selected org header", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const id = AccountID.make("user-1")
|
const id = AccountID.make("user-1")
|
||||||
|
|
||||||
@@ -188,7 +188,7 @@ it.effect("config sends the selected org header", () =>
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("poll stores the account and first org on success", () =>
|
it.live("poll stores the account and first org on success", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const client = HttpClient.make((req) =>
|
const client = HttpClient.make((req) =>
|
||||||
Effect.succeed(
|
Effect.succeed(
|
||||||
@@ -259,7 +259,7 @@ for (const [name, body, expectedTag] of [
|
|||||||
"PollExpired",
|
"PollExpired",
|
||||||
],
|
],
|
||||||
] as const) {
|
] as const) {
|
||||||
it.effect(`poll returns ${name} for ${body.error}`, () =>
|
it.live(`poll returns ${name} for ${body.error}`, () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* poll(body)
|
const result = yield* poll(body)
|
||||||
expect(result._tag).toBe(expectedTag)
|
expect(result._tag).toBe(expectedTag)
|
||||||
@@ -267,7 +267,7 @@ for (const [name, body, expectedTag] of [
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
it.effect("poll returns poll error for other OAuth errors", () =>
|
it.live("poll returns poll error for other OAuth errors", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const result = yield* poll({
|
const result = yield* poll({
|
||||||
error: "server_error",
|
error: "server_error",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { BunProc } from "../src/bun"
|
||||||
|
import { PackageRegistry } from "../src/bun/registry"
|
||||||
|
import { Global } from "../src/global"
|
||||||
|
import { Process } from "../src/util/process"
|
||||||
|
|
||||||
describe("BunProc registry configuration", () => {
|
describe("BunProc registry configuration", () => {
|
||||||
test("should not contain hardcoded registry parameters", async () => {
|
test("should not contain hardcoded registry parameters", async () => {
|
||||||
@@ -51,3 +55,83 @@ describe("BunProc registry configuration", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("BunProc install pinning", () => {
|
||||||
|
test("uses pinned cache without touching registry", async () => {
|
||||||
|
const pkg = `pin-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const ver = "1.2.3"
|
||||||
|
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||||
|
const data = path.join(Global.Path.cache, "package.json")
|
||||||
|
|
||||||
|
await fs.mkdir(mod, { recursive: true })
|
||||||
|
await Bun.write(path.join(mod, "package.json"), JSON.stringify({ name: pkg, version: ver }, null, 2))
|
||||||
|
|
||||||
|
const src = await fs.readFile(data, "utf8").catch(() => "")
|
||||||
|
const json = src ? ((JSON.parse(src) as { dependencies?: Record<string, string> }) ?? {}) : {}
|
||||||
|
const deps = json.dependencies ?? {}
|
||||||
|
deps[pkg] = ver
|
||||||
|
await Bun.write(data, JSON.stringify({ ...json, dependencies: deps }, null, 2))
|
||||||
|
|
||||||
|
const stale = spyOn(PackageRegistry, "isOutdated").mockImplementation(async () => {
|
||||||
|
throw new Error("unexpected registry check")
|
||||||
|
})
|
||||||
|
const run = spyOn(Process, "run").mockImplementation(async () => {
|
||||||
|
throw new Error("unexpected process.run")
|
||||||
|
})
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = await BunProc.install(pkg, ver)
|
||||||
|
expect(out).toBe(mod)
|
||||||
|
expect(stale).not.toHaveBeenCalled()
|
||||||
|
expect(run).not.toHaveBeenCalled()
|
||||||
|
} finally {
|
||||||
|
stale.mockRestore()
|
||||||
|
run.mockRestore()
|
||||||
|
|
||||||
|
await fs.rm(mod, { recursive: true, force: true })
|
||||||
|
const end = await fs
|
||||||
|
.readFile(data, "utf8")
|
||||||
|
.then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (end?.dependencies) {
|
||||||
|
delete end.dependencies[pkg]
|
||||||
|
await Bun.write(data, JSON.stringify(end, null, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("passes --ignore-scripts when requested", async () => {
|
||||||
|
const pkg = `ignore-test-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
||||||
|
const ver = "4.5.6"
|
||||||
|
const mod = path.join(Global.Path.cache, "node_modules", pkg)
|
||||||
|
const data = path.join(Global.Path.cache, "package.json")
|
||||||
|
|
||||||
|
const run = spyOn(Process, "run").mockImplementation(async () => ({
|
||||||
|
code: 0,
|
||||||
|
stdout: Buffer.alloc(0),
|
||||||
|
stderr: Buffer.alloc(0),
|
||||||
|
}))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.rm(mod, { recursive: true, force: true })
|
||||||
|
await BunProc.install(pkg, ver, { ignoreScripts: true })
|
||||||
|
|
||||||
|
expect(run).toHaveBeenCalled()
|
||||||
|
const call = run.mock.calls[0]?.[0]
|
||||||
|
expect(call).toContain("--ignore-scripts")
|
||||||
|
expect(call).toContain(`${pkg}@${ver}`)
|
||||||
|
} finally {
|
||||||
|
run.mockRestore()
|
||||||
|
await fs.rm(mod, { recursive: true, force: true })
|
||||||
|
|
||||||
|
const end = await fs
|
||||||
|
.readFile(data, "utf8")
|
||||||
|
.then((item) => JSON.parse(item) as { dependencies?: Record<string, string> })
|
||||||
|
.catch(() => undefined)
|
||||||
|
if (end?.dependencies) {
|
||||||
|
delete end.dependencies[pkg]
|
||||||
|
await Bun.write(data, JSON.stringify(end, null, 2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ const live = Layer.mergeAll(Bus.layer, node)
|
|||||||
const it = testEffect(live)
|
const it = testEffect(live)
|
||||||
|
|
||||||
describe("Bus (Effect-native)", () => {
|
describe("Bus (Effect-native)", () => {
|
||||||
it.effect("publish + subscribe stream delivers events", () =>
|
it.live("publish + subscribe stream delivers events", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -46,7 +46,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("subscribe filters by event type", () =>
|
it.live("subscribe filters by event type", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -70,7 +70,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("subscribeAll receives all types", () =>
|
it.live("subscribeAll receives all types", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -95,7 +95,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("multiple subscribers each receive the event", () =>
|
it.live("multiple subscribers each receive the event", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const bus = yield* Bus.Service
|
const bus = yield* Bus.Service
|
||||||
@@ -129,7 +129,7 @@ describe("Bus (Effect-native)", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
it.live("subscribeAll stream sees InstanceDisposed on disposal", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const dir = yield* tmpdirScoped()
|
const dir = yield* tmpdirScoped()
|
||||||
const types: string[] = []
|
const types: string[] = []
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ test("installs plugin without loading it", async () => {
|
|||||||
{
|
{
|
||||||
name: "demo-install-plugin",
|
name: "demo-install-plugin",
|
||||||
type: "module",
|
type: "module",
|
||||||
main: "./install-plugin.ts",
|
exports: {
|
||||||
"oc-plugin": [["tui", { marker }]],
|
"./tui": {
|
||||||
|
import: "./install-plugin.ts",
|
||||||
|
config: { marker },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -46,7 +50,7 @@ test("installs plugin without loading it", async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
process.env.OPENCODE_PLUGIN_META_FILE = path.join(tmp.path, "plugin-meta.json")
|
||||||
let cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
const cfg: Awaited<ReturnType<typeof TuiConfig.get>> = {
|
||||||
plugin: [],
|
plugin: [],
|
||||||
plugin_records: undefined,
|
plugin_records: undefined,
|
||||||
}
|
}
|
||||||
@@ -66,17 +70,6 @@ test("installs plugin without loading it", async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(api)
|
await TuiPluginRuntime.init(api)
|
||||||
cfg = {
|
|
||||||
plugin: [[tmp.extra.spec, { marker: tmp.extra.marker }]],
|
|
||||||
plugin_records: [
|
|
||||||
{
|
|
||||||
item: [tmp.extra.spec, { marker: tmp.extra.marker }],
|
|
||||||
scope: "local",
|
|
||||||
source: path.join(tmp.path, "tui.json"),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
|
const out = await TuiPluginRuntime.installPlugin(tmp.extra.spec)
|
||||||
expect(out).toMatchObject({
|
expect(out).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -304,17 +304,23 @@ test("does not use npm package main for tui entry", async () => {
|
|||||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||||
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
const install = spyOn(BunProc, "install").mockResolvedValue(tmp.extra.mod)
|
||||||
|
const warn = spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
const error = spyOn(console, "error").mockImplementation(() => {})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await TuiPluginRuntime.init(createTuiPluginApi())
|
await TuiPluginRuntime.init(createTuiPluginApi())
|
||||||
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
await expect(fs.readFile(tmp.extra.marker, "utf8")).rejects.toThrow()
|
||||||
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
expect(TuiPluginRuntime.list().some((item) => item.spec === tmp.extra.spec)).toBe(false)
|
||||||
|
expect(error).not.toHaveBeenCalled()
|
||||||
|
expect(warn.mock.calls.some((call) => String(call[0]).includes("tui plugin has no entrypoint"))).toBe(true)
|
||||||
} finally {
|
} finally {
|
||||||
await TuiPluginRuntime.dispose()
|
await TuiPluginRuntime.dispose()
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
cwd.mockRestore()
|
cwd.mockRestore()
|
||||||
get.mockRestore()
|
get.mockRestore()
|
||||||
wait.mockRestore()
|
wait.mockRestore()
|
||||||
|
warn.mockRestore()
|
||||||
|
error.mockRestore()
|
||||||
delete process.env.OPENCODE_PLUGIN_META_FILE
|
delete process.env.OPENCODE_PLUGIN_META_FILE
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/** @jsxImportSource @opentui/solid */
|
||||||
|
import { expect, test } from "bun:test"
|
||||||
|
import { createSlot, createSolidSlotRegistry, testRender, useRenderer } from "@opentui/solid"
|
||||||
|
import { onMount } from "solid-js"
|
||||||
|
|
||||||
|
type Slots = {
|
||||||
|
prompt: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("replace slot mounts plugin content once", async () => {
|
||||||
|
let mounts = 0
|
||||||
|
|
||||||
|
const Probe = () => {
|
||||||
|
onMount(() => {
|
||||||
|
mounts += 1
|
||||||
|
})
|
||||||
|
|
||||||
|
return <box />
|
||||||
|
}
|
||||||
|
|
||||||
|
const App = () => {
|
||||||
|
const renderer = useRenderer()
|
||||||
|
const reg = createSolidSlotRegistry<Slots>(renderer, {})
|
||||||
|
const Slot = createSlot(reg)
|
||||||
|
|
||||||
|
reg.register({
|
||||||
|
id: "plugin",
|
||||||
|
slots: {
|
||||||
|
prompt() {
|
||||||
|
return <Probe />
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<box>
|
||||||
|
<Slot name="prompt" mode="replace">
|
||||||
|
<box />
|
||||||
|
</Slot>
|
||||||
|
</box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
await testRender(() => <App />)
|
||||||
|
|
||||||
|
expect(mounts).toBe(1)
|
||||||
|
})
|
||||||
@@ -792,6 +792,7 @@ test("installs dependencies in writable OPENCODE_CONFIG_DIR", async () => {
|
|||||||
|
|
||||||
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
expect(await Filesystem.exists(path.join(tmp.extra, "package.json"))).toBe(true)
|
||||||
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
expect(await Filesystem.exists(path.join(tmp.extra, ".gitignore"))).toBe(true)
|
||||||
|
expect(await Filesystem.readText(path.join(tmp.extra, ".gitignore"))).toContain("package-lock.json")
|
||||||
} finally {
|
} finally {
|
||||||
online.mockRestore()
|
online.mockRestore()
|
||||||
run.mockRestore()
|
run.mockRestore()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, expect, test } from "bun:test"
|
import { afterEach, expect, test } from "bun:test"
|
||||||
import { Duration, Effect, Layer, ManagedRuntime, ServiceMap } from "effect"
|
import { Cause, Deferred, Duration, Effect, Exit, Fiber, Layer, ManagedRuntime, ServiceMap } from "effect"
|
||||||
import { InstanceState } from "../../src/effect/instance-state"
|
import { InstanceState } from "../../src/effect/instance-state"
|
||||||
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
@@ -382,3 +383,100 @@ test("InstanceState dedupes concurrent lookups", async () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("InstanceState survives deferred resume from the same instance context", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
interface Api {
|
||||||
|
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Test extends ServiceMap.Service<Test, Api>()("@test/DeferredResume") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Test,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||||
|
|
||||||
|
return Test.of({
|
||||||
|
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
|
||||||
|
yield* Deferred.await(gate)
|
||||||
|
return yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(Test.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gate = await Effect.runPromise(Deferred.make<void>())
|
||||||
|
const fiber = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: () => Promise.resolve(rt.runFork(Test.use((svc) => svc.get(gate)))),
|
||||||
|
})
|
||||||
|
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: () => Effect.runPromise(Deferred.succeed(gate, void 0)),
|
||||||
|
})
|
||||||
|
const exit = await Effect.runPromise(Fiber.await(fiber))
|
||||||
|
|
||||||
|
expect(Exit.isSuccess(exit)).toBe(true)
|
||||||
|
if (Exit.isSuccess(exit)) {
|
||||||
|
expect(exit.value).toBe(tmp.path)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
test("InstanceState survives deferred resume outside ALS when InstanceRef is set", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
|
interface Api {
|
||||||
|
readonly get: (gate: Deferred.Deferred<void>) => Effect.Effect<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
class Test extends ServiceMap.Service<Test, Api>()("@test/DeferredResumeOutside") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
Test,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* InstanceState.make((ctx) => Effect.sync(() => ctx.directory))
|
||||||
|
|
||||||
|
return Test.of({
|
||||||
|
get: Effect.fn("Test.get")(function* (gate: Deferred.Deferred<void>) {
|
||||||
|
yield* Deferred.await(gate)
|
||||||
|
return yield* InstanceState.get(state)
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rt = ManagedRuntime.make(Test.layer)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gate = await Effect.runPromise(Deferred.make<void>())
|
||||||
|
// Provide InstanceRef so the fiber carries the context even when
|
||||||
|
// the deferred is resolved from outside Instance.provide ALS.
|
||||||
|
const fiber = await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: () =>
|
||||||
|
Promise.resolve(
|
||||||
|
rt.runFork(Test.use((svc) => svc.get(gate)).pipe(Effect.provideService(InstanceRef, Instance.current))),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Resume from outside any Instance.provide — ALS is NOT set here
|
||||||
|
await Effect.runPromise(Deferred.succeed(gate, void 0))
|
||||||
|
const exit = await Effect.runPromise(Fiber.await(fiber))
|
||||||
|
|
||||||
|
expect(Exit.isSuccess(exit)).toBe(true)
|
||||||
|
if (Exit.isSuccess(exit)) {
|
||||||
|
expect(exit.value).toBe(tmp.path)
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { it } from "../lib/effect"
|
|||||||
describe("Runner", () => {
|
describe("Runner", () => {
|
||||||
// --- ensureRunning semantics ---
|
// --- ensureRunning semantics ---
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"ensureRunning starts work and returns result",
|
"ensureRunning starts work and returns result",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -18,7 +18,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"ensureRunning propagates work failures",
|
"ensureRunning propagates work failures",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -29,7 +29,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"concurrent callers share the same run",
|
"concurrent callers share the same run",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -51,7 +51,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"concurrent callers all receive same error",
|
"concurrent callers all receive same error",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -71,7 +71,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"ensureRunning can be called again after previous run completes",
|
"ensureRunning can be called again after previous run completes",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -81,7 +81,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"second ensureRunning ignores new work if already running",
|
"second ensureRunning ignores new work if already running",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -110,7 +110,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- cancel semantics ---
|
// --- cancel semantics ---
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"cancel interrupts running work",
|
"cancel interrupts running work",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -128,7 +128,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"cancel on idle is a no-op",
|
"cancel on idle is a no-op",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -138,7 +138,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"cancel with onInterrupt resolves callers gracefully",
|
"cancel with onInterrupt resolves callers gracefully",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -154,7 +154,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"cancel with queued callers resolves all",
|
"cancel with queued callers resolves all",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -175,7 +175,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"work can be started after cancel",
|
"work can be started after cancel",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -245,7 +245,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- shell semantics ---
|
// --- shell semantics ---
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"shell runs exclusively",
|
"shell runs exclusively",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -256,7 +256,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"shell rejects when run is active",
|
"shell rejects when run is active",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -272,7 +272,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"shell rejects when another shell is running",
|
"shell rejects when another shell is running",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -292,7 +292,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"shell rejects via busy callback and cancel still stops the first shell",
|
"shell rejects via busy callback and cancel still stops the first shell",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -323,7 +323,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"cancel interrupts shell that ignores abort signal",
|
"cancel interrupts shell that ignores abort signal",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -349,7 +349,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- shell→run handoff ---
|
// --- shell→run handoff ---
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"ensureRunning queues behind shell then runs after",
|
"ensureRunning queues behind shell then runs after",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -376,7 +376,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"multiple ensureRunning callers share the queued run behind shell",
|
"multiple ensureRunning callers share the queued run behind shell",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -407,7 +407,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"cancel during shell_then_run cancels both",
|
"cancel during shell_then_run cancels both",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -441,7 +441,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- lifecycle callbacks ---
|
// --- lifecycle callbacks ---
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"onIdle fires when returning to idle from running",
|
"onIdle fires when returning to idle from running",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -454,7 +454,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"onIdle fires on cancel",
|
"onIdle fires on cancel",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -470,7 +470,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"onBusy fires when shell starts",
|
"onBusy fires when shell starts",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -485,7 +485,7 @@ describe("Runner", () => {
|
|||||||
|
|
||||||
// --- busy flag ---
|
// --- busy flag ---
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"busy is true during run",
|
"busy is true during run",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
@@ -502,7 +502,7 @@ describe("Runner", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect(
|
it.live(
|
||||||
"busy is true during shell",
|
"busy is true during shell",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const s = yield* Scope.Scope
|
const s = yield* Scope.Scope
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ import { $ } from "bun"
|
|||||||
import * as fs from "fs/promises"
|
import * as fs from "fs/promises"
|
||||||
import os from "os"
|
import os from "os"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect, FileSystem, ServiceMap } from "effect"
|
import { Effect, ServiceMap } from "effect"
|
||||||
|
import type * as PlatformError from "effect/PlatformError"
|
||||||
|
import type * as Scope from "effect/Scope"
|
||||||
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"
|
||||||
import type { Config } from "../../src/config/config"
|
import type { Config } from "../../src/config/config"
|
||||||
|
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { TestLLMServer } from "../lib/llm-server"
|
||||||
|
|
||||||
// Strip null bytes from paths (defensive fix for CI environment issues)
|
// Strip null bytes from paths (defensive fix for CI environment issues)
|
||||||
function sanitizePath(p: string): string {
|
function sanitizePath(p: string): string {
|
||||||
@@ -78,9 +82,17 @@ export async function tmpdir<T>(options?: TmpDirOptions<T>) {
|
|||||||
/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
|
/** Effectful scoped tmpdir. Cleaned up when the scope closes. Make sure these stay in sync */
|
||||||
export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
|
export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.Info> }) {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
|
||||||
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
||||||
const dir = yield* fs.makeTempDirectoryScoped({ prefix: "opencode-test-" })
|
const dirpath = sanitizePath(path.join(os.tmpdir(), "opencode-test-" + Math.random().toString(36).slice(2)))
|
||||||
|
yield* Effect.promise(() => fs.mkdir(dirpath, { recursive: true }))
|
||||||
|
const dir = sanitizePath(yield* Effect.promise(() => fs.realpath(dirpath)))
|
||||||
|
|
||||||
|
yield* Effect.addFinalizer(() =>
|
||||||
|
Effect.promise(async () => {
|
||||||
|
if (options?.git) await stop(dir).catch(() => undefined)
|
||||||
|
await clean(dir).catch(() => undefined)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
const git = (...args: string[]) =>
|
const git = (...args: string[]) =>
|
||||||
spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
|
spawner.spawn(ChildProcess.make("git", args, { cwd: dir })).pipe(Effect.flatMap((handle) => handle.exitCode))
|
||||||
@@ -94,9 +106,11 @@ export function tmpdirScoped(options?: { git?: boolean; config?: Partial<Config.
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (options?.config) {
|
if (options?.config) {
|
||||||
yield* fs.writeFileString(
|
yield* Effect.promise(() =>
|
||||||
path.join(dir, "opencode.json"),
|
fs.writeFile(
|
||||||
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...options.config }),
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ $schema: "https://opencode.ai/config.json", ...options.config }),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,7 +125,7 @@ export const provideInstance =
|
|||||||
Effect.promise<A>(async () =>
|
Effect.promise<A>(async () =>
|
||||||
Instance.provide({
|
Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
fn: () => Effect.runPromiseWith(services)(self),
|
fn: () => Effect.runPromiseWith(services)(self.pipe(Effect.provideService(InstanceRef, Instance.current))),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -139,3 +153,20 @@ export function provideTmpdirInstance<A, E, R>(
|
|||||||
return yield* self(path).pipe(provideInstance(path))
|
return yield* self(path).pipe(provideInstance(path))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function provideTmpdirServer<A, E, R>(
|
||||||
|
self: (input: { dir: string; llm: TestLLMServer["Service"] }) => Effect.Effect<A, E, R>,
|
||||||
|
options?: { git?: boolean; config?: (url: string) => Partial<Config.Info> },
|
||||||
|
): Effect.Effect<
|
||||||
|
A,
|
||||||
|
E | PlatformError.PlatformError,
|
||||||
|
R | TestLLMServer | ChildProcessSpawner.ChildProcessSpawner | Scope.Scope
|
||||||
|
> {
|
||||||
|
return Effect.gen(function* () {
|
||||||
|
const llm = yield* TestLLMServer
|
||||||
|
return yield* provideTmpdirInstance((dir) => self({ dir, llm }), {
|
||||||
|
git: options?.git,
|
||||||
|
config: options?.config?.(llm.url),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import * as Formatter from "../../src/format/formatter"
|
|||||||
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
const it = testEffect(Layer.mergeAll(Format.defaultLayer, CrossSpawnSpawner.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
describe("Format", () => {
|
describe("Format", () => {
|
||||||
it.effect("status() returns built-in formatters when no config overrides", () =>
|
it.live("status() returns built-in formatters when no config overrides", () =>
|
||||||
provideTmpdirInstance(() =>
|
provideTmpdirInstance(() =>
|
||||||
Format.Service.use((fmt) =>
|
Format.Service.use((fmt) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -32,7 +32,7 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("status() returns empty list when formatter is disabled", () =>
|
it.live("status() returns empty list when formatter is disabled", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Format.Service.use((fmt) =>
|
Format.Service.use((fmt) =>
|
||||||
@@ -44,7 +44,7 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("status() excludes formatters marked as disabled in config", () =>
|
it.live("status() excludes formatters marked as disabled in config", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
() =>
|
() =>
|
||||||
Format.Service.use((fmt) =>
|
Format.Service.use((fmt) =>
|
||||||
@@ -64,11 +64,9 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("service initializes without error", () =>
|
it.live("service initializes without error", () => provideTmpdirInstance(() => Format.Service.use(() => Effect.void)))
|
||||||
provideTmpdirInstance(() => Format.Service.use(() => Effect.void)),
|
|
||||||
)
|
|
||||||
|
|
||||||
it.effect("status() initializes formatter state per directory", () =>
|
it.live("status() initializes formatter state per directory", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
const a = yield* provideTmpdirInstance(() => Format.Service.use((fmt) => fmt.status()), {
|
||||||
config: { formatter: false },
|
config: { formatter: false },
|
||||||
@@ -80,7 +78,7 @@ describe("Format", () => {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("runs enabled checks for matching formatters in parallel", () =>
|
it.live("runs enabled checks for matching formatters in parallel", () =>
|
||||||
provideTmpdirInstance((path) =>
|
provideTmpdirInstance((path) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const file = `${path}/test.parallel`
|
const file = `${path}/test.parallel`
|
||||||
@@ -144,7 +142,7 @@ describe("Format", () => {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("runs matching formatters sequentially for the same file", () =>
|
it.live("runs matching formatters sequentially for the same file", () =>
|
||||||
provideTmpdirInstance(
|
provideTmpdirInstance(
|
||||||
(path) =>
|
(path) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { test, type TestOptions } from "bun:test"
|
import { test, type TestOptions } from "bun:test"
|
||||||
import { Cause, Effect, Exit, Layer } from "effect"
|
import { Cause, Effect, Exit, Layer } from "effect"
|
||||||
import type * as Scope from "effect/Scope"
|
import type * as Scope from "effect/Scope"
|
||||||
|
import * as TestClock from "effect/testing/TestClock"
|
||||||
import * as TestConsole from "effect/testing/TestConsole"
|
import * as TestConsole from "effect/testing/TestConsole"
|
||||||
|
|
||||||
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
||||||
const env = TestConsole.layer
|
|
||||||
|
|
||||||
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
||||||
|
|
||||||
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2, never>) =>
|
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
||||||
if (Exit.isFailure(exit)) {
|
if (Exit.isFailure(exit)) {
|
||||||
@@ -19,19 +19,35 @@ const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer
|
|||||||
return yield* exit
|
return yield* exit
|
||||||
}).pipe(Effect.runPromise)
|
}).pipe(Effect.runPromise)
|
||||||
|
|
||||||
const make = <R, E>(layer: Layer.Layer<R, E, never>) => {
|
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
|
||||||
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
test(name, () => run(value, layer), opts)
|
test(name, () => run(value, testLayer), opts)
|
||||||
|
|
||||||
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
test.only(name, () => run(value, layer), opts)
|
test.only(name, () => run(value, testLayer), opts)
|
||||||
|
|
||||||
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
test.skip(name, () => run(value, layer), opts)
|
test.skip(name, () => run(value, testLayer), opts)
|
||||||
|
|
||||||
return { effect }
|
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
|
test(name, () => run(value, liveLayer), opts)
|
||||||
|
|
||||||
|
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
|
test.only(name, () => run(value, liveLayer), opts)
|
||||||
|
|
||||||
|
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
||||||
|
test.skip(name, () => run(value, liveLayer), opts)
|
||||||
|
|
||||||
|
return { effect, live }
|
||||||
}
|
}
|
||||||
|
|
||||||
export const it = make(env)
|
// Test environment with TestClock and TestConsole
|
||||||
|
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
||||||
|
|
||||||
export const testEffect = <R, E>(layer: Layer.Layer<R, E, never>) => make(Layer.provideMerge(layer, env))
|
// Live environment - uses real clock, but keeps TestConsole for output capture
|
||||||
|
const liveEnv = TestConsole.layer
|
||||||
|
|
||||||
|
export const it = make(testEnv, liveEnv)
|
||||||
|
|
||||||
|
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
|
||||||
|
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { NodeHttpServer } from "@effect/platform-node"
|
||||||
|
import * as Http from "node:http"
|
||||||
|
import { Deferred, Effect, Layer, ServiceMap, Stream } from "effect"
|
||||||
|
import * as HttpServer from "effect/unstable/http/HttpServer"
|
||||||
|
import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
|
|
||||||
|
type Step =
|
||||||
|
| {
|
||||||
|
type: "text"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "tool"
|
||||||
|
tool: string
|
||||||
|
input: unknown
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "fail"
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "hang"
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "hold"
|
||||||
|
text: string
|
||||||
|
wait: PromiseLike<unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Hit = {
|
||||||
|
url: URL
|
||||||
|
body: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
type Wait = {
|
||||||
|
count: number
|
||||||
|
ready: Deferred.Deferred<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
function sse(lines: unknown[]) {
|
||||||
|
return HttpServerResponse.stream(
|
||||||
|
Stream.fromIterable([
|
||||||
|
[...lines.map((line) => `data: ${JSON.stringify(line)}`), "data: [DONE]"].join("\n\n") + "\n\n",
|
||||||
|
]).pipe(Stream.encodeText),
|
||||||
|
{ contentType: "text/event-stream" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(step: Extract<Step, { type: "text" }>) {
|
||||||
|
return sse([
|
||||||
|
{
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ delta: { role: "assistant" } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ delta: { content: step.text } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function tool(step: Extract<Step, { type: "tool" }>, seq: number) {
|
||||||
|
const id = `call_${seq}`
|
||||||
|
const args = JSON.stringify(step.input)
|
||||||
|
return sse([
|
||||||
|
{
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ delta: { role: "assistant" } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
id,
|
||||||
|
type: "function",
|
||||||
|
function: {
|
||||||
|
name: step.tool,
|
||||||
|
arguments: "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [
|
||||||
|
{
|
||||||
|
delta: {
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
index: 0,
|
||||||
|
function: {
|
||||||
|
arguments: args,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ delta: {}, finish_reason: "tool_calls" }],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(step: Extract<Step, { type: "fail" }>) {
|
||||||
|
return HttpServerResponse.stream(
|
||||||
|
Stream.fromIterable([
|
||||||
|
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
||||||
|
]).pipe(Stream.encodeText, Stream.concat(Stream.fail(new Error(step.message)))),
|
||||||
|
{ contentType: "text/event-stream" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hang() {
|
||||||
|
return HttpServerResponse.stream(
|
||||||
|
Stream.fromIterable([
|
||||||
|
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
||||||
|
]).pipe(Stream.encodeText, Stream.concat(Stream.never)),
|
||||||
|
{ contentType: "text/event-stream" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hold(step: Extract<Step, { type: "hold" }>) {
|
||||||
|
return HttpServerResponse.stream(
|
||||||
|
Stream.fromIterable([
|
||||||
|
'data: {"id":"chatcmpl-test","object":"chat.completion.chunk","choices":[{"delta":{"role":"assistant"}}]}\n\n',
|
||||||
|
]).pipe(
|
||||||
|
Stream.encodeText,
|
||||||
|
Stream.concat(
|
||||||
|
Stream.fromEffect(Effect.promise(() => step.wait)).pipe(
|
||||||
|
Stream.flatMap(() =>
|
||||||
|
Stream.fromIterable([
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ delta: { content: step.text } }],
|
||||||
|
})}\n\n`,
|
||||||
|
`data: ${JSON.stringify({
|
||||||
|
id: "chatcmpl-test",
|
||||||
|
object: "chat.completion.chunk",
|
||||||
|
choices: [{ delta: {}, finish_reason: "stop" }],
|
||||||
|
})}\n\n`,
|
||||||
|
"data: [DONE]\n\n",
|
||||||
|
]).pipe(Stream.encodeText),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
{ contentType: "text/event-stream" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace TestLLMServer {
|
||||||
|
export interface Service {
|
||||||
|
readonly url: string
|
||||||
|
readonly text: (value: string) => Effect.Effect<void>
|
||||||
|
readonly tool: (tool: string, input: unknown) => Effect.Effect<void>
|
||||||
|
readonly fail: (message?: string) => Effect.Effect<void>
|
||||||
|
readonly hang: Effect.Effect<void>
|
||||||
|
readonly hold: (text: string, wait: PromiseLike<unknown>) => Effect.Effect<void>
|
||||||
|
readonly hits: Effect.Effect<Hit[]>
|
||||||
|
readonly calls: Effect.Effect<number>
|
||||||
|
readonly wait: (count: number) => Effect.Effect<void>
|
||||||
|
readonly inputs: Effect.Effect<Record<string, unknown>[]>
|
||||||
|
readonly pending: Effect.Effect<number>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TestLLMServer extends ServiceMap.Service<TestLLMServer, TestLLMServer.Service>()("@test/LLMServer") {
|
||||||
|
static readonly layer = Layer.effect(
|
||||||
|
TestLLMServer,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const server = yield* HttpServer.HttpServer
|
||||||
|
const router = yield* HttpRouter.HttpRouter
|
||||||
|
|
||||||
|
let hits: Hit[] = []
|
||||||
|
let list: Step[] = []
|
||||||
|
let seq = 0
|
||||||
|
let waits: Wait[] = []
|
||||||
|
|
||||||
|
const push = (step: Step) => {
|
||||||
|
list = [...list, step]
|
||||||
|
}
|
||||||
|
|
||||||
|
const notify = Effect.fnUntraced(function* () {
|
||||||
|
const ready = waits.filter((item) => hits.length >= item.count)
|
||||||
|
if (!ready.length) return
|
||||||
|
waits = waits.filter((item) => hits.length < item.count)
|
||||||
|
yield* Effect.forEach(ready, (item) => Deferred.succeed(item.ready, void 0))
|
||||||
|
})
|
||||||
|
|
||||||
|
const pull = () => {
|
||||||
|
const step = list[0]
|
||||||
|
if (!step) return { step: undefined, seq }
|
||||||
|
seq += 1
|
||||||
|
list = list.slice(1)
|
||||||
|
return { step, seq }
|
||||||
|
}
|
||||||
|
|
||||||
|
yield* router.add(
|
||||||
|
"POST",
|
||||||
|
"/v1/chat/completions",
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const req = yield* HttpServerRequest.HttpServerRequest
|
||||||
|
const next = pull()
|
||||||
|
if (!next.step) return HttpServerResponse.text("unexpected request", { status: 500 })
|
||||||
|
const json = yield* req.json.pipe(Effect.orElseSucceed(() => ({})))
|
||||||
|
hits = [
|
||||||
|
...hits,
|
||||||
|
{
|
||||||
|
url: new URL(req.originalUrl, "http://localhost"),
|
||||||
|
body: json && typeof json === "object" ? (json as Record<string, unknown>) : {},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
yield* notify()
|
||||||
|
if (next.step.type === "text") return text(next.step)
|
||||||
|
if (next.step.type === "tool") return tool(next.step, next.seq)
|
||||||
|
if (next.step.type === "fail") return fail(next.step)
|
||||||
|
if (next.step.type === "hang") return hang()
|
||||||
|
return hold(next.step)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
yield* server.serve(router.asHttpEffect())
|
||||||
|
|
||||||
|
return TestLLMServer.of({
|
||||||
|
url:
|
||||||
|
server.address._tag === "TcpAddress"
|
||||||
|
? `http://127.0.0.1:${server.address.port}/v1`
|
||||||
|
: `unix://${server.address.path}/v1`,
|
||||||
|
text: Effect.fn("TestLLMServer.text")(function* (value: string) {
|
||||||
|
push({ type: "text", text: value })
|
||||||
|
}),
|
||||||
|
tool: Effect.fn("TestLLMServer.tool")(function* (tool: string, input: unknown) {
|
||||||
|
push({ type: "tool", tool, input })
|
||||||
|
}),
|
||||||
|
fail: Effect.fn("TestLLMServer.fail")(function* (message = "boom") {
|
||||||
|
push({ type: "fail", message })
|
||||||
|
}),
|
||||||
|
hang: Effect.gen(function* () {
|
||||||
|
push({ type: "hang" })
|
||||||
|
}).pipe(Effect.withSpan("TestLLMServer.hang")),
|
||||||
|
hold: Effect.fn("TestLLMServer.hold")(function* (text: string, wait: PromiseLike<unknown>) {
|
||||||
|
push({ type: "hold", text, wait })
|
||||||
|
}),
|
||||||
|
hits: Effect.sync(() => [...hits]),
|
||||||
|
calls: Effect.sync(() => hits.length),
|
||||||
|
wait: Effect.fn("TestLLMServer.wait")(function* (count: number) {
|
||||||
|
if (hits.length >= count) return
|
||||||
|
const ready = yield* Deferred.make<void>()
|
||||||
|
waits = [...waits, { count, ready }]
|
||||||
|
yield* Deferred.await(ready)
|
||||||
|
}),
|
||||||
|
inputs: Effect.sync(() => hits.map((hit) => hit.body)),
|
||||||
|
pending: Effect.sync(() => list.length),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Layer.provide(HttpRouter.layer), //
|
||||||
|
Layer.provide(NodeHttpServer.layer(() => Http.createServer(), { port: 0 })),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -25,6 +25,11 @@ function run(msg: Msg) {
|
|||||||
|
|
||||||
async function plugin(dir: string, kinds: Array<"server" | "tui">) {
|
async function plugin(dir: string, kinds: Array<"server" | "tui">) {
|
||||||
const p = path.join(dir, "plugin")
|
const p = path.join(dir, "plugin")
|
||||||
|
const server = kinds.includes("server")
|
||||||
|
const tui = kinds.includes("tui")
|
||||||
|
const exports: Record<string, string> = {}
|
||||||
|
if (server) exports["./server"] = "./server.js"
|
||||||
|
if (tui) exports["./tui"] = "./tui.js"
|
||||||
await fs.mkdir(p, { recursive: true })
|
await fs.mkdir(p, { recursive: true })
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(p, "package.json"),
|
path.join(p, "package.json"),
|
||||||
@@ -32,7 +37,8 @@ async function plugin(dir: string, kinds: Array<"server" | "tui">) {
|
|||||||
{
|
{
|
||||||
name: "acme",
|
name: "acme",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
"oc-plugin": kinds,
|
...(server ? { main: "./server.js" } : {}),
|
||||||
|
...(Object.keys(exports).length ? { exports } : {}),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
|
|||||||
@@ -55,8 +55,34 @@ function ctxRoot(dir: string): PlugCtx {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function plugin(dir: string, kinds?: unknown) {
|
async function plugin(
|
||||||
|
dir: string,
|
||||||
|
kinds?: Array<"server" | "tui">,
|
||||||
|
opts?: {
|
||||||
|
server?: Record<string, unknown>
|
||||||
|
tui?: Record<string, unknown>
|
||||||
|
},
|
||||||
|
) {
|
||||||
const p = path.join(dir, "plugin")
|
const p = path.join(dir, "plugin")
|
||||||
|
const server = kinds?.includes("server") ?? false
|
||||||
|
const tui = kinds?.includes("tui") ?? false
|
||||||
|
const exports: Record<string, unknown> = {}
|
||||||
|
if (server) {
|
||||||
|
exports["./server"] = opts?.server
|
||||||
|
? {
|
||||||
|
import: "./server.js",
|
||||||
|
config: opts.server,
|
||||||
|
}
|
||||||
|
: "./server.js"
|
||||||
|
}
|
||||||
|
if (tui) {
|
||||||
|
exports["./tui"] = opts?.tui
|
||||||
|
? {
|
||||||
|
import: "./tui.js",
|
||||||
|
config: opts.tui,
|
||||||
|
}
|
||||||
|
: "./tui.js"
|
||||||
|
}
|
||||||
await fs.mkdir(p, { recursive: true })
|
await fs.mkdir(p, { recursive: true })
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(p, "package.json"),
|
path.join(p, "package.json"),
|
||||||
@@ -64,7 +90,8 @@ async function plugin(dir: string, kinds?: unknown) {
|
|||||||
{
|
{
|
||||||
name: "acme",
|
name: "acme",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
...(kinds === undefined ? {} : { "oc-plugin": kinds }),
|
...(server ? { main: "./server.js" } : {}),
|
||||||
|
...(Object.keys(exports).length ? { exports } : {}),
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
2,
|
2,
|
||||||
@@ -99,12 +126,12 @@ describe("plugin.install.task", () => {
|
|||||||
expect(tui.plugin).toEqual(["acme@1.2.3"])
|
expect(tui.plugin).toEqual(["acme@1.2.3"])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("writes default options from tuple manifest targets", async () => {
|
test("writes default options from exports config metadata", async () => {
|
||||||
await using tmp = await tmpdir()
|
await using tmp = await tmpdir()
|
||||||
const target = await plugin(tmp.path, [
|
const target = await plugin(tmp.path, ["server", "tui"], {
|
||||||
["server", { custom: true, other: false }],
|
server: { custom: true, other: false },
|
||||||
["tui", { compact: true }],
|
tui: { compact: true },
|
||||||
])
|
})
|
||||||
const run = createPlugTask(
|
const run = createPlugTask(
|
||||||
{
|
{
|
||||||
mod: "acme@1.2.3",
|
mod: "acme@1.2.3",
|
||||||
|
|||||||
@@ -266,8 +266,8 @@ describe("plugin.loader.shared", () => {
|
|||||||
try {
|
try {
|
||||||
await load(tmp.path)
|
await load(tmp.path)
|
||||||
|
|
||||||
expect(install.mock.calls).toContainEqual(["acme-plugin", "latest"])
|
expect(install.mock.calls).toContainEqual(["acme-plugin", "latest", { ignoreScripts: true }])
|
||||||
expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4"])
|
expect(install.mock.calls).toContainEqual(["scope-plugin", "2.3.4", { ignoreScripts: true }])
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
@@ -487,7 +487,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes('exports["./server"]') && x.includes("package.json main"))).toBe(true)
|
expect(errors).toHaveLength(0)
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1557,6 +1557,35 @@ describe("ProviderTransform.message - providerOptions key remapping", () => {
|
|||||||
expect(result[0].providerOptions?.openai).toBeUndefined()
|
expect(result[0].providerOptions?.openai).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("azure cognitive services remaps providerID to 'azure' key", () => {
|
||||||
|
const model = createModel("azure-cognitive-services", "@ai-sdk/azure")
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "text",
|
||||||
|
text: "Hello",
|
||||||
|
providerOptions: {
|
||||||
|
"azure-cognitive-services": { part: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
providerOptions: {
|
||||||
|
"azure-cognitive-services": { someOption: "value" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, model, {}) as any[]
|
||||||
|
const part = result[0].content[0] as any
|
||||||
|
|
||||||
|
expect(result[0].providerOptions?.azure).toEqual({ someOption: "value" })
|
||||||
|
expect(result[0].providerOptions?.["azure-cognitive-services"]).toBeUndefined()
|
||||||
|
expect(part.providerOptions?.azure).toEqual({ part: true })
|
||||||
|
expect(part.providerOptions?.["azure-cognitive-services"]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
test("copilot remaps providerID to 'copilot' key", () => {
|
test("copilot remaps providerID to 'copilot' key", () => {
|
||||||
const model = createModel("github-copilot", "@ai-sdk/github-copilot")
|
const model = createModel("github-copilot", "@ai-sdk/github-copilot")
|
||||||
const msgs = [
|
const msgs = [
|
||||||
@@ -1763,6 +1792,58 @@ describe("ProviderTransform.message - cache control on gateway", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("google-vertex-anthropic applies cache control", () => {
|
||||||
|
const model = createModel({
|
||||||
|
providerID: "google-vertex-anthropic",
|
||||||
|
api: {
|
||||||
|
id: "google-vertex-anthropic",
|
||||||
|
url: "https://us-central1-aiplatform.googleapis.com",
|
||||||
|
npm: "@ai-sdk/google-vertex/anthropic",
|
||||||
|
},
|
||||||
|
id: "claude-sonnet-4@20250514",
|
||||||
|
})
|
||||||
|
const msgs = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "You are a helpful assistant",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: "Hello",
|
||||||
|
},
|
||||||
|
] as any[]
|
||||||
|
|
||||||
|
const result = ProviderTransform.message(msgs, model, {}) as any[]
|
||||||
|
|
||||||
|
expect(result[0].providerOptions).toEqual({
|
||||||
|
anthropic: {
|
||||||
|
cacheControl: {
|
||||||
|
type: "ephemeral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openrouter: {
|
||||||
|
cacheControl: {
|
||||||
|
type: "ephemeral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
bedrock: {
|
||||||
|
cachePoint: {
|
||||||
|
type: "default",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
openaiCompatible: {
|
||||||
|
cache_control: {
|
||||||
|
type: "ephemeral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
copilot: {
|
||||||
|
copilot_cache_control: {
|
||||||
|
type: "ephemeral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.variants", () => {
|
describe("ProviderTransform.variants", () => {
|
||||||
|
|||||||
@@ -13,6 +13,18 @@ afterEach(async () => {
|
|||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function withoutWatcher<T>(fn: () => Promise<T>) {
|
||||||
|
if (process.platform !== "win32") return fn()
|
||||||
|
const prev = process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||||
|
process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = "true"
|
||||||
|
try {
|
||||||
|
return await fn()
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER
|
||||||
|
else process.env.OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER = prev
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
|
async function fill(sessionID: SessionID, count: number, time = (i: number) => Date.now() + i) {
|
||||||
const ids = [] as MessageID[]
|
const ids = [] as MessageID[]
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
@@ -42,86 +54,94 @@ async function fill(sessionID: SessionID, count: number, time = (i: number) => D
|
|||||||
describe("session messages endpoint", () => {
|
describe("session messages endpoint", () => {
|
||||||
test("returns cursor headers for older pages", async () => {
|
test("returns cursor headers for older pages", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
directory: tmp.path,
|
Instance.provide({
|
||||||
fn: async () => {
|
directory: tmp.path,
|
||||||
const session = await Session.create({})
|
fn: async () => {
|
||||||
const ids = await fill(session.id, 5)
|
const session = await Session.create({})
|
||||||
const app = Server.Default()
|
const ids = await fill(session.id, 5)
|
||||||
|
const app = Server.Default()
|
||||||
|
|
||||||
const a = await app.request(`/session/${session.id}/message?limit=2`)
|
const a = await app.request(`/session/${session.id}/message?limit=2`)
|
||||||
expect(a.status).toBe(200)
|
expect(a.status).toBe(200)
|
||||||
const aBody = (await a.json()) as MessageV2.WithParts[]
|
const aBody = (await a.json()) as MessageV2.WithParts[]
|
||||||
expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
|
expect(aBody.map((item) => item.info.id)).toEqual(ids.slice(-2))
|
||||||
const cursor = a.headers.get("x-next-cursor")
|
const cursor = a.headers.get("x-next-cursor")
|
||||||
expect(cursor).toBeTruthy()
|
expect(cursor).toBeTruthy()
|
||||||
expect(a.headers.get("link")).toContain('rel="next"')
|
expect(a.headers.get("link")).toContain('rel="next"')
|
||||||
|
|
||||||
const b = await app.request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
|
const b = await app.request(`/session/${session.id}/message?limit=2&before=${encodeURIComponent(cursor!)}`)
|
||||||
expect(b.status).toBe(200)
|
expect(b.status).toBe(200)
|
||||||
const bBody = (await b.json()) as MessageV2.WithParts[]
|
const bBody = (await b.json()) as MessageV2.WithParts[]
|
||||||
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
|
expect(bBody.map((item) => item.info.id)).toEqual(ids.slice(-4, -2))
|
||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("keeps full-history responses when limit is omitted", async () => {
|
test("keeps full-history responses when limit is omitted", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
directory: tmp.path,
|
Instance.provide({
|
||||||
fn: async () => {
|
directory: tmp.path,
|
||||||
const session = await Session.create({})
|
fn: async () => {
|
||||||
const ids = await fill(session.id, 3)
|
const session = await Session.create({})
|
||||||
const app = Server.Default()
|
const ids = await fill(session.id, 3)
|
||||||
|
const app = Server.Default()
|
||||||
|
|
||||||
const res = await app.request(`/session/${session.id}/message`)
|
const res = await app.request(`/session/${session.id}/message`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as MessageV2.WithParts[]
|
const body = (await res.json()) as MessageV2.WithParts[]
|
||||||
expect(body.map((item) => item.info.id)).toEqual(ids)
|
expect(body.map((item) => item.info.id)).toEqual(ids)
|
||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects invalid cursors and missing sessions", async () => {
|
test("rejects invalid cursors and missing sessions", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
directory: tmp.path,
|
Instance.provide({
|
||||||
fn: async () => {
|
directory: tmp.path,
|
||||||
const session = await Session.create({})
|
fn: async () => {
|
||||||
const app = Server.Default()
|
const session = await Session.create({})
|
||||||
|
const app = Server.Default()
|
||||||
|
|
||||||
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
|
const bad = await app.request(`/session/${session.id}/message?limit=2&before=bad`)
|
||||||
expect(bad.status).toBe(400)
|
expect(bad.status).toBe(400)
|
||||||
|
|
||||||
const miss = await app.request(`/session/ses_missing/message?limit=2`)
|
const miss = await app.request(`/session/ses_missing/message?limit=2`)
|
||||||
expect(miss.status).toBe(404)
|
expect(miss.status).toBe(404)
|
||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("does not truncate large legacy limit requests", async () => {
|
test("does not truncate large legacy limit requests", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
await Instance.provide({
|
await withoutWatcher(() =>
|
||||||
directory: tmp.path,
|
Instance.provide({
|
||||||
fn: async () => {
|
directory: tmp.path,
|
||||||
const session = await Session.create({})
|
fn: async () => {
|
||||||
await fill(session.id, 520)
|
const session = await Session.create({})
|
||||||
const app = Server.Default()
|
await fill(session.id, 520)
|
||||||
|
const app = Server.Default()
|
||||||
|
|
||||||
const res = await app.request(`/session/${session.id}/message?limit=510`)
|
const res = await app.request(`/session/${session.id}/message?limit=510`)
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
const body = (await res.json()) as MessageV2.WithParts[]
|
const body = (await res.json()) as MessageV2.WithParts[]
|
||||||
expect(body).toHaveLength(510)
|
expect(body).toHaveLength(510)
|
||||||
|
|
||||||
await Session.remove(session.id)
|
await Session.remove(session.id)
|
||||||
},
|
},
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1199,4 +1199,26 @@ describe("session.getUsage", () => {
|
|||||||
expect(result.tokens.total).toBe(1500)
|
expect(result.tokens.total).toBe(1500)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
test("extracts cache write tokens from vertex metadata key", () => {
|
||||||
|
const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" })
|
||||||
|
const result = Session.getUsage({
|
||||||
|
model,
|
||||||
|
usage: {
|
||||||
|
inputTokens: 1000,
|
||||||
|
outputTokens: 500,
|
||||||
|
totalTokens: 1500,
|
||||||
|
cachedInputTokens: 200,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
vertex: {
|
||||||
|
cacheCreationInputTokens: 300,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.tokens.input).toBe(500)
|
||||||
|
expect(result.tokens.cache.read).toBe(200)
|
||||||
|
expect(result.tokens.cache.write).toBe(300)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -264,7 +264,7 @@ const env = SessionProcessor.layer.pipe(Layer.provideMerge(deps))
|
|||||||
|
|
||||||
const it = testEffect(env)
|
const it = testEffect(env)
|
||||||
|
|
||||||
it.effect("session.processor effect tests capture llm input cleanly", () => {
|
it.live("session.processor effect tests capture llm input cleanly", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -316,7 +316,7 @@ it.effect("session.processor effect tests capture llm input cleanly", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests stop after token overflow requests compaction", () => {
|
it.live("session.processor effect tests stop after token overflow requests compaction", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -376,7 +376,7 @@ it.effect("session.processor effect tests stop after token overflow requests com
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests reset reasoning state across retries", () => {
|
it.live("session.processor effect tests reset reasoning state across retries", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -449,7 +449,7 @@ it.effect("session.processor effect tests reset reasoning state across retries",
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests do not retry unknown json errors", () => {
|
it.live("session.processor effect tests do not retry unknown json errors", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -495,7 +495,7 @@ it.effect("session.processor effect tests do not retry unknown json errors", ()
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests retry recognized structured json errors", () => {
|
it.live("session.processor effect tests retry recognized structured json errors", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -544,7 +544,7 @@ it.effect("session.processor effect tests retry recognized structured json error
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests publish retry status updates", () => {
|
it.live("session.processor effect tests publish retry status updates", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -611,7 +611,7 @@ it.effect("session.processor effect tests publish retry status updates", () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests compact on structured context overflow", () => {
|
it.live("session.processor effect tests compact on structured context overflow", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -656,7 +656,7 @@ it.effect("session.processor effect tests compact on structured context overflow
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests mark pending tools as aborted on cleanup", () => {
|
it.live("session.processor effect tests mark pending tools as aborted on cleanup", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -725,7 +725,7 @@ it.effect("session.processor effect tests mark pending tools as aborted on clean
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests record aborted errors and idle state", () => {
|
it.live("session.processor effect tests record aborted errors and idle state", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -807,7 +807,7 @@ it.effect("session.processor effect tests record aborted errors and idle state",
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it.effect("session.processor effect tests mark interruptions aborted without manual abort", () => {
|
it.live("session.processor effect tests mark interruptions aborted without manual abort", () => {
|
||||||
return provideTmpdirInstance(
|
return provideTmpdirInstance(
|
||||||
(dir) =>
|
(dir) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,66 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { MessageID, PartID } from "../../src/session/schema"
|
import { MessageID, PartID } from "../../src/session/schema"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
const projectRoot = path.join(__dirname, "../..")
|
|
||||||
Log.init({ print: false })
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
function user(sessionID: string, agent = "default") {
|
||||||
|
return Session.updateMessage({
|
||||||
|
id: MessageID.ascending(),
|
||||||
|
role: "user" as const,
|
||||||
|
sessionID: sessionID as any,
|
||||||
|
agent,
|
||||||
|
model: { providerID: ProviderID.make("openai"), modelID: ModelID.make("gpt-4") },
|
||||||
|
time: { created: Date.now() },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistant(sessionID: string, parentID: string, dir: string) {
|
||||||
|
return Session.updateMessage({
|
||||||
|
id: MessageID.ascending(),
|
||||||
|
role: "assistant" as const,
|
||||||
|
sessionID: sessionID as any,
|
||||||
|
mode: "default",
|
||||||
|
agent: "default",
|
||||||
|
path: { cwd: dir, root: dir },
|
||||||
|
cost: 0,
|
||||||
|
tokens: { output: 0, input: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||||
|
modelID: ModelID.make("gpt-4"),
|
||||||
|
providerID: ProviderID.make("openai"),
|
||||||
|
parentID: parentID as any,
|
||||||
|
time: { created: Date.now() },
|
||||||
|
finish: "end_turn",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(sessionID: string, messageID: string, content: string) {
|
||||||
|
return Session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: messageID as any,
|
||||||
|
sessionID: sessionID as any,
|
||||||
|
type: "text" as const,
|
||||||
|
text: content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function tool(sessionID: string, messageID: string) {
|
||||||
|
return Session.updatePart({
|
||||||
|
id: PartID.ascending(),
|
||||||
|
messageID: messageID as any,
|
||||||
|
sessionID: sessionID as any,
|
||||||
|
type: "tool" as const,
|
||||||
|
tool: "bash",
|
||||||
|
callID: "call-1",
|
||||||
|
state: {
|
||||||
|
status: "completed" as const,
|
||||||
|
input: {},
|
||||||
|
output: "done",
|
||||||
|
title: "",
|
||||||
|
metadata: {},
|
||||||
|
time: { start: 0, end: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
describe("revert + compact workflow", () => {
|
describe("revert + compact workflow", () => {
|
||||||
test("should properly handle compact command after revert", async () => {
|
test("should properly handle compact command after revert", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
@@ -283,4 +340,98 @@ describe("revert + compact workflow", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("cleanup with partID removes parts from the revert point onward", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
const sid = session.id
|
||||||
|
|
||||||
|
const u1 = await user(sid)
|
||||||
|
const p1 = await text(sid, u1.id, "first part")
|
||||||
|
const p2 = await tool(sid, u1.id)
|
||||||
|
const p3 = await text(sid, u1.id, "third part")
|
||||||
|
|
||||||
|
// Set revert state pointing at a specific part
|
||||||
|
await Session.setRevert({
|
||||||
|
sessionID: sid,
|
||||||
|
revert: { messageID: u1.id, partID: p2.id },
|
||||||
|
summary: { additions: 0, deletions: 0, files: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = await Session.get(sid)
|
||||||
|
await SessionRevert.cleanup(info)
|
||||||
|
|
||||||
|
const msgs = await Session.messages({ sessionID: sid })
|
||||||
|
expect(msgs.length).toBe(1)
|
||||||
|
// Only the first part should remain (before the revert partID)
|
||||||
|
expect(msgs[0].parts.length).toBe(1)
|
||||||
|
expect(msgs[0].parts[0].id).toBe(p1.id)
|
||||||
|
|
||||||
|
const cleared = await Session.get(sid)
|
||||||
|
expect(cleared.revert).toBeUndefined()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleanup removes messages after revert point but keeps earlier ones", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
const sid = session.id
|
||||||
|
|
||||||
|
const u1 = await user(sid)
|
||||||
|
await text(sid, u1.id, "hello")
|
||||||
|
const a1 = await assistant(sid, u1.id, tmp.path)
|
||||||
|
await text(sid, a1.id, "hi back")
|
||||||
|
|
||||||
|
const u2 = await user(sid)
|
||||||
|
await text(sid, u2.id, "second question")
|
||||||
|
const a2 = await assistant(sid, u2.id, tmp.path)
|
||||||
|
await text(sid, a2.id, "second answer")
|
||||||
|
|
||||||
|
// Revert from u2 onward
|
||||||
|
await Session.setRevert({
|
||||||
|
sessionID: sid,
|
||||||
|
revert: { messageID: u2.id },
|
||||||
|
summary: { additions: 0, deletions: 0, files: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const info = await Session.get(sid)
|
||||||
|
await SessionRevert.cleanup(info)
|
||||||
|
|
||||||
|
const msgs = await Session.messages({ sessionID: sid })
|
||||||
|
const ids = msgs.map((m) => m.info.id)
|
||||||
|
expect(ids).toContain(u1.id)
|
||||||
|
expect(ids).toContain(a1.id)
|
||||||
|
expect(ids).not.toContain(u2.id)
|
||||||
|
expect(ids).not.toContain(a2.id)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("cleanup is a no-op when session has no revert state", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({
|
||||||
|
directory: tmp.path,
|
||||||
|
fn: async () => {
|
||||||
|
const session = await Session.create({})
|
||||||
|
const sid = session.id
|
||||||
|
|
||||||
|
const u1 = await user(sid)
|
||||||
|
await text(sid, u1.id, "hello")
|
||||||
|
|
||||||
|
const info = await Session.get(sid)
|
||||||
|
expect(info.revert).toBeUndefined()
|
||||||
|
await SessionRevert.cleanup(info)
|
||||||
|
|
||||||
|
const msgs = await Session.messages({ sessionID: sid })
|
||||||
|
expect(msgs.length).toBe(1)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
|
import { Storage } from "../../src/storage/storage"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const dir = path.join(Global.Path.data, "storage")
|
||||||
|
|
||||||
|
async function withScope<T>(fn: (root: string[]) => Promise<T>) {
|
||||||
|
const root = ["storage_test", crypto.randomUUID()]
|
||||||
|
try {
|
||||||
|
return await fn(root)
|
||||||
|
} finally {
|
||||||
|
await fs.rm(path.join(dir, ...root), { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function map(root: string, file: string) {
|
||||||
|
if (file === Global.Path.data) return root
|
||||||
|
if (file.startsWith(Global.Path.data + path.sep)) return path.join(root, path.relative(Global.Path.data, file))
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
function layer(root: string) {
|
||||||
|
return Layer.effect(
|
||||||
|
AppFileSystem.Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const fs = yield* AppFileSystem.Service
|
||||||
|
return AppFileSystem.Service.of({
|
||||||
|
...fs,
|
||||||
|
isDir: (file) => fs.isDir(map(root, file)),
|
||||||
|
readJson: (file) => fs.readJson(map(root, file)),
|
||||||
|
writeWithDirs: (file, content, mode) => fs.writeWithDirs(map(root, file), content, mode),
|
||||||
|
readFileString: (file) => fs.readFileString(map(root, file)),
|
||||||
|
remove: (file) => fs.remove(map(root, file)),
|
||||||
|
glob: (pattern, options) =>
|
||||||
|
fs.glob(pattern, options?.cwd ? { ...options, cwd: map(root, options.cwd) } : options),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
).pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withStorage<T>(
|
||||||
|
root: string,
|
||||||
|
fn: (run: <A, E>(body: Effect.Effect<A, E, Storage.Service>) => Promise<A>) => Promise<T>,
|
||||||
|
) {
|
||||||
|
const rt = ManagedRuntime.make(Storage.layer.pipe(Layer.provide(layer(root))))
|
||||||
|
try {
|
||||||
|
return await fn((body) => rt.runPromise(body))
|
||||||
|
} finally {
|
||||||
|
await rt.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function write(file: string, value: unknown) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await Bun.write(file, JSON.stringify(value, null, 2))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function text(file: string, value: string) {
|
||||||
|
await fs.mkdir(path.dirname(file), { recursive: true })
|
||||||
|
await Bun.write(file, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exists(file: string) {
|
||||||
|
return fs
|
||||||
|
.stat(file)
|
||||||
|
.then(() => true)
|
||||||
|
.catch(() => false)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Storage", () => {
|
||||||
|
test("round-trips JSON content", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "session_diff", "roundtrip"]
|
||||||
|
const value = [{ file: "a.ts", additions: 2, deletions: 1 }]
|
||||||
|
|
||||||
|
await Storage.write(key, value)
|
||||||
|
|
||||||
|
expect(await Storage.read<typeof value>(key)).toEqual(value)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("maps missing reads to NotFoundError", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
await expect(Storage.read([...root, "missing", "value"])).rejects.toMatchObject({ name: "NotFoundError" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("update on missing key throws NotFoundError", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
await expect(
|
||||||
|
Storage.update<{ value: number }>([...root, "missing", "key"], (draft) => {
|
||||||
|
draft.value += 1
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ name: "NotFoundError" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("write overwrites existing value", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "overwrite", "test"]
|
||||||
|
await Storage.write<{ v: number }>(key, { v: 1 })
|
||||||
|
await Storage.write<{ v: number }>(key, { v: 2 })
|
||||||
|
|
||||||
|
expect(await Storage.read<{ v: number }>(key)).toEqual({ v: 2 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("remove on missing key is a no-op", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
await expect(Storage.remove([...root, "nonexistent", "key"])).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("list on missing prefix returns empty", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
expect(await Storage.list([...root, "nonexistent"])).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("serializes concurrent updates for the same key", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "counter", "shared"]
|
||||||
|
await Storage.write(key, { value: 0 })
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: 25 }, () =>
|
||||||
|
Storage.update<{ value: number }>(key, (draft) => {
|
||||||
|
draft.value += 1
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await Storage.read<{ value: number }>(key)).toEqual({ value: 25 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("concurrent reads do not block each other", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "concurrent", "reads"]
|
||||||
|
await Storage.write(key, { ok: true })
|
||||||
|
|
||||||
|
const results = await Promise.all(Array.from({ length: 10 }, () => Storage.read(key)))
|
||||||
|
|
||||||
|
expect(results).toHaveLength(10)
|
||||||
|
for (const r of results) expect(r).toEqual({ ok: true })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("nested keys create deep paths", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const key = [...root, "a", "b", "c", "deep"]
|
||||||
|
await Storage.write<{ nested: boolean }>(key, { nested: true })
|
||||||
|
|
||||||
|
expect(await Storage.read<{ nested: boolean }>(key)).toEqual({ nested: true })
|
||||||
|
expect(await Storage.list([...root, "a"])).toEqual([key])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("lists and removes stored entries", async () => {
|
||||||
|
await withScope(async (root) => {
|
||||||
|
const a = [...root, "list", "a"]
|
||||||
|
const b = [...root, "list", "b"]
|
||||||
|
const prefix = [...root, "list"]
|
||||||
|
|
||||||
|
await Storage.write(b, { value: 2 })
|
||||||
|
await Storage.write(a, { value: 1 })
|
||||||
|
|
||||||
|
expect(await Storage.list(prefix)).toEqual([a, b])
|
||||||
|
|
||||||
|
await Storage.remove(a)
|
||||||
|
|
||||||
|
expect(await Storage.list(prefix)).toEqual([b])
|
||||||
|
await expect(Storage.read(a)).rejects.toMatchObject({ name: "NotFoundError" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migration 2 runs when marker contents are invalid", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const storage = path.join(tmp.path, "storage")
|
||||||
|
const diffs = [
|
||||||
|
{ additions: 2, deletions: 1 },
|
||||||
|
{ additions: 3, deletions: 4 },
|
||||||
|
]
|
||||||
|
|
||||||
|
await text(path.join(storage, "migration"), "wat")
|
||||||
|
await write(path.join(storage, "session", "proj_test", "ses_test.json"), {
|
||||||
|
id: "ses_test",
|
||||||
|
projectID: "proj_test",
|
||||||
|
title: "legacy",
|
||||||
|
summary: { diffs },
|
||||||
|
})
|
||||||
|
|
||||||
|
await withStorage(tmp.path, async (run) => {
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.list(["session_diff"])))).toEqual([
|
||||||
|
["session_diff", "ses_test"],
|
||||||
|
])
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.read<typeof diffs>(["session_diff", "ses_test"])))).toEqual(
|
||||||
|
diffs,
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
await run(
|
||||||
|
Storage.Service.use((svc) =>
|
||||||
|
svc.read<{
|
||||||
|
id: string
|
||||||
|
projectID: string
|
||||||
|
title: string
|
||||||
|
summary: {
|
||||||
|
additions: number
|
||||||
|
deletions: number
|
||||||
|
}
|
||||||
|
}>(["session", "proj_test", "ses_test"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
id: "ses_test",
|
||||||
|
projectID: "proj_test",
|
||||||
|
title: "legacy",
|
||||||
|
summary: {
|
||||||
|
additions: 5,
|
||||||
|
deletions: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("migration 1 tolerates malformed legacy records", async () => {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
const storage = path.join(tmp.path, "storage")
|
||||||
|
const legacy = path.join(tmp.path, "project", "legacy")
|
||||||
|
|
||||||
|
await write(path.join(legacy, "storage", "session", "message", "probe", "0.json"), [])
|
||||||
|
await write(path.join(legacy, "storage", "session", "message", "probe", "1.json"), {
|
||||||
|
path: { root: tmp.path },
|
||||||
|
})
|
||||||
|
await write(path.join(legacy, "storage", "session", "info", "ses_legacy.json"), {
|
||||||
|
id: "ses_legacy",
|
||||||
|
title: "legacy",
|
||||||
|
})
|
||||||
|
await write(path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"), {
|
||||||
|
role: "user",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
|
||||||
|
await withStorage(tmp.path, async (run) => {
|
||||||
|
const projects = await run(Storage.Service.use((svc) => svc.list(["project"])))
|
||||||
|
expect(projects).toHaveLength(1)
|
||||||
|
const project = projects[0]![1]
|
||||||
|
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.list(["session", project])))).toEqual([
|
||||||
|
["session", project, "ses_legacy"],
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
await run(
|
||||||
|
Storage.Service.use((svc) => svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
id: "ses_legacy",
|
||||||
|
title: "legacy",
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
await run(
|
||||||
|
Storage.Service.use((svc) =>
|
||||||
|
svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).toEqual({
|
||||||
|
role: "user",
|
||||||
|
text: "hello",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await Bun.file(path.join(storage, "migration")).text()).toBe("2")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("failed migrations do not advance the marker", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
const storage = path.join(tmp.path, "storage")
|
||||||
|
const legacy = path.join(tmp.path, "project", "legacy")
|
||||||
|
|
||||||
|
await text(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{")
|
||||||
|
|
||||||
|
await withStorage(tmp.path, async (run) => {
|
||||||
|
expect(await run(Storage.Service.use((svc) => svc.list(["project"])))).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await exists(path.join(storage, "migration"))).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -140,7 +140,7 @@ describe("Truncate", () => {
|
|||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
it.effect("deletes files older than 7 days and preserves recent files", () =>
|
it.live("deletes files older than 7 days and preserves recent files", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/plugin",
|
"name": "@opencode-ai/plugin",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -21,8 +21,8 @@
|
|||||||
"zod": "catalog:"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@opentui/core": ">=0.1.92",
|
"@opentui/core": ">=0.1.95",
|
||||||
"@opentui/solid": ">=0.1.92"
|
"@opentui/solid": ">=0.1.95"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@opentui/core": {
|
"@opentui/core": {
|
||||||
@@ -33,8 +33,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@opentui/core": "0.1.92",
|
"@opentui/core": "0.1.95",
|
||||||
"@opentui/solid": "0.1.92",
|
"@opentui/solid": "0.1.95",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/node": "catalog:",
|
"@types/node": "catalog:",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@opencode-ai/sdk",
|
"name": "@opencode-ai/sdk",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -4,20 +4,6 @@ export type ClientOptions = {
|
|||||||
baseUrl: `${string}://${string}` | (string & {})
|
baseUrl: `${string}://${string}` | (string & {})
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventInstallationUpdated = {
|
|
||||||
type: "installation.updated"
|
|
||||||
properties: {
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventInstallationUpdateAvailable = {
|
|
||||||
type: "installation.update-available"
|
|
||||||
properties: {
|
|
||||||
version: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Project = {
|
export type Project = {
|
||||||
id: string
|
id: string
|
||||||
worktree: string
|
worktree: string
|
||||||
@@ -47,6 +33,20 @@ export type EventProjectUpdated = {
|
|||||||
properties: Project
|
properties: Project
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventInstallationUpdated = {
|
||||||
|
type: "installation.updated"
|
||||||
|
properties: {
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventInstallationUpdateAvailable = {
|
||||||
|
type: "installation.update-available"
|
||||||
|
properties: {
|
||||||
|
version: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type EventServerInstanceDisposed = {
|
export type EventServerInstanceDisposed = {
|
||||||
type: "server.instance.disposed"
|
type: "server.instance.disposed"
|
||||||
properties: {
|
properties: {
|
||||||
@@ -964,9 +964,9 @@ export type EventSessionDeleted = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type Event =
|
export type Event =
|
||||||
|
| EventProjectUpdated
|
||||||
| EventInstallationUpdated
|
| EventInstallationUpdated
|
||||||
| EventInstallationUpdateAvailable
|
| EventInstallationUpdateAvailable
|
||||||
| EventProjectUpdated
|
|
||||||
| EventServerInstanceDisposed
|
| EventServerInstanceDisposed
|
||||||
| EventServerConnected
|
| EventServerConnected
|
||||||
| EventGlobalDisposed
|
| EventGlobalDisposed
|
||||||
|
|||||||
+41
-41
@@ -7040,44 +7040,6 @@
|
|||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
"schemas": {
|
"schemas": {
|
||||||
"Event.installation.updated": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "installation.updated"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["version"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.installation.update-available": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "installation.update-available"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"version": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["version"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Project": {
|
"Project": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7154,6 +7116,44 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
|
"Event.installation.updated": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "installation.updated"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["version"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
|
"Event.installation.update-available": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "installation.update-available"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"version": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["version"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
"Event.server.instance.disposed": {
|
"Event.server.instance.disposed": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9733,15 +9733,15 @@
|
|||||||
},
|
},
|
||||||
"Event": {
|
"Event": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.project.updated"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.installation.updated"
|
"$ref": "#/components/schemas/Event.installation.updated"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.installation.update-available"
|
"$ref": "#/components/schemas/Event.installation.update-available"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.project.updated"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
"$ref": "#/components/schemas/Event.server.instance.disposed"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/slack",
|
"name": "@opencode-ai/slack",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "@opencode-ai/web",
|
"name": "@opencode-ai/web",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "astro dev",
|
"dev": "astro dev",
|
||||||
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
"dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev",
|
||||||
|
|||||||
+57
-242
@@ -1,249 +1,40 @@
|
|||||||
#!/usr/bin/env bun
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
import { $ } from "bun"
|
import { rm } from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
import { parseArgs } from "util"
|
import { parseArgs } from "util"
|
||||||
|
|
||||||
type Release = {
|
const root = path.resolve(import.meta.dir, "..")
|
||||||
tag_name: string
|
const file = path.join(root, "UPCOMING_CHANGELOG.md")
|
||||||
draft: boolean
|
const { values, positionals } = parseArgs({
|
||||||
}
|
args: Bun.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
from: { type: "string", short: "f" },
|
||||||
|
to: { type: "string", short: "t" },
|
||||||
|
variant: { type: "string", default: "low" },
|
||||||
|
quiet: { type: "boolean", default: false },
|
||||||
|
print: { type: "boolean", default: false },
|
||||||
|
help: { type: "boolean", short: "h", default: false },
|
||||||
|
},
|
||||||
|
allowPositionals: true,
|
||||||
|
})
|
||||||
|
const args = [...positionals]
|
||||||
|
|
||||||
type Commit = {
|
if (values.from) args.push("--from", values.from)
|
||||||
hash: string
|
if (values.to) args.push("--to", values.to)
|
||||||
author: string | null
|
|
||||||
message: string
|
|
||||||
areas: Set<string>
|
|
||||||
}
|
|
||||||
|
|
||||||
type User = Map<string, Set<string>>
|
if (values.help) {
|
||||||
type Diff = {
|
console.log(`
|
||||||
sha: string
|
|
||||||
login: string | null
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
|
|
||||||
const bot = ["actions-user", "opencode", "opencode-agent[bot]"]
|
|
||||||
const team = [
|
|
||||||
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
|
|
||||||
.text()
|
|
||||||
.then((x) => x.split(/\r?\n/).map((x) => x.trim()))
|
|
||||||
.then((x) => x.filter((x) => x && !x.startsWith("#")))),
|
|
||||||
...bot,
|
|
||||||
]
|
|
||||||
const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const
|
|
||||||
const sections = {
|
|
||||||
core: "Core",
|
|
||||||
tui: "TUI",
|
|
||||||
app: "Desktop",
|
|
||||||
tauri: "Desktop",
|
|
||||||
sdk: "SDK",
|
|
||||||
plugin: "SDK",
|
|
||||||
"extensions/zed": "Extensions",
|
|
||||||
"extensions/vscode": "Extensions",
|
|
||||||
github: "Extensions",
|
|
||||||
} as const
|
|
||||||
|
|
||||||
function ref(input: string) {
|
|
||||||
if (input === "HEAD") return input
|
|
||||||
if (input.startsWith("v")) return input
|
|
||||||
if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}`
|
|
||||||
return input
|
|
||||||
}
|
|
||||||
|
|
||||||
async function latest() {
|
|
||||||
const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json()
|
|
||||||
const release = (data as Release[]).find((item) => !item.draft)
|
|
||||||
if (!release) throw new Error("No releases found")
|
|
||||||
return release.tag_name.replace(/^v/, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
async function diff(base: string, head: string) {
|
|
||||||
const list: Diff[] = []
|
|
||||||
for (let page = 1; ; page++) {
|
|
||||||
const text =
|
|
||||||
await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
|
|
||||||
const batch = text
|
|
||||||
.split("\n")
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((line) => JSON.parse(line) as Diff)
|
|
||||||
if (batch.length === 0) break
|
|
||||||
list.push(...batch)
|
|
||||||
if (batch.length < 100) break
|
|
||||||
}
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
||||||
function section(areas: Set<string>) {
|
|
||||||
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"]
|
|
||||||
for (const area of priority) {
|
|
||||||
if (areas.has(area)) return sections[area as keyof typeof sections]
|
|
||||||
}
|
|
||||||
return "Core"
|
|
||||||
}
|
|
||||||
|
|
||||||
function reverted(commits: Commit[]) {
|
|
||||||
const seen = new Map<string, Commit>()
|
|
||||||
|
|
||||||
for (const commit of commits) {
|
|
||||||
const match = commit.message.match(/^Revert "(.+)"$/)
|
|
||||||
if (match) {
|
|
||||||
const msg = match[1]!
|
|
||||||
if (seen.has(msg)) seen.delete(msg)
|
|
||||||
else seen.set(commit.message, commit)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
const revert = `Revert "${commit.message}"`
|
|
||||||
if (seen.has(revert)) {
|
|
||||||
seen.delete(revert)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.set(commit.message, commit)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...seen.values()]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function commits(from: string, to: string) {
|
|
||||||
const base = ref(from)
|
|
||||||
const head = ref(to)
|
|
||||||
|
|
||||||
const data = new Map<string, { login: string | null; message: string }>()
|
|
||||||
for (const item of await diff(base, head)) {
|
|
||||||
data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
const log =
|
|
||||||
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
|
|
||||||
|
|
||||||
const list: Commit[] = []
|
|
||||||
for (const hash of log.split("\n").filter(Boolean)) {
|
|
||||||
const item = data.get(hash)
|
|
||||||
if (!item) continue
|
|
||||||
if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
|
||||||
|
|
||||||
const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
|
|
||||||
const areas = new Set<string>()
|
|
||||||
|
|
||||||
for (const file of diff.split("\n").filter(Boolean)) {
|
|
||||||
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
|
|
||||||
else if (file.startsWith("packages/opencode/")) areas.add("core")
|
|
||||||
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
|
|
||||||
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
|
|
||||||
else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
|
|
||||||
else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed")
|
|
||||||
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (areas.size === 0) continue
|
|
||||||
|
|
||||||
list.push({
|
|
||||||
hash: hash.slice(0, 7),
|
|
||||||
author: item.login,
|
|
||||||
message: item.message,
|
|
||||||
areas,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return reverted(list)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function contributors(from: string, to: string) {
|
|
||||||
const base = ref(from)
|
|
||||||
const head = ref(to)
|
|
||||||
|
|
||||||
const users: User = new Map()
|
|
||||||
for (const item of await diff(base, head)) {
|
|
||||||
const title = item.message.split("\n")[0] ?? ""
|
|
||||||
if (!item.login || team.includes(item.login)) continue
|
|
||||||
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
|
||||||
if (!users.has(item.login)) users.set(item.login, new Set())
|
|
||||||
users.get(item.login)!.add(title)
|
|
||||||
}
|
|
||||||
|
|
||||||
return users
|
|
||||||
}
|
|
||||||
|
|
||||||
async function published(to: string) {
|
|
||||||
if (to === "HEAD") return
|
|
||||||
const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
|
|
||||||
if (!body) return
|
|
||||||
|
|
||||||
const lines = body.split(/\r?\n/)
|
|
||||||
const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
|
|
||||||
if (start < 0) return
|
|
||||||
return lines.slice(start).join("\n").trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function thanks(from: string, to: string, reuse: boolean) {
|
|
||||||
const release = reuse ? await published(to) : undefined
|
|
||||||
if (release) return release.split(/\r?\n/)
|
|
||||||
|
|
||||||
const users = await contributors(from, to)
|
|
||||||
if (users.size === 0) return []
|
|
||||||
|
|
||||||
const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
|
|
||||||
for (const [name, commits] of users) {
|
|
||||||
lines.push(`- @${name}:`)
|
|
||||||
for (const commit of commits) lines.push(` - ${commit}`)
|
|
||||||
}
|
|
||||||
return lines
|
|
||||||
}
|
|
||||||
|
|
||||||
function format(from: string, to: string, list: Commit[], thanks: string[]) {
|
|
||||||
const grouped = new Map<string, string[]>()
|
|
||||||
for (const title of order) grouped.set(title, [])
|
|
||||||
|
|
||||||
for (const commit of list) {
|
|
||||||
const title = section(commit.areas)
|
|
||||||
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
|
||||||
grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
|
|
||||||
|
|
||||||
if (list.length === 0) {
|
|
||||||
lines.push("No notable changes.")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const title of order) {
|
|
||||||
const entries = grouped.get(title)
|
|
||||||
if (!entries || entries.length === 0) continue
|
|
||||||
lines.push(`## ${title}`)
|
|
||||||
lines.push(...entries)
|
|
||||||
lines.push("")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (thanks.length > 0) {
|
|
||||||
if (lines.at(-1) !== "") lines.push("")
|
|
||||||
lines.push("## Community Contributors Input")
|
|
||||||
lines.push("")
|
|
||||||
lines.push(...thanks)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (lines.at(-1) === "") lines.pop()
|
|
||||||
return lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.main) {
|
|
||||||
const { values } = parseArgs({
|
|
||||||
args: Bun.argv.slice(2),
|
|
||||||
options: {
|
|
||||||
from: { type: "string", short: "f" },
|
|
||||||
to: { type: "string", short: "t", default: "HEAD" },
|
|
||||||
help: { type: "boolean", short: "h", default: false },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if (values.help) {
|
|
||||||
console.log(`
|
|
||||||
Usage: bun script/changelog.ts [options]
|
Usage: bun script/changelog.ts [options]
|
||||||
|
|
||||||
|
Generates UPCOMING_CHANGELOG.md by running the opencode changelog command.
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
||||||
-t, --to <ref> Ending ref (default: HEAD)
|
-t, --to <ref> Ending ref (default: HEAD)
|
||||||
|
--variant <name> Thinking variant for opencode run (default: low)
|
||||||
|
--quiet Suppress opencode command output unless it fails
|
||||||
|
--print Print the generated UPCOMING_CHANGELOG.md after success
|
||||||
-h, --help Show this help message
|
-h, --help Show this help message
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
@@ -251,11 +42,35 @@ Examples:
|
|||||||
bun script/changelog.ts --from 1.0.200
|
bun script/changelog.ts --from 1.0.200
|
||||||
bun script/changelog.ts -f 1.0.200 -t 1.0.205
|
bun script/changelog.ts -f 1.0.200 -t 1.0.205
|
||||||
`)
|
`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
}
|
|
||||||
|
|
||||||
const to = values.to!
|
|
||||||
const from = values.from ?? (await latest())
|
|
||||||
const list = await commits(from, to)
|
|
||||||
console.log(format(from, to, list, await thanks(from, to, !values.from)))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await rm(file, { force: true })
|
||||||
|
|
||||||
|
const quiet = values.quiet
|
||||||
|
const cmd = ["opencode", "run"]
|
||||||
|
cmd.push("--variant", values.variant)
|
||||||
|
cmd.push("--command", "changelog", "--", ...args)
|
||||||
|
|
||||||
|
const proc = Bun.spawn(cmd, {
|
||||||
|
cwd: root,
|
||||||
|
stdin: "inherit",
|
||||||
|
stdout: quiet ? "pipe" : "inherit",
|
||||||
|
stderr: quiet ? "pipe" : "inherit",
|
||||||
|
})
|
||||||
|
|
||||||
|
const [out, err] = quiet
|
||||||
|
? await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()])
|
||||||
|
: ["", ""]
|
||||||
|
const code = await proc.exited
|
||||||
|
if (code === 0) {
|
||||||
|
if (values.print) process.stdout.write(await Bun.file(file).text())
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (quiet) {
|
||||||
|
if (out) process.stdout.write(out)
|
||||||
|
if (err) process.stderr.write(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(code)
|
||||||
|
|||||||
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
import { $ } from "bun"
|
||||||
|
import { parseArgs } from "util"
|
||||||
|
|
||||||
|
type Release = {
|
||||||
|
tag_name: string
|
||||||
|
draft: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
type Commit = {
|
||||||
|
hash: string
|
||||||
|
author: string | null
|
||||||
|
message: string
|
||||||
|
areas: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
|
type User = Map<string, Set<string>>
|
||||||
|
type Diff = {
|
||||||
|
sha: string
|
||||||
|
login: string | null
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const repo = process.env.GH_REPO ?? "anomalyco/opencode"
|
||||||
|
const bot = ["actions-user", "opencode", "opencode-agent[bot]"]
|
||||||
|
const team = [
|
||||||
|
...(await Bun.file(new URL("../.github/TEAM_MEMBERS", import.meta.url))
|
||||||
|
.text()
|
||||||
|
.then((x) => x.split(/\r?\n/).map((x) => x.trim()))
|
||||||
|
.then((x) => x.filter((x) => x && !x.startsWith("#")))),
|
||||||
|
...bot,
|
||||||
|
]
|
||||||
|
const order = ["Core", "TUI", "Desktop", "SDK", "Extensions"] as const
|
||||||
|
const sections = {
|
||||||
|
core: "Core",
|
||||||
|
tui: "TUI",
|
||||||
|
app: "Desktop",
|
||||||
|
tauri: "Desktop",
|
||||||
|
sdk: "SDK",
|
||||||
|
plugin: "SDK",
|
||||||
|
"extensions/zed": "Extensions",
|
||||||
|
"extensions/vscode": "Extensions",
|
||||||
|
github: "Extensions",
|
||||||
|
} as const
|
||||||
|
|
||||||
|
function ref(input: string) {
|
||||||
|
if (input === "HEAD") return input
|
||||||
|
if (input.startsWith("v")) return input
|
||||||
|
if (input.match(/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/)) return `v${input}`
|
||||||
|
return input
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latest() {
|
||||||
|
const data = await $`gh api "/repos/${repo}/releases?per_page=100"`.json()
|
||||||
|
const release = (data as Release[]).find((item) => !item.draft)
|
||||||
|
if (!release) throw new Error("No releases found")
|
||||||
|
return release.tag_name.replace(/^v/, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diff(base: string, head: string) {
|
||||||
|
const list: Diff[] = []
|
||||||
|
for (let page = 1; ; page++) {
|
||||||
|
const text =
|
||||||
|
await $`gh api "/repos/${repo}/compare/${base}...${head}?per_page=100&page=${page}" --jq '.commits[] | {sha: .sha, login: .author.login, message: .commit.message}'`.text()
|
||||||
|
const batch = text
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => JSON.parse(line) as Diff)
|
||||||
|
if (batch.length === 0) break
|
||||||
|
list.push(...batch)
|
||||||
|
if (batch.length < 100) break
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(areas: Set<string>) {
|
||||||
|
const priority = ["core", "tui", "app", "tauri", "sdk", "plugin", "extensions/zed", "extensions/vscode", "github"]
|
||||||
|
for (const area of priority) {
|
||||||
|
if (areas.has(area)) return sections[area as keyof typeof sections]
|
||||||
|
}
|
||||||
|
return "Core"
|
||||||
|
}
|
||||||
|
|
||||||
|
function reverted(commits: Commit[]) {
|
||||||
|
const seen = new Map<string, Commit>()
|
||||||
|
|
||||||
|
for (const commit of commits) {
|
||||||
|
const match = commit.message.match(/^Revert "(.+)"$/)
|
||||||
|
if (match) {
|
||||||
|
const msg = match[1]!
|
||||||
|
if (seen.has(msg)) seen.delete(msg)
|
||||||
|
else seen.set(commit.message, commit)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const revert = `Revert "${commit.message}"`
|
||||||
|
if (seen.has(revert)) {
|
||||||
|
seen.delete(revert)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
seen.set(commit.message, commit)
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...seen.values()]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commits(from: string, to: string) {
|
||||||
|
const base = ref(from)
|
||||||
|
const head = ref(to)
|
||||||
|
|
||||||
|
const data = new Map<string, { login: string | null; message: string }>()
|
||||||
|
for (const item of await diff(base, head)) {
|
||||||
|
data.set(item.sha, { login: item.login, message: item.message.split("\n")[0] ?? "" })
|
||||||
|
}
|
||||||
|
|
||||||
|
const log =
|
||||||
|
await $`git log ${base}..${head} --format=%H -- packages/opencode packages/sdk packages/plugin packages/desktop packages/app sdks/vscode packages/extensions github`.text()
|
||||||
|
|
||||||
|
const list: Commit[] = []
|
||||||
|
for (const hash of log.split("\n").filter(Boolean)) {
|
||||||
|
const item = data.get(hash)
|
||||||
|
if (!item) continue
|
||||||
|
if (item.message.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||||
|
|
||||||
|
const diff = await $`git diff-tree --no-commit-id --name-only -r ${hash}`.text()
|
||||||
|
const areas = new Set<string>()
|
||||||
|
|
||||||
|
for (const file of diff.split("\n").filter(Boolean)) {
|
||||||
|
if (file.startsWith("packages/opencode/src/cli/cmd/")) areas.add("tui")
|
||||||
|
else if (file.startsWith("packages/opencode/")) areas.add("core")
|
||||||
|
else if (file.startsWith("packages/desktop/src-tauri/")) areas.add("tauri")
|
||||||
|
else if (file.startsWith("packages/desktop/") || file.startsWith("packages/app/")) areas.add("app")
|
||||||
|
else if (file.startsWith("packages/sdk/") || file.startsWith("packages/plugin/")) areas.add("sdk")
|
||||||
|
else if (file.startsWith("packages/extensions/")) areas.add("extensions/zed")
|
||||||
|
else if (file.startsWith("sdks/vscode/") || file.startsWith("github/")) areas.add("extensions/vscode")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (areas.size === 0) continue
|
||||||
|
|
||||||
|
list.push({
|
||||||
|
hash: hash.slice(0, 7),
|
||||||
|
author: item.login,
|
||||||
|
message: item.message,
|
||||||
|
areas,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return reverted(list)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contributors(from: string, to: string) {
|
||||||
|
const base = ref(from)
|
||||||
|
const head = ref(to)
|
||||||
|
|
||||||
|
const users: User = new Map()
|
||||||
|
for (const item of await diff(base, head)) {
|
||||||
|
const title = item.message.split("\n")[0] ?? ""
|
||||||
|
if (!item.login || team.includes(item.login)) continue
|
||||||
|
if (title.match(/^(ignore:|test:|chore:|ci:|release:)/i)) continue
|
||||||
|
if (!users.has(item.login)) users.set(item.login, new Set())
|
||||||
|
users.get(item.login)!.add(title)
|
||||||
|
}
|
||||||
|
|
||||||
|
return users
|
||||||
|
}
|
||||||
|
|
||||||
|
async function published(to: string) {
|
||||||
|
if (to === "HEAD") return
|
||||||
|
const body = await $`gh release view ${ref(to)} --repo ${repo} --json body --jq .body`.text().catch(() => "")
|
||||||
|
if (!body) return
|
||||||
|
|
||||||
|
const lines = body.split(/\r?\n/)
|
||||||
|
const start = lines.findIndex((line) => line.startsWith("**Thank you to "))
|
||||||
|
if (start < 0) return
|
||||||
|
return lines.slice(start).join("\n").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function thanks(from: string, to: string, reuse: boolean) {
|
||||||
|
const release = reuse ? await published(to) : undefined
|
||||||
|
if (release) return release.split(/\r?\n/)
|
||||||
|
|
||||||
|
const users = await contributors(from, to)
|
||||||
|
if (users.size === 0) return []
|
||||||
|
|
||||||
|
const lines = [`**Thank you to ${users.size} community contributor${users.size > 1 ? "s" : ""}:**`]
|
||||||
|
for (const [name, commits] of users) {
|
||||||
|
lines.push(`- @${name}:`)
|
||||||
|
for (const commit of commits) lines.push(` - ${commit}`)
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
function format(from: string, to: string, list: Commit[], thanks: string[]) {
|
||||||
|
const grouped = new Map<string, string[]>()
|
||||||
|
for (const title of order) grouped.set(title, [])
|
||||||
|
|
||||||
|
for (const commit of list) {
|
||||||
|
const title = section(commit.areas)
|
||||||
|
const attr = commit.author && !team.includes(commit.author) ? ` (@${commit.author})` : ""
|
||||||
|
grouped.get(title)!.push(`- \`${commit.hash}\` ${commit.message}${attr}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [`Last release: ${ref(from)}`, `Target ref: ${to}`, ""]
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
lines.push("No notable changes.")
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const title of order) {
|
||||||
|
const entries = grouped.get(title)
|
||||||
|
if (!entries || entries.length === 0) continue
|
||||||
|
lines.push(`## ${title}`)
|
||||||
|
lines.push(...entries)
|
||||||
|
lines.push("")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (thanks.length > 0) {
|
||||||
|
if (lines.at(-1) !== "") lines.push("")
|
||||||
|
lines.push("## Community Contributors Input")
|
||||||
|
lines.push("")
|
||||||
|
lines.push(...thanks)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lines.at(-1) === "") lines.pop()
|
||||||
|
return lines.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (import.meta.main) {
|
||||||
|
const { values } = parseArgs({
|
||||||
|
args: Bun.argv.slice(2),
|
||||||
|
options: {
|
||||||
|
from: { type: "string", short: "f" },
|
||||||
|
to: { type: "string", short: "t", default: "HEAD" },
|
||||||
|
help: { type: "boolean", short: "h", default: false },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (values.help) {
|
||||||
|
console.log(`
|
||||||
|
Usage: bun script/raw-changelog.ts [options]
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-f, --from <version> Starting version (default: latest non-draft GitHub release)
|
||||||
|
-t, --to <ref> Ending ref (default: HEAD)
|
||||||
|
-h, --help Show this help message
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
bun script/raw-changelog.ts
|
||||||
|
bun script/raw-changelog.ts --from 1.0.200
|
||||||
|
bun script/raw-changelog.ts -f 1.0.200 -t 1.0.205
|
||||||
|
`)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = values.to!
|
||||||
|
const from = values.from ?? (await latest())
|
||||||
|
const list = await commits(from, to)
|
||||||
|
console.log(format(from, to, list, await thanks(from, to, !values.from)))
|
||||||
|
}
|
||||||
+1
-1
@@ -7,7 +7,7 @@ const output = [`version=${Script.version}`]
|
|||||||
|
|
||||||
if (!Script.preview) {
|
if (!Script.preview) {
|
||||||
const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
|
const sha = process.env.GITHUB_SHA ?? (await $`git rev-parse HEAD`.text()).trim()
|
||||||
await $`opencode run --command changelog -- --to ${sha}`.cwd(process.cwd())
|
await $`bun script/changelog.ts --to ${sha}`.cwd(process.cwd())
|
||||||
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
|
const file = `${process.cwd()}/UPCOMING_CHANGELOG.md`
|
||||||
const body = await Bun.file(file)
|
const body = await Bun.file(file)
|
||||||
.text()
|
.text()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.3.9",
|
"version": "1.3.13",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user