mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 08:46:15 -04:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db75770226 | |||
| 911b2ac088 | |||
| da689d77c4 | |||
| c9df833d2c | |||
| c2b1ebd9dc | |||
| d1356f509e | |||
| 69feb224ba | |||
| 7d9ac713a9 | |||
| 4d9c675b00 | |||
| 604de70689 | |||
| 673226d214 | |||
| 2447f42294 | |||
| 005e64e7d8 | |||
| 8310e7df70 | |||
| c4e676b8a0 | |||
| c2c40b5b61 | |||
| 456495289e | |||
| d6367853ae | |||
| adccab5970 | |||
| b6d3fa09bc | |||
| 31e2d72bf7 | |||
| b7c6fa611f | |||
| d0b8ff0f22 | |||
| 59b976b954 | |||
| d1640c075b | |||
| 17d4c366e6 | |||
| d0844c600b | |||
| 1d243ce25a | |||
| d8c8322ddf | |||
| 1b6599f411 | |||
| 937a3c10b3 | |||
| 72ce24c200 | |||
| 3301fad8cd | |||
| 9c54255aeb | |||
| b0674b473f | |||
| ded4da735b | |||
| 2c334d9242 | |||
| 81dd46abec | |||
| baef5cd43b | |||
| 65368f609d | |||
| 2cb697b720 | |||
| cb511f78ff | |||
| 159964b172 | |||
| d9f9f1553b | |||
| 0fb55b4f1a | |||
| 3c34f6704b | |||
| 4cf088ae84 | |||
| ca28dd02ec | |||
| 2017dc165c | |||
| dc9d6a08cb | |||
| af4ab017cb |
@@ -33,8 +33,9 @@ runs:
|
||||
shell: bash
|
||||
run: echo "dir=$(bun pm cache)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Cache Bun dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
- name: Restore Bun dependencies
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
@@ -56,3 +57,10 @@ runs:
|
||||
bun install ${{ inputs.install-flags }}
|
||||
fi
|
||||
shell: bash
|
||||
|
||||
- name: Save Bun dependencies
|
||||
if: steps.bun-cache.outputs.cache-hit != 'true' && github.event_name != 'pull_request' && github.event_name != 'pull_request_target'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
|
||||
with:
|
||||
path: ${{ steps.cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
||||
|
||||
@@ -397,6 +397,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -418,7 +419,6 @@
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-sound": "1.1.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
@@ -508,9 +508,9 @@
|
||||
"typescript": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.2.6",
|
||||
"@opentui/keymap": ">=0.2.6",
|
||||
"@opentui/solid": ">=0.2.6",
|
||||
"@opentui/core": ">=0.2.8",
|
||||
"@opentui/keymap": ">=0.2.8",
|
||||
"@opentui/solid": ">=0.2.8",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"@opentui/core",
|
||||
@@ -677,6 +677,9 @@
|
||||
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
|
||||
},
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
},
|
||||
@@ -690,9 +693,9 @@
|
||||
"@npmcli/arborist": "9.4.0",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@openauthjs/openauth": "0.0.0-20250322224806",
|
||||
"@opentui/core": "0.2.6",
|
||||
"@opentui/keymap": "0.2.6",
|
||||
"@opentui/solid": "0.2.6",
|
||||
"@opentui/core": "0.2.8",
|
||||
"@opentui/keymap": "0.2.8",
|
||||
"@opentui/solid": "0.2.8",
|
||||
"@pierre/diffs": "1.1.0-beta.18",
|
||||
"@playwright/test": "1.59.1",
|
||||
"@sentry/solid": "10.36.0",
|
||||
@@ -1071,8 +1074,6 @@
|
||||
|
||||
"@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="],
|
||||
|
||||
"@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="],
|
||||
|
||||
"@dot/log": ["@dot/log@0.1.5", "", { "dependencies": { "chalk": "^4.1.2", "loglevelnext": "^6.0.0", "p-defer": "^3.0.0" } }, "sha512-ECraEVJWv2f2mWK93lYiefUkphStVlKD6yKDzisuoEmxuLKrxO9iGetHK2DoEAkj7sxjE886n0OUVVCUx0YPNg=="],
|
||||
|
||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.11.0", "", {}, "sha512-hD3pekGiPg0WPCCGAZmusBBJsDqGUR66Y452YgQsZOnkdQ7ViEPKuyP4huUGEZQefp8g34RRodXYmJ2TbCH+tg=="],
|
||||
@@ -1289,62 +1290,6 @@
|
||||
|
||||
"@isaacs/string-locale-compare": ["@isaacs/string-locale-compare@1.1.0", "", {}, "sha512-SQ7Kzhh9+D+ZW9MA0zkYv3VXhIDNx+LzM6EJ+/65I3QY+enU6Itte7E5XX7EWrqLW2FN4n06GWzBnPoC3th2aQ=="],
|
||||
|
||||
"@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="],
|
||||
|
||||
"@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="],
|
||||
|
||||
"@jimp/file-ops": ["@jimp/file-ops@1.6.0", "", {}, "sha512-Dx/bVDmgnRe1AlniRpCKrGRm5YvGmUwbDzt+MAkgmLGf+jvBT75hmMEZ003n9HQI/aPnm/YKnXjg/hOpzNCpHQ=="],
|
||||
|
||||
"@jimp/js-bmp": ["@jimp/js-bmp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "bmp-ts": "^1.0.9" } }, "sha512-FU6Q5PC/e3yzLyBDXupR3SnL3htU7S3KEs4e6rjDP6gNEOXRFsWs6YD3hXuXd50jd8ummy+q2WSwuGkr8wi+Gw=="],
|
||||
|
||||
"@jimp/js-gif": ["@jimp/js-gif@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "gifwrap": "^0.10.1", "omggif": "^1.0.10" } }, "sha512-N9CZPHOrJTsAUoWkWZstLPpwT5AwJ0wge+47+ix3++SdSL/H2QzyMqxbcDYNFe4MoI5MIhATfb0/dl/wmX221g=="],
|
||||
|
||||
"@jimp/js-jpeg": ["@jimp/js-jpeg@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "jpeg-js": "^0.4.4" } }, "sha512-6vgFDqeusblf5Pok6B2DUiMXplH8RhIKAryj1yn+007SIAQ0khM1Uptxmpku/0MfbClx2r7pnJv9gWpAEJdMVA=="],
|
||||
|
||||
"@jimp/js-png": ["@jimp/js-png@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "pngjs": "^7.0.0" } }, "sha512-AbQHScy3hDDgMRNfG0tPjL88AV6qKAILGReIa3ATpW5QFjBKpisvUaOqhzJ7Reic1oawx3Riyv152gaPfqsBVg=="],
|
||||
|
||||
"@jimp/js-tiff": ["@jimp/js-tiff@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "utif2": "^4.1.0" } }, "sha512-zhReR8/7KO+adijj3h0ZQUOiun3mXUv79zYEAKvE0O+rP7EhgtKvWJOZfRzdZSNv0Pu1rKtgM72qgtwe2tFvyw=="],
|
||||
|
||||
"@jimp/plugin-blit": ["@jimp/plugin-blit@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M+uRWl1csi7qilnSK8uxK4RJMSuVeBiO1AY0+7APnfUbQNZm6hCe0CCFv1Iyw1D/Dhb8ph8fQgm5mwM0eSxgVA=="],
|
||||
|
||||
"@jimp/plugin-blur": ["@jimp/plugin-blur@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-zrM7iic1OTwUCb0g/rN5y+UnmdEsT3IfuCXCJJNs8SZzP0MkZ1eTvuwK9ZidCuMo4+J3xkzCidRwYXB5CyGZTw=="],
|
||||
|
||||
"@jimp/plugin-circle": ["@jimp/plugin-circle@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-xt1Gp+LtdMKAXfDp3HNaG30SPZW6AQ7dtAtTnoRKorRi+5yCJjKqXRgkewS5bvj8DEh87Ko1ydJfzqS3P2tdWw=="],
|
||||
|
||||
"@jimp/plugin-color": ["@jimp/plugin-color@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "tinycolor2": "^1.6.0", "zod": "^3.23.8" } }, "sha512-J5q8IVCpkBsxIXM+45XOXTrsyfblyMZg3a9eAo0P7VPH4+CrvyNQwaYatbAIamSIN1YzxmO3DkIZXzRjFSz1SA=="],
|
||||
|
||||
"@jimp/plugin-contain": ["@jimp/plugin-contain@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-oN/n+Vdq/Qg9bB4yOBOxtY9IPAtEfES8J1n9Ddx+XhGBYT1/QTU/JYkGaAkIGoPnyYvmLEDqMz2SGihqlpqfzQ=="],
|
||||
|
||||
"@jimp/plugin-cover": ["@jimp/plugin-cover@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Iow0h6yqSC269YUJ8HC3Q/MpCi2V55sMlbkkTTx4zPvd8mWZlC0ykrNDeAy9IJegrQ7v5E99rJwmQu25lygKLA=="],
|
||||
|
||||
"@jimp/plugin-crop": ["@jimp/plugin-crop@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-KqZkEhvs+21USdySCUDI+GFa393eDIzbi1smBqkUPTE+pRwSWMAf01D5OC3ZWB+xZsNla93BDS9iCkLHA8wang=="],
|
||||
|
||||
"@jimp/plugin-displace": ["@jimp/plugin-displace@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-4Y10X9qwr5F+Bo5ME356XSACEF55485j5nGdiyJ9hYzjQP9nGgxNJaZ4SAOqpd+k5sFaIeD7SQ0Occ26uIng5Q=="],
|
||||
|
||||
"@jimp/plugin-dither": ["@jimp/plugin-dither@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0" } }, "sha512-600d1RxY0pKwgyU0tgMahLNKsqEcxGdbgXadCiVCoGd6V6glyCvkNrnnwC0n5aJ56Htkj88PToSdF88tNVZEEQ=="],
|
||||
|
||||
"@jimp/plugin-fisheye": ["@jimp/plugin-fisheye@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-E5QHKWSCBFtpgZarlmN3Q6+rTQxjirFqo44ohoTjzYVrDI6B6beXNnPIThJgPr0Y9GwfzgyarKvQuQuqCnnfbA=="],
|
||||
|
||||
"@jimp/plugin-flip": ["@jimp/plugin-flip@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-/+rJVDuBIVOgwoyVkBjUFHtP+wmW0r+r5OQ2GpatQofToPVbJw1DdYWXlwviSx7hvixTWLKVgRWQ5Dw862emDg=="],
|
||||
|
||||
"@jimp/plugin-hash": ["@jimp/plugin-hash@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "any-base": "^1.1.0" } }, "sha512-wWzl0kTpDJgYVbZdajTf+4NBSKvmI3bRI8q6EH9CVeIHps9VWVsUvEyb7rpbcwVLWYuzDtP2R0lTT6WeBNQH9Q=="],
|
||||
|
||||
"@jimp/plugin-mask": ["@jimp/plugin-mask@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-Cwy7ExSJMZszvkad8NV8o/Z92X2kFUFM8mcDAhNVxU0Q6tA0op2UKRJY51eoK8r6eds/qak3FQkXakvNabdLnA=="],
|
||||
|
||||
"@jimp/plugin-print": ["@jimp/plugin-print@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/types": "1.6.0", "parse-bmfont-ascii": "^1.0.6", "parse-bmfont-binary": "^1.0.6", "parse-bmfont-xml": "^1.1.6", "simple-xml-to-json": "^1.2.2", "zod": "^3.23.8" } }, "sha512-zarTIJi8fjoGMSI/M3Xh5yY9T65p03XJmPsuNet19K/Q7mwRU6EV2pfj+28++2PV2NJ+htDF5uecAlnGyxFN2A=="],
|
||||
|
||||
"@jimp/plugin-quantize": ["@jimp/plugin-quantize@1.6.0", "", { "dependencies": { "image-q": "^4.0.0", "zod": "^3.23.8" } }, "sha512-EmzZ/s9StYQwbpG6rUGBCisc3f64JIhSH+ncTJd+iFGtGo0YvSeMdAd+zqgiHpfZoOL54dNavZNjF4otK+mvlg=="],
|
||||
|
||||
"@jimp/plugin-resize": ["@jimp/plugin-resize@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/types": "1.6.0", "zod": "^3.23.8" } }, "sha512-uSUD1mqXN9i1SGSz5ov3keRZ7S9L32/mAQG08wUwZiEi5FpbV0K8A8l1zkazAIZi9IJzLlTauRNU41Mi8IF9fA=="],
|
||||
|
||||
"@jimp/plugin-rotate": ["@jimp/plugin-rotate@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-JagdjBLnUZGSG4xjCLkIpQOZZ3Mjbg8aGCCi4G69qR+OjNpOeGI7N2EQlfK/WE8BEHOW5vdjSyglNqcYbQBWRw=="],
|
||||
|
||||
"@jimp/plugin-threshold": ["@jimp/plugin-threshold@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "zod": "^3.23.8" } }, "sha512-M59m5dzLoHOVWdM41O8z9SyySzcDn43xHseOH0HavjsfQsT56GGCC4QzU1banJidbUrePhzoEdS42uFE8Fei8w=="],
|
||||
|
||||
"@jimp/types": ["@jimp/types@1.6.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-7UfRsiKo5GZTAATxm2qQ7jqmUXP0DxTArztllTcYdyw6Xi5oT4RaoXynVtCD4UyLK5gJgkZJcwonoijrhYFKfg=="],
|
||||
|
||||
"@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="],
|
||||
|
||||
"@joshwooding/vite-plugin-react-docgen-typescript": ["@joshwooding/vite-plugin-react-docgen-typescript@0.7.0", "", { "dependencies": { "glob": "^13.0.1", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "typescript": ">= 4.3.x", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-qvsTEwEFefhdirGOPnu9Wp6ChfIwy2dBCRuETU3uE+4cC+PFoxMSiiEhxk4lOluA34eARHA0OxqsEUYDqRMgeQ=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
@@ -1617,23 +1562,23 @@
|
||||
|
||||
"@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.40.0", "", {}, "sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw=="],
|
||||
|
||||
"@opentui/core": ["@opentui/core@0.2.6", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.6", "@opentui/core-darwin-x64": "0.2.6", "@opentui/core-linux-arm64": "0.2.6", "@opentui/core-linux-x64": "0.2.6", "@opentui/core-win32-arm64": "0.2.6", "@opentui/core-win32-x64": "0.2.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-dBpMaWVM7wtW2/2TlGPrkPjg6gOL3MVU/5XXk+U1LDJB8L4q4NeYWVdzfAVNcEvgmuuCy/cVqdY2D4ei+e7MMg=="],
|
||||
"@opentui/core": ["@opentui/core@0.2.8", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.8", "@opentui/core-darwin-x64": "0.2.8", "@opentui/core-linux-arm64": "0.2.8", "@opentui/core-linux-x64": "0.2.8", "@opentui/core-win32-arm64": "0.2.8", "@opentui/core-win32-x64": "0.2.8" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-bRRiCXuwjS8/6mN1oA5iVaf55z9APyalm7FnoxkLkEyIU1VDaQeTpYtElBbfo1rxtcO6Rj53XywH9oW8auNO9A=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hR5nsxNj+059utzenTCF0kealUlibON6fLuebFUCGM/5kJnqa+shIh0XbUDFm0+F47vqVUgZufBdUuieQZIbvQ=="],
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Qh6VCMQgW3hWh/7MR51y+XuQezh8NOLwKS8EQSoKzAr4VOc/W5P0/DvgMKgwaqXw2Mz0AIba/BvZ6by20yc4zA=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-pJ/bH4WC/mbBaakM1YdH6TVo67jhy0KPd61bCz97w0I/PJGr8fmNKvhmMt/AwyFgOQi3FYZiEKLMpGdvUcSsrQ=="],
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-wQjJ38C3IiVx/gwwBYxnCarzgD75FdS7IyUErt3lhn57XriNiCbb7ScphWnRMwwtL8CI+bBGzClroDRA2lCfvg=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Pnd3kOxig8ii+/IqYheOPEgferylsQA0L6tKBnHQ9jRlCJOcu0Rv65Jepueh212vevdV9DzPURJnhejG06J6g=="],
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-fx4ADeWSSSVU1O/MkMnklCRxtWRy6CLeAvktLlNdPb+BhmQIDg1kpZcdv7m/3cgD1/ksFEXIwO6VTvfKYE0umw=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.6", "", { "os": "linux", "cpu": "x64" }, "sha512-458Mx9tBzEPzfft8cSt5ZaIpEepoxBXBOL6AUVmDTKWaZ3uouraPcEKraGAyvOTDQp2XDI3R8c/2GdaR77FaUQ=="],
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.8", "", { "os": "linux", "cpu": "x64" }, "sha512-4ekUyzopBj2ClsUbneLnUOrmZtvU67FCVFLgmBfKL4IvVl/P0YobGNg71gN1JNiYpY7hK77qOpidVLHcNMIE7w=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-BDUrdrT1RCcVnQoHJmUut4y811jDBAEtc6GJFB4Gs265Be8SrTjVCus6p2fSQ7j9sZQ1OcjO+5+4NkheSZICDQ=="],
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-63K046wpzTzQOLOG9LTsp3+Ld0TNTxeQczexkg0pKSBxZFhws+/9YIGjTctZmJUfE1g1X4tI31dO+KNRpXRHQw=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.6", "", { "os": "win32", "cpu": "x64" }, "sha512-SUYAzRJ9TSoD2Qt8kn6FJz6dbTrFEPVig5mScB4zFGgGQO/Bbod2/Q31vLS/IQrX+FDb67WaErD+kuMCnMPPLA=="],
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.8", "", { "os": "win32", "cpu": "x64" }, "sha512-+WDiTlTyDpgkis8rPAhW1fS7TwXJih+fk+RYXS2bC3tAKsRD+O3PRSkVABRbjkuXbtfJZf2cjOHZFGN4Vf5qDg=="],
|
||||
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.6", "", { "dependencies": { "@opentui/core": "0.2.6" }, "peerDependencies": { "@opentui/react": "0.2.6", "@opentui/solid": "0.2.6", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-+6OYuedrFCKVo4ryGFNwws++2VOmPcXU3PwpY0mP47gYQY2nvQ+etWIs2Y7r5eMIqUfxVCldkKsrzcEcA4tb/A=="],
|
||||
"@opentui/keymap": ["@opentui/keymap@0.2.8", "", { "dependencies": { "@opentui/core": "0.2.8" }, "peerDependencies": { "@opentui/react": "0.2.8", "@opentui/solid": "0.2.8", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-/H9j8fP64cf3/nFDCvVP8+7cwU/oRh4sgfQH2NhcPp8illgBb/e9pG5x3vM0nK4RVyTqUvkPXsOeIX5u7vltlg=="],
|
||||
|
||||
"@opentui/solid": ["@opentui/solid@0.2.6", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.6", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-2y225WlOGi/fCaajkxBmLyVW8Cr+OmhowHdvrYcz5w2kBD15sKbJLIYu1G9DxceirT1uIyasGy2TGzRRcVkTDg=="],
|
||||
"@opentui/solid": ["@opentui/solid@0.2.8", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.8", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-f2g0riBuzk4/ZmcJnp1k13odUmNZcfA3nF7RzdSlEfpkwNDfc4xqnRAwYbNNDwGNrJX0JDCTEZY5ZEhuL155MQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
@@ -2285,8 +2230,6 @@
|
||||
|
||||
"@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="],
|
||||
|
||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
||||
|
||||
"@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="],
|
||||
|
||||
"@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="],
|
||||
@@ -2551,8 +2494,6 @@
|
||||
|
||||
"ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="],
|
||||
|
||||
"any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="],
|
||||
|
||||
"any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="],
|
||||
|
||||
"anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="],
|
||||
@@ -2621,8 +2562,6 @@
|
||||
|
||||
"avvio": ["avvio@9.2.0", "", { "dependencies": { "@fastify/error": "^4.0.0", "fastq": "^1.17.1" } }, "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ=="],
|
||||
|
||||
"await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="],
|
||||
|
||||
"aws-sdk": ["aws-sdk@2.1692.0", "", { "dependencies": { "buffer": "4.9.2", "events": "1.1.1", "ieee754": "1.1.13", "jmespath": "0.16.0", "querystring": "0.2.0", "sax": "1.2.1", "url": "0.10.3", "util": "^0.12.4", "uuid": "8.0.0", "xml2js": "0.6.2" } }, "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw=="],
|
||||
|
||||
"aws-ssl-profiles": ["aws-ssl-profiles@1.1.2", "", {}, "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g=="],
|
||||
@@ -2687,8 +2626,6 @@
|
||||
|
||||
"blob-to-buffer": ["blob-to-buffer@1.2.9", "", {}, "sha512-BF033y5fN6OCofD3vgHmNtwZWRcq9NLyyxyILx9hfMy1sXYy4ojFl765hJ2lP0YaN2fuxPaLO2Vzzoxy0FLFFA=="],
|
||||
|
||||
"bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="],
|
||||
|
||||
"body-parser": ["body-parser@1.20.4", "", { "dependencies": { "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", "destroy": "~1.2.0", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "on-finished": "~2.4.1", "qs": "~6.14.0", "raw-body": "~2.5.3", "type-is": "~1.6.18", "unpipe": "~1.0.0" } }, "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA=="],
|
||||
|
||||
"bonjour-service": ["bonjour-service@1.3.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "multicast-dns": "^7.2.5" } }, "sha512-3YuAUiSkWykd+2Azjgyxei8OWf8thdn8AITIog2M4UICzoqfjlqr64WIjEXZllf/W6vK1goqleSR6brGomxQqA=="],
|
||||
@@ -2731,16 +2668,6 @@
|
||||
|
||||
"bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="],
|
||||
|
||||
"bun-webgpu": ["bun-webgpu@0.1.5", "", { "dependencies": { "@webgpu/types": "^0.1.60" }, "optionalDependencies": { "bun-webgpu-darwin-arm64": "^0.1.5", "bun-webgpu-darwin-x64": "^0.1.5", "bun-webgpu-linux-x64": "^0.1.5", "bun-webgpu-win32-x64": "^0.1.5" } }, "sha512-91/K6S5whZKX7CWAm9AylhyKrLGRz6BUiiPiM/kXadSnD4rffljCD/q9cNFftm5YXhx4MvLqw33yEilxogJvwA=="],
|
||||
|
||||
"bun-webgpu-darwin-arm64": ["bun-webgpu-darwin-arm64@0.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-lIsDkPzJzPl6yrB5CUOINJFPnTRv6fF/Q8J1mAr43ogSp86WZEg9XZKaT6f3EUJ+9ETogGoMnoj1q0AwHUTbAQ=="],
|
||||
|
||||
"bun-webgpu-darwin-x64": ["bun-webgpu-darwin-x64@0.1.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-uEddf5U7GvKIkM/BV18rUKtYHL6d0KeqBjNHwfqDH9QgEo9KVSKvJXS5I/sMefk5V5pIYE+8tQhtrREevhocng=="],
|
||||
|
||||
"bun-webgpu-linux-x64": ["bun-webgpu-linux-x64@0.1.6", "", { "os": "linux", "cpu": "x64" }, "sha512-Y/f15j9r8ba0xUz+3lATtS74OE+PPzQXO7Do/1eCluJcuOlfa77kMjvBK/ShWnem3Y9xqi59pebTPOGRB+CaJA=="],
|
||||
|
||||
"bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-MHSFAKqizISb+C5NfDrFe3g0Al5Njnu0j/A+oO2Q+bIWX+fUYjBSowiYE1ZXJx65KuryuB+tiM7Qh6cQbVvkEg=="],
|
||||
|
||||
"bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="],
|
||||
|
||||
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
|
||||
@@ -2811,8 +2738,6 @@
|
||||
|
||||
"cli-cursor": ["cli-cursor@3.1.0", "", { "dependencies": { "restore-cursor": "^3.1.0" } }, "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw=="],
|
||||
|
||||
"cli-sound": ["cli-sound@1.1.3", "", { "dependencies": { "find-exec": "^1.0.3" }, "bin": { "cli-sound": "dist/esm/cli.js" } }, "sha512-dpdF3KS3wjo1fobKG5iU9KyKqzQWAqueymHzZ9epus/dZ40487gAvS6aXFeBul+GiQAQYUTAtUWgQvw6Jftbyg=="],
|
||||
|
||||
"cli-spinners": ["cli-spinners@3.4.0", "", {}, "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw=="],
|
||||
|
||||
"cli-truncate": ["cli-truncate@4.0.0", "", { "dependencies": { "slice-ansi": "^5.0.0", "string-width": "^7.0.0" } }, "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA=="],
|
||||
@@ -3163,8 +3088,6 @@
|
||||
|
||||
"execa": ["execa@8.0.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^8.0.1", "human-signals": "^5.0.0", "is-stream": "^3.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^5.1.0", "onetime": "^6.0.0", "signal-exit": "^4.1.0", "strip-final-newline": "^3.0.0" } }, "sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg=="],
|
||||
|
||||
"exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="],
|
||||
|
||||
"exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="],
|
||||
|
||||
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
|
||||
@@ -3227,8 +3150,6 @@
|
||||
|
||||
"fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
|
||||
|
||||
"file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="],
|
||||
|
||||
"filelist": ["filelist@1.0.6", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA=="],
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
@@ -3237,8 +3158,6 @@
|
||||
|
||||
"find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
|
||||
|
||||
"find-exec": ["find-exec@1.0.3", "", { "dependencies": { "shell-quote": "^1.8.1" } }, "sha512-gnG38zW90mS8hm5smNcrBnakPEt+cGJoiMkJwCU0IYnEb0H2NQk0NIljhNW+48oniCriFek/PH6QXbwsJo/qug=="],
|
||||
|
||||
"find-my-way": ["find-my-way@9.5.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-VW2RfnmscZO5KgBY5XVyKREMW5nMZcxDy+buTOsL+zIPnBlbKm+00sgzoQzq1EVh4aALZLfKdwv6atBGcjvjrQ=="],
|
||||
|
||||
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
|
||||
@@ -3321,8 +3240,6 @@
|
||||
|
||||
"ghostty-web": ["ghostty-web@github:anomalyco/ghostty-web#20bd361", {}, "anomalyco-ghostty-web-20bd361", "sha512-dW0nwaiBBcun9y5WJSvm3HxDLe5o9V0xLCndQvWonRVubU8CS1PHxZpLffyPt1YujPWC13ez03aWxcuKBPYYGQ=="],
|
||||
|
||||
"gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="],
|
||||
|
||||
"giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="],
|
||||
|
||||
"github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="],
|
||||
@@ -3475,8 +3392,6 @@
|
||||
|
||||
"ignore-walk": ["ignore-walk@8.0.0", "", { "dependencies": { "minimatch": "^10.0.3" } }, "sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A=="],
|
||||
|
||||
"image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="],
|
||||
|
||||
"immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"import-local": ["import-local@3.2.0", "", { "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" }, "bin": { "import-local-fixture": "fixtures/cli.js" } }, "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA=="],
|
||||
@@ -3619,16 +3534,12 @@
|
||||
|
||||
"jake": ["jake@10.9.4", "", { "dependencies": { "async": "^3.2.6", "filelist": "^1.0.4", "picocolors": "^1.1.1" }, "bin": { "jake": "bin/cli.js" } }, "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA=="],
|
||||
|
||||
"jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
|
||||
|
||||
"jose": ["jose@6.0.11", "", {}, "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg=="],
|
||||
|
||||
"jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="],
|
||||
|
||||
"js-base64": ["js-base64@3.7.7", "", {}, "sha512-7rCnleh0z2CkXhH67J8K1Ytz0b2Y+yxTPL+/KOJoa20hfnVQ/3/T6W/KflYI4bRHRagNeXeU2bkNGI3v1oS/lw=="],
|
||||
|
||||
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
|
||||
@@ -4089,8 +4000,6 @@
|
||||
|
||||
"oidc-token-hash": ["oidc-token-hash@5.2.0", "", {}, "sha512-6gj2m8cJZ+iSW8bm0FXdGF0YhIQbKrfP4yWTNzxc31U6MOjfEmB1rHvlYvxI1B7t7BCi1F2vYTT6YhtQRG4hxw=="],
|
||||
|
||||
"omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="],
|
||||
|
||||
"on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="],
|
||||
|
||||
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
|
||||
@@ -4165,12 +4074,6 @@
|
||||
|
||||
"param-case": ["param-case@3.0.4", "", { "dependencies": { "dot-case": "^3.0.4", "tslib": "^2.0.3" } }, "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A=="],
|
||||
|
||||
"parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="],
|
||||
|
||||
"parse-bmfont-binary": ["parse-bmfont-binary@1.0.6", "", {}, "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA=="],
|
||||
|
||||
"parse-bmfont-xml": ["parse-bmfont-xml@1.1.6", "", { "dependencies": { "xml-parse-from-string": "^1.0.0", "xml2js": "^0.5.0" } }, "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA=="],
|
||||
|
||||
"parse-conflict-json": ["parse-conflict-json@5.0.1", "", { "dependencies": { "json-parse-even-better-errors": "^5.0.0", "just-diff": "^6.0.0", "just-diff-apply": "^5.2.0" } }, "sha512-ZHEmNKMq1wyJXNwLxyHnluPfRAFSIliBvbK/UiOceROt4Xh9Pz0fq49NytIaeaCUf5VR86hwQ/34FCcNU5/LKQ=="],
|
||||
|
||||
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
|
||||
@@ -4215,8 +4118,6 @@
|
||||
|
||||
"peberminta": ["peberminta@0.9.0", "", {}, "sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ=="],
|
||||
|
||||
"peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="],
|
||||
@@ -4237,8 +4138,6 @@
|
||||
|
||||
"pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="],
|
||||
|
||||
"pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
|
||||
"pkg-dir": ["pkg-dir@4.2.0", "", { "dependencies": { "find-up": "^4.0.0" } }, "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ=="],
|
||||
@@ -4247,16 +4146,12 @@
|
||||
|
||||
"pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
|
||||
|
||||
"planck": ["planck@1.5.0", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-dlvqJE+FscZgrGUXJ5ybd0o5bvZ5XXyZNbm08xGsXp9WjXeAyWSFT6n9s/1PQcUBo4546fDXA5RMA4wbDyZw6g=="],
|
||||
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],
|
||||
|
||||
"pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="],
|
||||
|
||||
"poe-oauth": ["poe-oauth@0.0.6", "", {}, "sha512-dI8xrVl7RSFh0B+cb4GGuCjIfGtDT9VpbpVkP0UKcunpXF0eFw+6GencoJ7k+E02ZYqopBQApMVWGq70/GP69w=="],
|
||||
|
||||
"possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="],
|
||||
@@ -4381,8 +4276,6 @@
|
||||
|
||||
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
|
||||
|
||||
"readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="],
|
||||
|
||||
"readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="],
|
||||
|
||||
"readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
@@ -4567,8 +4460,6 @@
|
||||
|
||||
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
|
||||
|
||||
"shiki": ["shiki@3.20.0", "", { "dependencies": { "@shikijs/core": "3.20.0", "@shikijs/engine-javascript": "3.20.0", "@shikijs/engine-oniguruma": "3.20.0", "@shikijs/langs": "3.20.0", "@shikijs/themes": "3.20.0", "@shikijs/types": "3.20.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-kgCOlsnyWb+p0WU+01RjkCH+eBVsjL1jOwUYWv0YDWkM2/A46+LDKVs5yZCUXjJG6bj4ndFoAg5iLIIue6dulg=="],
|
||||
|
||||
"shikiji": ["shikiji@0.6.13", "", { "dependencies": { "hast-util-to-html": "^9.0.0" } }, "sha512-4T7X39csvhT0p7GDnq9vysWddf2b6BeioiN3Ymhnt3xcy9tXmDcnsEFVxX18Z4YcQgEE/w48dLJ4pPPUcG9KkA=="],
|
||||
@@ -4591,8 +4482,6 @@
|
||||
|
||||
"simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="],
|
||||
|
||||
"simple-xml-to-json": ["simple-xml-to-json@1.2.7", "", {}, "sha512-mz9VXphOxQWX3eQ/uXCtm6upltoN0DLx8Zb5T4TFC4FHB7S9FDPGre8CfLWqPWQQH/GrQYd2AXhhVM5LDpYx6Q=="],
|
||||
|
||||
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
|
||||
|
||||
"sitemap": ["sitemap@9.0.1", "", { "dependencies": { "@types/node": "^24.9.2", "@types/sax": "^1.2.1", "arg": "^5.0.0", "sax": "^1.4.1" }, "bin": { "sitemap": "dist/esm/cli.js" } }, "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ=="],
|
||||
@@ -4679,8 +4568,6 @@
|
||||
|
||||
"stackframe": ["stackframe@1.3.4", "", {}, "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw=="],
|
||||
|
||||
"stage-js": ["stage-js@1.0.2", "", {}, "sha512-EWTRBYlg7Qv9wGUao99/PfRe3KaiQqWmgSvTOXvaWnu1Jk/q/vV8yJVu6bi/3EqDZeMVnCPAjheba6OFc5k1GQ=="],
|
||||
|
||||
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
|
||||
|
||||
"stat-mode": ["stat-mode@1.0.0", "", {}, "sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg=="],
|
||||
@@ -4729,8 +4616,6 @@
|
||||
|
||||
"strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="],
|
||||
|
||||
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
|
||||
|
||||
"stubborn-fs": ["stubborn-fs@2.0.0", "", { "dependencies": { "stubborn-utils": "^1.0.1" } }, "sha512-Y0AvSwDw8y+nlSNFXMm2g6L51rBGdAQT20J3YSOqxC53Lo3bjWRtr2BKcfYoAf352WYpsZSTURrA0tqhfgudPA=="],
|
||||
|
||||
"stubborn-utils": ["stubborn-utils@1.0.2", "", {}, "sha512-zOh9jPYI+xrNOyisSelgym4tolKTJCQd5GBhK0+0xJvcYDcwlOoxF/rnFKQ2KRZknXSG9jWAp66fwP6AxN9STg=="],
|
||||
@@ -4783,8 +4668,6 @@
|
||||
|
||||
"thread-stream": ["thread-stream@4.0.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA=="],
|
||||
|
||||
"three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="],
|
||||
|
||||
"thunky": ["thunky@1.1.0", "", {}, "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA=="],
|
||||
|
||||
"tiny-async-pool": ["tiny-async-pool@1.3.0", "", { "dependencies": { "semver": "^5.5.0" } }, "sha512-01EAw5EDrcVrdgyCLgoSPvqznC0sVxDSVeiOz09FUpjh71G79VCqneOr+xvt7T1r76CF6ZZfPjHorN2+d+3mqA=="],
|
||||
@@ -4797,8 +4680,6 @@
|
||||
|
||||
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
|
||||
|
||||
"tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="],
|
||||
|
||||
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||
@@ -4819,8 +4700,6 @@
|
||||
|
||||
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
|
||||
|
||||
"token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="],
|
||||
|
||||
"toml": ["toml@4.1.1", "", {}, "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw=="],
|
||||
|
||||
"toolbeam-docs-theme": ["toolbeam-docs-theme@0.4.8", "", { "peerDependencies": { "@astrojs/starlight": "^0.34.3", "astro": "^5.7.13" } }, "sha512-b+5ynEFp4Woe5a22hzNQm42lD23t13ZMihVxHbzjA50zdcM9aOSJTIjdJ0PDSd4/50HbBXcpHiQsz6rM4N88ww=="],
|
||||
@@ -4973,8 +4852,6 @@
|
||||
|
||||
"utf8-byte-length": ["utf8-byte-length@1.0.5", "", {}, "sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA=="],
|
||||
|
||||
"utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="],
|
||||
|
||||
"util": ["util@0.12.5", "", { "dependencies": { "inherits": "^2.0.3", "is-arguments": "^1.0.4", "is-generator-function": "^1.0.7", "is-typed-array": "^1.1.3", "which-typed-array": "^1.1.2" } }, "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA=="],
|
||||
|
||||
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
|
||||
@@ -5103,8 +4980,6 @@
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
@@ -5457,46 +5332,10 @@
|
||||
|
||||
"@gitlab/opencode-gitlab-auth/open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="],
|
||||
|
||||
"@happy-dom/global-registrator/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="],
|
||||
|
||||
"@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
|
||||
|
||||
"@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-color/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-contain/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-cover/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-crop/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-displace/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-fisheye/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-flip/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-mask/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-print/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-quantize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-resize/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-rotate/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/plugin-threshold/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jimp/types/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@jsx-email/cli/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"@jsx-email/cli/esbuild": ["esbuild@0.19.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.19.12", "@esbuild/android-arm": "0.19.12", "@esbuild/android-arm64": "0.19.12", "@esbuild/android-x64": "0.19.12", "@esbuild/darwin-arm64": "0.19.12", "@esbuild/darwin-x64": "0.19.12", "@esbuild/freebsd-arm64": "0.19.12", "@esbuild/freebsd-x64": "0.19.12", "@esbuild/linux-arm": "0.19.12", "@esbuild/linux-arm64": "0.19.12", "@esbuild/linux-ia32": "0.19.12", "@esbuild/linux-loong64": "0.19.12", "@esbuild/linux-mips64el": "0.19.12", "@esbuild/linux-ppc64": "0.19.12", "@esbuild/linux-riscv64": "0.19.12", "@esbuild/linux-s390x": "0.19.12", "@esbuild/linux-x64": "0.19.12", "@esbuild/netbsd-x64": "0.19.12", "@esbuild/openbsd-x64": "0.19.12", "@esbuild/sunos-x64": "0.19.12", "@esbuild/win32-arm64": "0.19.12", "@esbuild/win32-ia32": "0.19.12", "@esbuild/win32-x64": "0.19.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg=="],
|
||||
@@ -5635,24 +5474,16 @@
|
||||
|
||||
"@slack/bolt/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="],
|
||||
|
||||
"@slack/logger/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@slack/oauth/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
|
||||
"@slack/oauth/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@slack/socket-mode/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
|
||||
"@slack/socket-mode/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@slack/socket-mode/@types/ws": ["@types/ws@7.4.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww=="],
|
||||
|
||||
"@slack/socket-mode/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="],
|
||||
|
||||
"@slack/web-api/@slack/logger": ["@slack/logger@3.0.0", "", { "dependencies": { "@types/node": ">=12.0.0" } }, "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA=="],
|
||||
|
||||
"@slack/web-api/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@slack/web-api/eventemitter3": ["eventemitter3@3.1.2", "", {}, "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="],
|
||||
|
||||
"@slack/web-api/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="],
|
||||
@@ -5713,62 +5544,8 @@
|
||||
|
||||
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
|
||||
|
||||
"@types/body-parser/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/cacache/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/cacheable-request/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/connect/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/cross-spawn/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/express-serve-static-core/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/fontkit/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/fs-extra/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/is-stream/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/jsonwebtoken/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/keyv/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/mssql/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/node-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/npm-registry-fetch/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/npmcli__arborist/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/npmlog/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/pacote/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/plist/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/plist/xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||
|
||||
"@types/readable-stream/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/responselike/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/sax/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/send/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/serve-static/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/ssri/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/tunnel/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/ws/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@types/yauzl/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils": ["@vitest/utils@3.2.4", "", { "dependencies": { "@vitest/pretty-format": "3.2.4", "loupe": "^3.1.4", "tinyrainbow": "^2.0.0" } }, "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA=="],
|
||||
|
||||
"@vitest/expect/tinyrainbow": ["tinyrainbow@2.0.0", "", {}, "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw=="],
|
||||
@@ -5845,18 +5622,12 @@
|
||||
|
||||
"builder-util-runtime/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||
|
||||
"bun-types/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"bun-webgpu/@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
|
||||
|
||||
"c12/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
|
||||
|
||||
"c12/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
|
||||
|
||||
"clone-response/mimic-response": ["mimic-response@1.0.1", "", {}, "sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ=="],
|
||||
|
||||
"cloudflare/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"compress-commons/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
|
||||
|
||||
"condense-newlines/kind-of": ["kind-of@3.2.2", "", { "dependencies": { "is-buffer": "^1.1.5" } }, "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ=="],
|
||||
@@ -5891,8 +5662,6 @@
|
||||
|
||||
"effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"electron/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"electron-builder/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"electron-builder/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
|
||||
@@ -5947,8 +5716,6 @@
|
||||
|
||||
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
|
||||
|
||||
"happy-dom/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"happy-dom/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"html-minifier-terser/commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
|
||||
@@ -5961,8 +5728,6 @@
|
||||
|
||||
"iconv-corefoundation/node-addon-api": ["node-addon-api@1.7.2", "", {}, "sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg=="],
|
||||
|
||||
"image-q/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"js-beautify/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
|
||||
|
||||
"js-beautify/nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
|
||||
@@ -6035,9 +5800,9 @@
|
||||
|
||||
"openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core": ["@opentui/core@0.1.105", "", { "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.105", "@opentui/core-darwin-x64": "0.1.105", "@opentui/core-linux-arm64": "0.1.105", "@opentui/core-linux-x64": "0.1.105", "@opentui/core-win32-arm64": "0.1.105", "@opentui/core-win32-x64": "0.1.105", "bun-webgpu": "0.1.5", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-vllSOOCW6VIThV/96GRLJ1IxIBuR+ci6FDvnPIAG4s7SJ/FW6zAkqDn1xrtBwwk/lM3QWjLqy8BZc+zwWvveJA=="],
|
||||
"opentui-spinner/@opentui/core": ["@opentui/core@0.2.7", "", { "dependencies": { "bun-ffi-structs": "0.2.2", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.2.7", "@opentui/core-darwin-x64": "0.2.7", "@opentui/core-linux-arm64": "0.2.7", "@opentui/core-linux-x64": "0.2.7", "@opentui/core-win32-arm64": "0.2.7", "@opentui/core-win32-x64": "0.2.7" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-cnN6JcaGC7SeQzobBy/CHzqUAQFtypazuw1CjQBo7WwoOiLMGubt9W5FXeF0zIrSxH2Ed6NLWhPYRg7SD4629Q=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.1.105", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.1.105", "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-uxnaMP802sCI487pv/Hk9xdFdIj9mkg3eNliAqbqR0Shmd4phcjKEZvPRpijjmI99j4s9nul71jzF3h1oz31Nw=="],
|
||||
"opentui-spinner/@opentui/solid": ["@opentui/solid@0.2.7", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.2.7", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-nlkx9HvuWaHtc5A8eUEAPNi+5+37LZS3ln73WRmtT5xin8LnQf+yhwopqGgPSnLq1ODLwhkKRdr/9JCDr2j7Bg=="],
|
||||
|
||||
"ora/bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="],
|
||||
|
||||
@@ -6051,14 +5816,10 @@
|
||||
|
||||
"p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
|
||||
|
||||
"parse-bmfont-xml/xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="],
|
||||
|
||||
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
|
||||
|
||||
"parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
|
||||
|
||||
"pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="],
|
||||
|
||||
"pkg-dir/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="],
|
||||
|
||||
"pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
|
||||
@@ -6083,8 +5844,6 @@
|
||||
|
||||
"proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
|
||||
|
||||
"protobufjs/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"raw-body/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="],
|
||||
|
||||
"readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
||||
@@ -6115,8 +5874,6 @@
|
||||
|
||||
"shiki/@shikijs/types": ["@shikijs/types@3.20.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-lhYAATn10nkZcBQ0BlzSbJA3wcmL5MXUUF8d2Zzon6saZDlToKaiRX60n2+ZaHJCmXEcZRWNzn+k9vplr8Jhsw=="],
|
||||
|
||||
"sitemap/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"sitemap/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||
|
||||
"slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
|
||||
@@ -6139,20 +5896,14 @@
|
||||
|
||||
"strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"stripe/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"sucrase/commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="],
|
||||
|
||||
"tar/yallist": ["yallist@5.0.0", "", {}, "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw=="],
|
||||
|
||||
"tedious/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"terser/commander": ["commander@2.20.3", "", {}, "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ=="],
|
||||
|
||||
"tiny-async-pool/semver": ["semver@5.7.2", "", { "bin": { "semver": "bin/semver" } }, "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g=="],
|
||||
|
||||
"token-types/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"tree-sitter-bash/node-addon-api": ["node-addon-api@8.7.0", "", {}, "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA=="],
|
||||
|
||||
"tw-to-css/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="],
|
||||
@@ -6169,8 +5920,6 @@
|
||||
|
||||
"uri-js/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||
|
||||
"utif2/pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="],
|
||||
|
||||
"venice-ai-sdk-provider/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="],
|
||||
|
||||
"vite-plugin-icons-spritesheet/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
|
||||
@@ -6485,8 +6234,6 @@
|
||||
|
||||
"@gitlab/opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"@happy-dom/global-registrator/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@jsx-email/cli/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.19.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA=="],
|
||||
|
||||
"@jsx-email/cli/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.19.12", "", { "os": "android", "cpu": "arm" }, "sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w=="],
|
||||
@@ -6681,14 +6428,6 @@
|
||||
|
||||
"@sentry/cli/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
|
||||
"@slack/logger/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@slack/oauth/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@slack/socket-mode/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@slack/web-api/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@slack/web-api/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="],
|
||||
@@ -6715,60 +6454,6 @@
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@types/body-parser/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/cacache/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/cacheable-request/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/connect/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/cross-spawn/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/express-serve-static-core/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/fontkit/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/fs-extra/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/is-stream/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/jsonwebtoken/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/keyv/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/mssql/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/node-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/npm-registry-fetch/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/npmcli__arborist/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/npmlog/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/pacote/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/plist/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/readable-stream/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/responselike/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/sax/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/send/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/serve-static/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/ssri/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/tunnel/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/ws/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@types/yauzl/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="],
|
||||
|
||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
@@ -6817,12 +6502,8 @@
|
||||
|
||||
"body-parser/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"bun-types/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="],
|
||||
|
||||
"cloudflare/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"crc/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||
@@ -6841,8 +6522,6 @@
|
||||
|
||||
"electron-winstaller/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||
|
||||
"electron/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"esbuild-plugin-copy/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
@@ -6855,14 +6534,10 @@
|
||||
|
||||
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
|
||||
|
||||
"happy-dom/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate/slice-ansi": ["slice-ansi@3.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "astral-regex": "^2.0.0", "is-fullwidth-code-point": "^3.0.0" } }, "sha512-pSyv7bSTC7ig9Dcgbw9AuRNUb5k5V6oDudjZoMBSr13qpLBG7tB+zgCkARjq7xIUgdz5P1Qe8u+rSGdouOOIyQ=="],
|
||||
|
||||
"iconv-corefoundation/cli-truncate/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"image-q/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"js-beautify/glob/jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
|
||||
|
||||
"js-beautify/glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
|
||||
@@ -6881,8 +6556,6 @@
|
||||
|
||||
"motion/framer-motion/motion-utils": ["motion-utils@12.36.0", "", {}, "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg=="],
|
||||
|
||||
"mssql/tedious/@types/node": ["@types/node@22.13.9", "", { "dependencies": { "undici-types": "~6.20.0" } }, "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw=="],
|
||||
|
||||
"mssql/tedious/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
|
||||
|
||||
"opencode-gitlab-auth/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
@@ -6901,24 +6574,22 @@
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.105", "", { "os": "darwin", "cpu": "arm64" }, "sha512-1pIL7aer9amwj8EpYoMNtvavKetIe+nX8uBRmYsMQb+KvJoUAZUqENfRW+qHE5WrsOyxx8/QoyXTHw15GG5iLQ=="],
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.2.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CAy6cL3byz2Xf6gFiJHBpcnsp/2ADEWLLOUokVypOyPLcy8GY3sPzlA4pkAjVGQMYQhDj+Y3+SXz4uTLt4AETg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.105", "", { "os": "darwin", "cpu": "x64" }, "sha512-hLIRSWlK3gY2NRXJGWiTBiMYSmRDjOYFZF6WtUVXhY2SL3sp08dhmr/6dmAVH+3pKCsCipLEsrrcQX6SAihCTA=="],
|
||||
"opentui-spinner/@opentui/core/@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.2.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-K06h333rMkC9cyMJr/VvcRK3ik81Admd8ZsES5uf5YXWPdYhXGf75I1T8mKIThhUmoFLb8R5xqfuPmoocsjM7Q=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.105", "", { "os": "linux", "cpu": "arm64" }, "sha512-jlRKfPkozTZEkHEePuCWYcTIUtPm+ieInAwGVqGmjbvqjxdVv1/W/Dt6LEZ/9jpRiOPd+FjXAfLe6wa/XWHr+w=="],
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.2.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-iYWGTztbdG9yYSB5Alxuo0dWAmkWQR0+/paNWUyPOocjigmKgMmACDtHgYqa7sxkIcWgmXljt/f8rgXDG4wdMg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.105", "", { "os": "linux", "cpu": "x64" }, "sha512-kfWS1WMg6qHShmxZX9s1tZc/8JcXw6uyy2UtyTbJdRFExtXGH37oKHi8QK8iPL2ExCx4z7zqVnVJfO3X/Wh7lA=="],
|
||||
"opentui-spinner/@opentui/core/@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.2.7", "", { "os": "linux", "cpu": "x64" }, "sha512-tymBCfYbsDRfHQNXsolkFfaTEIDhemD4+1ZovUztQd7i+0Ggnu9WbPN1SNCiRz6PjrlaNeQzZE3Wl8FfVdw/cw=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.105", "", { "os": "win32", "cpu": "arm64" }, "sha512-UFx6A8OpBVbGWK6OAw4GqAqKZgIITJfSOd35pG9yDVKQouHN2OGc2HeeXrH2A4h42p40Xl6IfcqqfllkpC13Dg=="],
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.2.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-XLPJWdT8QOukrYDkpIng6+uNUlF66ByXcQlC3qA9JbrUTBetZhgXs8Q2jEjRfc+Ty3uh1iRSA6PgJGbbOK/f4Q=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.105", "", { "os": "win32", "cpu": "x64" }, "sha512-f9FqqUmxehwhF+cgyazm0YT0v0BYTTCPzd6eztqhl74N3x/kC+jOOz2rdJDC/tTBo1JVsF64KupOnhIs6/Cogg=="],
|
||||
"opentui-spinner/@opentui/core/@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.2.7", "", { "os": "win32", "cpu": "x64" }, "sha512-CzVGEfqysVk8Hxcj0RDv/DtXIM6iZmbmr23kW7y8CJMPtmV1gmKI4D9abVjynWJnGbaSBnDi43mgZnGMgOdyEg=="],
|
||||
|
||||
"opentui-spinner/@opentui/core/bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
|
||||
"opentui-spinner/@opentui/core/diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
|
||||
|
||||
"opentui-spinner/@opentui/solid/babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="],
|
||||
|
||||
"ora/bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
|
||||
|
||||
"ora/bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
@@ -6927,14 +6598,10 @@
|
||||
|
||||
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||
|
||||
"parse-bmfont-xml/xml2js/sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||
|
||||
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],
|
||||
|
||||
"pkg-up/find-up/locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
|
||||
|
||||
"protobufjs/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"readable-stream/buffer/ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
|
||||
|
||||
"readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="],
|
||||
@@ -6945,16 +6612,10 @@
|
||||
|
||||
"send/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="],
|
||||
|
||||
"sitemap/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"storybook/open/wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="],
|
||||
|
||||
"string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||
|
||||
"stripe/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"tedious/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"tw-to-css/tailwindcss/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
|
||||
|
||||
"tw-to-css/tailwindcss/glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||
@@ -7257,8 +6918,6 @@
|
||||
|
||||
"js-beautify/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||
|
||||
"mssql/tedious/@types/node/undici-types": ["undici-types@6.20.0", "", {}, "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"opencontrol/@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
exact = true
|
||||
# Only install newly resolved package versions published at least 3 days ago.
|
||||
minimumReleaseAge = 259200
|
||||
minimumReleaseAgeExcludes = ["@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-x64", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid"]
|
||||
|
||||
[test]
|
||||
root = "./do-not-run-tests-from-root"
|
||||
|
||||
@@ -293,3 +293,12 @@ new sst.cloudflare.x.SolidStart("Console", {
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
////////////////
|
||||
// HELPERS
|
||||
////////////////
|
||||
|
||||
export const stat = new sst.cloudflare.Worker("Stat", {
|
||||
handler: "packages/console/function/src/stat.ts",
|
||||
link: [database],
|
||||
})
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-MUHog06sZEi6bXR1m8exdkjSNW9bHEv9bPQXACJ7SFw=",
|
||||
"aarch64-linux": "sha256-3dwdZ3It++OsdGT8xMOQ10Arz8eeODp/LXOrI4DLEhY=",
|
||||
"aarch64-darwin": "sha256-TmUPGDCewjsrT13npVH6B55J43NKKut67p/HgPJpQNM=",
|
||||
"x86_64-darwin": "sha256-j8I7t3MZoUQUMFRWyaFO75TRbAw5TauSZAa4yKOHFMA="
|
||||
"x86_64-linux": "sha256-CF+ecq3PqUuA7ta09oYkDaFz+FxZqQragu9xvtw++RE=",
|
||||
"aarch64-linux": "sha256-QVdgdCncl+XEIx569oNNRXyxRh5mkEMJSTSO1I3QfzM=",
|
||||
"aarch64-darwin": "sha256-dfP1SEpgQ63DEfG4eRD7BWGoMPFmjGSWiW4M0J4Asfg=",
|
||||
"x86_64-darwin": "sha256-JgXg+BwZTWroChnxE6akoEFfdb7vVH0qBMZSZ7TJhDU="
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -35,9 +35,9 @@
|
||||
"@types/cross-spawn": "6.0.6",
|
||||
"@octokit/rest": "22.0.0",
|
||||
"@hono/zod-validator": "0.4.2",
|
||||
"@opentui/core": "0.2.6",
|
||||
"@opentui/keymap": "0.2.6",
|
||||
"@opentui/solid": "0.2.6",
|
||||
"@opentui/core": "0.2.8",
|
||||
"@opentui/keymap": "0.2.8",
|
||||
"@opentui/solid": "0.2.8",
|
||||
"ulid": "3.0.1",
|
||||
"@kobalte/core": "0.13.11",
|
||||
"@types/luxon": "3.7.1",
|
||||
@@ -128,6 +128,9 @@
|
||||
"electron"
|
||||
],
|
||||
"overrides": {
|
||||
"@opentui/core": "catalog:",
|
||||
"@opentui/keymap": "catalog:",
|
||||
"@opentui/solid": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@types/node": "catalog:"
|
||||
},
|
||||
|
||||
@@ -166,18 +166,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => {
|
||||
})
|
||||
const isWorking = createMemo(() => {
|
||||
if (hasPermissions()) return false
|
||||
const pending = (sessionStore.message[props.session.id] ?? []).findLast(
|
||||
(message) =>
|
||||
message.role === "assistant" &&
|
||||
typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number",
|
||||
)
|
||||
// This matches how the TUI does it
|
||||
const status = sessionStore.session_status[props.session.id]
|
||||
return (
|
||||
pending !== undefined ||
|
||||
status?.type === "busy" ||
|
||||
status?.type === "retry" ||
|
||||
(status !== undefined && status.type !== "idle")
|
||||
)
|
||||
return status?.type === "busy" || status?.type === "retry"
|
||||
})
|
||||
|
||||
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.session.id], sessionStore.agent))
|
||||
|
||||
@@ -1497,10 +1497,8 @@ export default function Page() {
|
||||
})
|
||||
|
||||
const busy = (sessionID: string) => {
|
||||
if ((sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle") return true
|
||||
return (sync.data.message[sessionID] ?? []).some(
|
||||
(item) => item.role === "assistant" && typeof item.time.completed !== "number",
|
||||
)
|
||||
// This matches how the TUI does it
|
||||
return (sync.data.session_status[sessionID] ?? { type: "idle" as const }).type !== "idle"
|
||||
}
|
||||
|
||||
const queuedFollowups = createMemo(() => {
|
||||
|
||||
Vendored
+1
@@ -298,6 +298,7 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { WorkerEntrypoint } from "cloudflare:workers"
|
||||
import { and, Database, inArray } from "@opencode-ai/console-core/drizzle/index.js"
|
||||
import { ModelTpsRateLimitTable } from "@opencode-ai/console-core/schema/ip.sql.js"
|
||||
|
||||
type Result = Record<string, { qualify: number; unqualify: number }>
|
||||
|
||||
export default class Stat extends WorkerEntrypoint {
|
||||
async fetch() {
|
||||
return new Response("Not Found", { status: 404 })
|
||||
}
|
||||
|
||||
async getStats(ids: string[]): Promise<Result> {
|
||||
if (ids.length === 0) return {}
|
||||
|
||||
const toInterval = (date: Date) =>
|
||||
parseInt(
|
||||
date
|
||||
.toISOString()
|
||||
.replace(/[^0-9]/g, "")
|
||||
.substring(0, 12),
|
||||
)
|
||||
const now = Date.now()
|
||||
const intervals = Array.from({ length: 5 }, (_, i) => toInterval(new Date(now - i * 60 * 1000)))
|
||||
|
||||
const rows = await Database.use((tx) =>
|
||||
tx
|
||||
.select()
|
||||
.from(ModelTpsRateLimitTable)
|
||||
.where(and(inArray(ModelTpsRateLimitTable.id, ids), inArray(ModelTpsRateLimitTable.interval, intervals))),
|
||||
)
|
||||
|
||||
const result: Result = Object.fromEntries(ids.map((id) => [id, { qualify: 0, unqualify: 0 }]))
|
||||
for (const row of rows) {
|
||||
result[row.id].qualify += row.qualify
|
||||
result[row.id].unqualify += row.unqualify
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
+1
@@ -298,6 +298,7 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
+1
@@ -298,6 +298,7 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Context, Duration, Effect, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import type { PlatformError } from "effect/PlatformError"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
|
||||
import { CrossSpawnSpawner } from "./cross-spawn-spawner"
|
||||
|
||||
export class AppProcessError extends Schema.TaggedErrorClass<AppProcessError>()("AppProcessError", {
|
||||
command: Schema.String,
|
||||
exitCode: Schema.optional(Schema.Number),
|
||||
stderr: Schema.optional(Schema.String),
|
||||
cause: Schema.optional(Schema.Defect),
|
||||
}) {}
|
||||
|
||||
export interface RunOptions {
|
||||
readonly maxOutputBytes?: number
|
||||
readonly maxErrorBytes?: number
|
||||
readonly signal?: AbortSignal
|
||||
readonly timeout?: Duration.Input
|
||||
}
|
||||
|
||||
export interface RunStreamOptions {
|
||||
readonly signal?: AbortSignal
|
||||
readonly includeStderr?: boolean
|
||||
readonly okExitCodes?: ReadonlyArray<number>
|
||||
readonly maxErrorBytes?: number
|
||||
}
|
||||
|
||||
export interface RunResult {
|
||||
readonly command: string
|
||||
readonly exitCode: number
|
||||
readonly stdout: Buffer
|
||||
readonly stderr: Buffer
|
||||
readonly truncated: boolean
|
||||
}
|
||||
|
||||
export type Interface = ChildProcessSpawner["Service"] & {
|
||||
readonly run: (command: ChildProcess.Command, options?: RunOptions) => Effect.Effect<RunResult, AppProcessError>
|
||||
readonly runStream: (
|
||||
command: ChildProcess.Command,
|
||||
options?: RunStreamOptions,
|
||||
) => Stream.Stream<string, AppProcessError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/AppProcess") {}
|
||||
|
||||
export const requireSuccess = (result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
|
||||
result.exitCode === 0
|
||||
? Effect.succeed(result)
|
||||
: Effect.fail(
|
||||
new AppProcessError({
|
||||
command: result.command,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}),
|
||||
)
|
||||
|
||||
export const requireExitIn =
|
||||
(codes: ReadonlyArray<number>) =>
|
||||
(result: RunResult): Effect.Effect<RunResult, AppProcessError> =>
|
||||
codes.includes(result.exitCode)
|
||||
? Effect.succeed(result)
|
||||
: Effect.fail(
|
||||
new AppProcessError({
|
||||
command: result.command,
|
||||
exitCode: result.exitCode,
|
||||
stderr: result.stderr.toString("utf8"),
|
||||
}),
|
||||
)
|
||||
|
||||
const describeCommand = (command: ChildProcess.Command): string => {
|
||||
if (command._tag === "StandardCommand") {
|
||||
return command.args.length ? `${command.command} ${command.args.join(" ")}` : command.command
|
||||
}
|
||||
return `${describeCommand(command.left)} | ${describeCommand(command.right)}`
|
||||
}
|
||||
|
||||
const wrapError = (description: string, cause: unknown): AppProcessError =>
|
||||
cause instanceof AppProcessError ? cause : new AppProcessError({ command: description, cause })
|
||||
|
||||
const abortError = (signal: AbortSignal): Error => {
|
||||
const reason = signal.reason
|
||||
if (reason instanceof Error) return reason
|
||||
const err = new Error("Aborted")
|
||||
err.name = "AbortError"
|
||||
return err
|
||||
}
|
||||
|
||||
const waitForAbort = (signal: AbortSignal) =>
|
||||
Effect.callback<never, Error>((resume) => {
|
||||
if (signal.aborted) {
|
||||
resume(Effect.fail(abortError(signal)))
|
||||
return
|
||||
}
|
||||
const onabort = () => resume(Effect.fail(abortError(signal)))
|
||||
signal.addEventListener("abort", onabort, { once: true })
|
||||
return Effect.sync(() => signal.removeEventListener("abort", onabort))
|
||||
})
|
||||
|
||||
const collectStream = (stream: Stream.Stream<Uint8Array, PlatformError>, maxOutputBytes: number | undefined) =>
|
||||
Stream.runFold(
|
||||
stream,
|
||||
() => ({ chunks: [] as Uint8Array[], bytes: 0, truncated: false }),
|
||||
(acc, chunk) => {
|
||||
if (maxOutputBytes === undefined) {
|
||||
acc.chunks.push(chunk)
|
||||
acc.bytes += chunk.length
|
||||
return acc
|
||||
}
|
||||
const remaining = maxOutputBytes - acc.bytes
|
||||
if (remaining > 0) acc.chunks.push(remaining >= chunk.length ? chunk : chunk.slice(0, remaining))
|
||||
acc.bytes += chunk.length
|
||||
acc.truncated = acc.truncated || acc.bytes > maxOutputBytes
|
||||
return acc
|
||||
},
|
||||
).pipe(Effect.map((x) => ({ buffer: Buffer.concat(x.chunks), truncated: x.truncated })))
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const spawner = yield* ChildProcessSpawner
|
||||
|
||||
const run = Effect.fn("AppProcess.run")(function* (command: ChildProcess.Command, options?: RunOptions) {
|
||||
const description = describeCommand(command)
|
||||
const collect = Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const [stdout, stderr, exitCode] = yield* Effect.all(
|
||||
[
|
||||
collectStream(handle.stdout, options?.maxOutputBytes),
|
||||
collectStream(handle.stderr, options?.maxErrorBytes),
|
||||
handle.exitCode,
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
return {
|
||||
command: description,
|
||||
exitCode,
|
||||
stdout: stdout.buffer,
|
||||
stderr: stderr.buffer,
|
||||
truncated: stdout.truncated,
|
||||
} satisfies RunResult
|
||||
}),
|
||||
)
|
||||
const timed = options?.timeout
|
||||
? Effect.timeoutOrElse(collect, {
|
||||
duration: options.timeout,
|
||||
orElse: () => Effect.fail(new AppProcessError({ command: description, cause: new Error("Timed out") })),
|
||||
})
|
||||
: collect
|
||||
const aborted = options?.signal
|
||||
? timed.pipe(
|
||||
Effect.raceFirst(
|
||||
waitForAbort(options.signal).pipe(Effect.mapError((cause) => wrapError(description, cause))),
|
||||
),
|
||||
)
|
||||
: timed
|
||||
return yield* aborted.pipe(Effect.catch((cause) => Effect.fail(wrapError(description, cause))))
|
||||
})
|
||||
|
||||
const runStream = (
|
||||
command: ChildProcess.Command,
|
||||
options?: RunStreamOptions,
|
||||
): Stream.Stream<string, AppProcessError> => {
|
||||
const description = describeCommand(command)
|
||||
const okExitCodes = options?.okExitCodes
|
||||
const built: Stream.Stream<string, AppProcessError | PlatformError> = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const handle = yield* spawner.spawn(command)
|
||||
const stderrFiber = yield* Effect.forkScoped(
|
||||
collectStream(handle.stderr, options?.maxErrorBytes).pipe(Effect.map((x) => x.buffer.toString("utf8"))),
|
||||
)
|
||||
const source = options?.includeStderr === true ? handle.all : handle.stdout
|
||||
const lines = source.pipe(
|
||||
Stream.decodeText,
|
||||
Stream.splitLines,
|
||||
Stream.filter((line) => line.length > 0),
|
||||
)
|
||||
const tail = Stream.unwrap(
|
||||
Effect.gen(function* () {
|
||||
const code = yield* handle.exitCode
|
||||
if (okExitCodes && okExitCodes.length > 0 && !okExitCodes.includes(code)) {
|
||||
const stderr = yield* Fiber.join(stderrFiber)
|
||||
return Stream.fail(new AppProcessError({ command: description, exitCode: code, stderr }))
|
||||
}
|
||||
return Stream.empty
|
||||
}),
|
||||
)
|
||||
return Stream.concat(lines, tail) as Stream.Stream<string, AppProcessError | PlatformError>
|
||||
}),
|
||||
)
|
||||
const mapped = built.pipe(
|
||||
Stream.catch((cause): Stream.Stream<string, AppProcessError> => Stream.fail(wrapError(description, cause))),
|
||||
)
|
||||
if (!options?.signal) return mapped
|
||||
const signal = options.signal
|
||||
return mapped.pipe(
|
||||
Stream.interruptWhen(waitForAbort(signal).pipe(Effect.mapError((cause) => wrapError(description, cause)))),
|
||||
)
|
||||
}
|
||||
|
||||
return Service.of({ ...spawner, run, runStream })
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
export * as AppProcess from "./process"
|
||||
@@ -0,0 +1,206 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit, Stream } from "effect"
|
||||
import { ChildProcess } from "effect/unstable/process"
|
||||
import { AppProcess } from "@opencode-ai/core/process"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppProcess.defaultLayer)
|
||||
|
||||
const NODE = process.execPath
|
||||
const cmd = (...args: string[]) => ChildProcess.make(NODE, args)
|
||||
|
||||
describe("AppProcess", () => {
|
||||
describe("run", () => {
|
||||
it.effect(
|
||||
"captures stdout and exit code zero",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi\\n')"))
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout.toString("utf8")).toBe("hi\n")
|
||||
expect(result.truncated).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"non-zero exit returns RunResult; caller can require success",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.exit(1)"))
|
||||
expect(result.exitCode).toBe(1)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireSuccess fails on non-zero exit",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const exit = yield* Effect.exit(
|
||||
svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(AppProcess.requireSuccess)),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(1)
|
||||
} else {
|
||||
throw new Error("expected fail reason")
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireSuccess succeeds on exit 0",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(AppProcess.requireSuccess))
|
||||
expect(result.exitCode).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"requireExitIn allowlists multiple exit codes",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const requireZeroOrOne = AppProcess.requireExitIn([0, 1])
|
||||
const okZero = yield* svc.run(cmd("-e", "process.exit(0)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okZero.exitCode).toBe(0)
|
||||
const okOne = yield* svc.run(cmd("-e", "process.exit(1)")).pipe(Effect.flatMap(requireZeroOrOne))
|
||||
expect(okOne.exitCode).toBe(1)
|
||||
const exit = yield* Effect.exit(svc.run(cmd("-e", "process.exit(2)")).pipe(Effect.flatMap(requireZeroOrOne)))
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
expect((reason.error as AppProcess.AppProcessError).exitCode).toBe(2)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"truncates output when maxOutputBytes is set",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('0123456789')"), { maxOutputBytes: 5 })
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.truncated).toBe(true)
|
||||
expect(result.stdout.length).toBe(5)
|
||||
expect(result.stdout.toString("utf8")).toBe("01234")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"result includes command description",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.run(cmd("-e", "process.stdout.write('hi')"))
|
||||
expect(result.command).toBe(`${NODE} -e process.stdout.write('hi')`)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("inherited platform methods", () => {
|
||||
it.effect(
|
||||
"string returns stdout as string",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const out = yield* svc.string(cmd("-e", "process.stdout.write('hi\\n')"))
|
||||
expect(out).toBe("hi\n")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect(
|
||||
"lines returns the platform's array of lines",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const out = yield* svc.lines(cmd("-e", "process.stdout.write('a\\nb\\n')"))
|
||||
expect(Array.from(out)).toEqual(["a", "b"])
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("runStream", () => {
|
||||
it.live(
|
||||
"emits lines incrementally and ends cleanly on exit 0",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc
|
||||
.runStream(cmd("-e", "console.log('one'); console.log('two'); console.log('three')"))
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["one", "two", "three"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"fails with AppProcessError when exit not in okExitCodes",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const exit = yield* Effect.exit(
|
||||
svc.runStream(cmd("-e", "console.log('a'); process.exit(2)"), { okExitCodes: [0] }).pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const reason = exit.cause.reasons[0]
|
||||
if (reason && reason._tag === "Fail") {
|
||||
expect(reason.error).toBeInstanceOf(AppProcess.AppProcessError)
|
||||
}
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"okExitCodes allowlist treats non-zero as success",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc
|
||||
.runStream(cmd("-e", "console.log('only'); process.exit(1)"), { okExitCodes: [0, 1] })
|
||||
.pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["only"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"without okExitCodes, never fails on exit code",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const result = yield* svc.runStream(cmd("-e", "console.log('only'); process.exit(7)")).pipe(Stream.runCollect)
|
||||
expect(Array.from(result)).toEqual(["only"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live(
|
||||
"AbortSignal interrupts the stream",
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const controller = new AbortController()
|
||||
setTimeout(() => controller.abort(), 50)
|
||||
const exit = yield* Effect.exit(
|
||||
svc
|
||||
.runStream(cmd("-e", "setInterval(() => console.log('tick'), 100); setTimeout(() => {}, 60_000)"), {
|
||||
signal: controller.signal,
|
||||
})
|
||||
.pipe(Stream.runCollect),
|
||||
)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
describe("spawn (inherited)", () => {
|
||||
it.live(
|
||||
"returns the platform ChildProcessHandle for advanced use",
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* AppProcess.Service
|
||||
const handle = yield* svc.spawn(cmd("-e", "setInterval(() => {}, 1_000)"))
|
||||
expect(yield* handle.isRunning).toBe(true)
|
||||
yield* handle.kill()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
Vendored
+1
@@ -298,6 +298,7 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -298,6 +298,7 @@ declare module "sst" {
|
||||
"EnterpriseStorage": cloudflare.R2Bucket
|
||||
"GatewayKv": cloudflare.KVNamespace
|
||||
"LogProcessor": cloudflare.Service
|
||||
"Stat": cloudflare.Service
|
||||
"ZenData": cloudflare.R2Bucket
|
||||
"ZenDataNew": cloudflare.R2Bucket
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@
|
||||
"@opencode-ai/plugin": "workspace:*",
|
||||
"@opencode-ai/script": "workspace:*",
|
||||
"@opencode-ai/sdk": "workspace:*",
|
||||
"@opencode-ai/ui": "workspace:*",
|
||||
"@openrouter/ai-sdk-provider": "2.8.1",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/context-async-hooks": "2.6.1",
|
||||
@@ -129,7 +130,6 @@
|
||||
"bonjour-service": "1.3.0",
|
||||
"bun-pty": "0.4.8",
|
||||
"chokidar": "4.0.3",
|
||||
"cli-sound": "1.1.3",
|
||||
"clipboardy": "4.0.0",
|
||||
"cross-spawn": "catalog:",
|
||||
"decimal.js": "10.5.0",
|
||||
|
||||
@@ -29,6 +29,16 @@ Example:
|
||||
"plugin": ["@acme/opencode-plugin@1.2.3", ["./plugins/demo.tsx", { "label": "demo" }]],
|
||||
"plugin_enabled": {
|
||||
"acme.demo": false
|
||||
},
|
||||
"attention": {
|
||||
"enabled": true,
|
||||
"notifications": true,
|
||||
"sound": true,
|
||||
"volume": 0.4,
|
||||
"sound_pack": "opencode.default",
|
||||
"sounds": {
|
||||
"error": "/Users/me/sounds/error.mp3"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -45,6 +55,11 @@ Example:
|
||||
- Internal plugins can declare `enabled: false` to be registered but inactive by default; `plugin_enabled` and runtime KV can still enable them by id.
|
||||
- `plugin_enabled` is merged across config layers.
|
||||
- Runtime enable/disable state is also stored in KV under `plugin_enabled`; that KV state overrides config on startup.
|
||||
- `attention.enabled` defaults to `false`; when `false`, it disables all `api.attention.notify(...)` delivery.
|
||||
- `attention.notifications` and `attention.sound` independently control terminal-mediated desktop notifications and built-in sounds.
|
||||
- `attention.volume` sets the default built-in sound volume from `0` to `1`.
|
||||
- `attention.sound_pack` selects the initial semantic sound pack. Persisted runtime selection in KV can override it.
|
||||
- `attention.sounds` overrides individual semantic sound slots such as `error`, `done`, or `subagent_done`.
|
||||
- `leader_timeout` is a top-level TUI setting.
|
||||
- `keybinds` is a flat object keyed by command id; values are key binding values (`false`, `"none"`, a key string/object, a binding object, or an array of key strings/objects/binding objects).
|
||||
- `keybinds.leader` sets the key used by `<leader>` shortcuts.
|
||||
@@ -212,6 +227,7 @@ That is what makes local config-scoped plugins able to import `@opencode-ai/plug
|
||||
Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
|
||||
- `api.app.version`
|
||||
- `api.attention.notify(input)`
|
||||
- `api.keys.formatSequence(parts)`, `formatBindings(bindings)`
|
||||
- `api.keymap`
|
||||
- `api.route.register(routes)` / `api.route.navigate(name, params?)` / `api.route.current`
|
||||
@@ -246,6 +262,24 @@ Top-level API groups exposed to `tui(api, options, meta)`:
|
||||
- `formatBindings(bindings)` formats binding lists and returns `undefined` when there is nothing to show.
|
||||
- For generic config-to-bindings helpers, import `createBindingLookup` from `@opencode-ai/plugin/tui`.
|
||||
|
||||
### Attention
|
||||
|
||||
- `api.attention.notify({ title?, message, notification?, sound? })` requests user attention while keeping terminal focus, notifications, and audio owned by the host.
|
||||
- `message` is required; `title` defaults to `"opencode"`; `notification` defaults to enabled with `when: "blurred"`; `sound` defaults to enabled with `when: "always"`.
|
||||
- `when: "always"` requests delivery regardless of terminal focus state.
|
||||
- `when: "focused"` only requests delivery after the terminal is known focused; `when: "blurred"` only requests delivery after the terminal is known blurred.
|
||||
- Example: `notification: { when: "blurred" }, sound: { name: "question", when: "always" }` plays sound while focused but only triggers system notifications when blurred.
|
||||
- Semantic sound names are `"default"`, `"question"`, `"permission"`, `"error"`, `"done"`, and `"subagent_done"`.
|
||||
- `sound: true` plays the `"default"` sound; `sound: { name: "question" }` plays a named semantic sound.
|
||||
- `sound: { volume }` overrides volume for that call; `sound: false` disables sound for that call; `notification: false` disables system notification for that call.
|
||||
- `api.attention.soundboard.registerPack({ id, name?, sounds })` registers a sound pack and returns a disposer. Relative paths resolve from the plugin root and are cleaned up on plugin deactivation.
|
||||
- `api.attention.soundboard.activate(id, { persist })` selects the active pack. `persist: true` writes the selected pack id to TUI KV state, not `tui.json`.
|
||||
- `api.attention.soundboard.current()` and `list()` expose the active/registered packs for plugin UX.
|
||||
- Config `attention.sounds` overrides active-pack sounds by slot. Failed loads fall back to the active pack and then `opencode.default`.
|
||||
- The host strips ANSI/control characters and collapses newlines before sending text to the terminal notification API.
|
||||
- Terminal and OS settings decide whether a requested notification is visibly displayed.
|
||||
- Prefer privacy-safe messages such as `"A question needs your input"`; avoid full commands, paths, prompts, errors, secrets, or file contents unless the plugin intentionally exposes them.
|
||||
|
||||
### Routes
|
||||
|
||||
- Reserved route names: `home` and `session`.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
# TUI Notifications Default
|
||||
|
||||
Problem:
|
||||
|
||||
- v1 defaults `attention.enabled` to `false`
|
||||
- users can opt in with `attention.enabled = true`
|
||||
- v2 should make core TUI notifications a default behavior
|
||||
|
||||
## v2 Target
|
||||
|
||||
Flip `attention.enabled` to `true` by default in v2.
|
||||
|
||||
Keep `attention.enabled = false` as the explicit opt-out.
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "../provider/schema"
|
||||
import { generateObject, streamObject, type ModelMessage } from "ai"
|
||||
@@ -117,7 +118,10 @@ export const layer = Layer.effect(
|
||||
},
|
||||
})
|
||||
|
||||
const user = Permission.fromConfig(cfg.permission ?? {})
|
||||
// Convert permission layers to rulesets and merge them
|
||||
// Each layer's rules come after the previous, so later configs override earlier ones
|
||||
const layers = ConfigPermission.toLayers(cfg.permission)
|
||||
const user = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
const agents: Record<string, Info> = {
|
||||
build: {
|
||||
|
||||
@@ -5,10 +5,10 @@ import type { Agent } from "./agent"
|
||||
* Build the `permission` ruleset for a subagent's session when it's spawned
|
||||
* via the task tool. Combines:
|
||||
*
|
||||
* 1. The parent **agent's** deny rules — Plan Mode and other agent-level
|
||||
* restrictions live on the agent ruleset, not on the session, so a
|
||||
* 1. The parent **agent's** edit-class deny rules — Plan Mode's file-edit
|
||||
* restriction lives on the agent ruleset, not on the session, so a
|
||||
* subagent that only inherited the parent SESSION's permission would
|
||||
* silently bypass them. (#26514)
|
||||
* silently bypass it. (#26514)
|
||||
* 2. The parent **session's** deny rules and external_directory rules —
|
||||
* same forwarding the original code already did.
|
||||
* 3. Default `todowrite` and `task` denies if the subagent's own ruleset
|
||||
@@ -21,7 +21,8 @@ export function deriveSubagentSessionPermission(input: {
|
||||
}): Permission.Ruleset {
|
||||
const canTask = input.subagent.permission.some((rule) => rule.permission === "task")
|
||||
const canTodo = input.subagent.permission.some((rule) => rule.permission === "todowrite")
|
||||
const parentAgentDenies = input.parentAgent?.permission.filter((rule) => rule.action === "deny") ?? []
|
||||
const parentAgentDenies =
|
||||
input.parentAgent?.permission.filter((rule) => rule.action === "deny" && rule.permission === "edit") ?? []
|
||||
return [
|
||||
...parentAgentDenies,
|
||||
...input.parentSessionPermission.filter(
|
||||
|
||||
Vendored
+5
@@ -3,6 +3,11 @@ declare module "*.wav" {
|
||||
export default file
|
||||
}
|
||||
|
||||
declare module "*.mp3" {
|
||||
const file: string
|
||||
export default file
|
||||
}
|
||||
|
||||
declare module "*.wasm" {
|
||||
const file: string
|
||||
export default file
|
||||
|
||||
@@ -124,6 +124,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
yield* spinner.stop("Login successful")
|
||||
@@ -156,6 +157,7 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
yield* Prompt.log.success("Login successful")
|
||||
@@ -191,10 +193,11 @@ const handlePluginAuth = Effect.fn("Cli.providers.pluginAuth")(function* (
|
||||
}
|
||||
if (result.type === "success") {
|
||||
const saveProvider = result.provider ?? provider
|
||||
const merged = { ...(metadata.metadata ?? {}), ...(result.metadata ?? {}) }
|
||||
yield* put(saveProvider, {
|
||||
type: "api",
|
||||
key: result.key ?? apiKey,
|
||||
...metadata,
|
||||
...(Object.keys(merged).length ? { metadata: merged } : {}),
|
||||
})
|
||||
yield* Prompt.log.success("Login successful")
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { render, TimeToFirstDraw, useRenderer, useTerminalDimensions } from "@op
|
||||
import { createDefaultOpenTuiKeymap } from "@opentui/keymap/opentui"
|
||||
import * as Clipboard from "@tui/util/clipboard"
|
||||
import * as Selection from "@tui/util/selection"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import { createCliRenderer, MouseButton, type CliRendererConfig } from "@opentui/core"
|
||||
import { RouteProvider, useRoute } from "@tui/context/route"
|
||||
import {
|
||||
@@ -63,6 +64,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import { TuiPluginRuntime } from "@/cli/cmd/tui/plugin/runtime"
|
||||
import { createTuiApi } from "@/cli/cmd/tui/plugin/api"
|
||||
import type { RouteMap } from "@/cli/cmd/tui/plugin/api"
|
||||
import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||
import { CommandPaletteProvider, useCommandPalette } from "./context/command-palette"
|
||||
import { OpencodeKeymapProvider, registerOpencodeKeymap, useBindings, useOpencodeKeymap } from "./keymap"
|
||||
@@ -176,10 +178,10 @@ export function tui(input: {
|
||||
unguard?.()
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onBeforeExit = async () => {
|
||||
offKeymap()
|
||||
await TuiPluginRuntime.dispose()
|
||||
TuiAudio.dispose()
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer(rendererConfig(input.config))
|
||||
@@ -283,6 +285,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
routeRev()
|
||||
return routes.get(name)?.at(-1)?.render
|
||||
}
|
||||
const attention = createTuiAttention({ renderer, config: tuiConfig, kv })
|
||||
|
||||
const api = createTuiApi({
|
||||
tuiConfig,
|
||||
@@ -298,11 +301,13 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
theme: themeState,
|
||||
toast,
|
||||
renderer,
|
||||
attention,
|
||||
})
|
||||
const [ready, setReady] = createSignal(false)
|
||||
TuiPluginRuntime.init({
|
||||
api,
|
||||
config: tuiConfig,
|
||||
dispose: () => attention.dispose(),
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load TUI plugins", error)
|
||||
@@ -320,7 +325,10 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
||||
},
|
||||
{ priority: 1 },
|
||||
)
|
||||
onCleanup(offSelectionKeys)
|
||||
onCleanup(() => {
|
||||
offSelectionKeys()
|
||||
attention.dispose()
|
||||
})
|
||||
|
||||
// Wire up console copy-to-clipboard via opentui's onCopySelection callback
|
||||
renderer.console.onCopySelection = async (text: string) => {
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,262 @@
|
||||
import type {
|
||||
TuiAttention,
|
||||
TuiAttentionNotifyInput,
|
||||
TuiAttentionNotifyResult,
|
||||
TuiAttentionNotifySkipReason,
|
||||
TuiAttentionWhen,
|
||||
TuiKV,
|
||||
TuiAttentionSoundName,
|
||||
TuiAttentionSoundPack,
|
||||
TuiAttentionSoundPackInfo,
|
||||
} from "@opencode-ai/plugin/tui"
|
||||
import stripAnsi from "strip-ansi"
|
||||
import type { TuiConfig } from "./config/tui"
|
||||
import { isAttentionSoundName } from "./config/tui-schema"
|
||||
import * as TuiAudio from "@tui/util/audio"
|
||||
import defaultSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
|
||||
import questionSoundPath from "@opencode-ai/ui/audio/bip-bop-03.mp3" with { type: "file" }
|
||||
import permissionSoundPath from "@opencode-ai/ui/audio/staplebops-06.mp3" with { type: "file" }
|
||||
import errorSoundPath from "@opencode-ai/ui/audio/nope-03.mp3" with { type: "file" }
|
||||
import doneSoundPath from "@opencode-ai/ui/audio/bip-bop-01.mp3" with { type: "file" }
|
||||
import subagentDoneSoundPath from "@opencode-ai/ui/audio/yup-01.mp3" with { type: "file" }
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
type FocusState = "unknown" | "focused" | "blurred"
|
||||
|
||||
type AttentionRenderer = {
|
||||
readonly isDestroyed: boolean
|
||||
on(event: "focus" | "blur", listener: () => void): unknown
|
||||
off(event: "focus" | "blur", listener: () => void): unknown
|
||||
triggerNotification(message: string, title?: string): boolean
|
||||
}
|
||||
|
||||
type RegisteredSoundPack = TuiAttentionSoundPack & {
|
||||
builtin: boolean
|
||||
}
|
||||
|
||||
type TuiAttentionHost = TuiAttention & {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
const log = Log.create({ service: "tui.attention" })
|
||||
|
||||
const DEFAULT_TITLE = "opencode"
|
||||
const DEFAULT_PACK_ID = "opencode.default"
|
||||
const KV_SOUND_PACK = "attention_sound_pack"
|
||||
const TITLE_LIMIT = 80
|
||||
const MESSAGE_LIMIT = 240
|
||||
const BUILTIN_PACK: RegisteredSoundPack = {
|
||||
id: DEFAULT_PACK_ID,
|
||||
name: "OpenCode Default",
|
||||
builtin: true,
|
||||
sounds: {
|
||||
default: defaultSoundPath,
|
||||
question: questionSoundPath,
|
||||
permission: permissionSoundPath,
|
||||
error: errorSoundPath,
|
||||
done: doneSoundPath,
|
||||
subagent_done: subagentDoneSoundPath,
|
||||
},
|
||||
}
|
||||
|
||||
function skipped(reason: TuiAttentionNotifySkipReason): TuiAttentionNotifyResult {
|
||||
return {
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: reason,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeText(input: string | undefined, fallback: string, limit: number) {
|
||||
const text = stripAnsi(input ?? "")
|
||||
.replace(/[ \t]*[\r\n]+[ \t]*/g, " ")
|
||||
.replace(/[\u0000-\u0009\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, "")
|
||||
.trim()
|
||||
const normalized = text.length ? text : fallback
|
||||
return Array.from(normalized).slice(0, limit).join("")
|
||||
}
|
||||
|
||||
function clampVolume(volume: number) {
|
||||
if (!Number.isFinite(volume)) return 0
|
||||
return Math.min(1, Math.max(0, volume))
|
||||
}
|
||||
|
||||
function soundVolume(input: TuiAttentionNotifyInput, config: Pick<TuiConfig.Resolved, "attention">) {
|
||||
if (!config.attention.sound) return
|
||||
if (input.sound === false) return
|
||||
if (input.sound === undefined) return clampVolume(config.attention.volume)
|
||||
if (input.sound === true) return clampVolume(config.attention.volume)
|
||||
return clampVolume(input.sound.volume ?? config.attention.volume)
|
||||
}
|
||||
|
||||
function normalizePack(pack: TuiAttentionSoundPack): RegisteredSoundPack | undefined {
|
||||
const id = pack.id.trim()
|
||||
if (!id) return
|
||||
return {
|
||||
id,
|
||||
name: pack.name?.trim() || undefined,
|
||||
builtin: false,
|
||||
sounds: Object.fromEntries(
|
||||
Object.entries(pack.sounds).filter(
|
||||
(item): item is [TuiAttentionSoundName, string] =>
|
||||
isAttentionSoundName(item[0]) && typeof item[1] === "string" && item[1].trim().length > 0,
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function focusSkip(when: TuiAttentionWhen, focus: FocusState) {
|
||||
if (when === "always") return
|
||||
if (focus === "unknown") return "focus_unknown"
|
||||
if (when === "blurred" && focus === "focused") return "focused"
|
||||
if (when === "focused" && focus === "blurred") return "blurred"
|
||||
}
|
||||
|
||||
export function createTuiAttention(input: {
|
||||
renderer: AttentionRenderer
|
||||
config: Pick<TuiConfig.Resolved, "attention">
|
||||
kv?: TuiKV
|
||||
audio?: Pick<typeof TuiAudio, "loadSoundFile" | "play">
|
||||
}): TuiAttentionHost {
|
||||
let focus: FocusState = "unknown"
|
||||
let disposed = false
|
||||
let activePackID: string | undefined
|
||||
const packs = new Map<string, RegisteredSoundPack>([[BUILTIN_PACK.id, BUILTIN_PACK]])
|
||||
const audio = input.audio ?? TuiAudio
|
||||
|
||||
const onFocus = () => {
|
||||
focus = "focused"
|
||||
}
|
||||
const onBlur = () => {
|
||||
focus = "blurred"
|
||||
}
|
||||
|
||||
input.renderer.on("focus", onFocus)
|
||||
input.renderer.on("blur", onBlur)
|
||||
|
||||
function configuredPackID() {
|
||||
const stored = input.kv?.get<string | undefined>(KV_SOUND_PACK, undefined)
|
||||
return activePackID ?? stored ?? input.config.attention.sound_pack
|
||||
}
|
||||
|
||||
function currentPack() {
|
||||
return packs.get(configuredPackID()) ?? BUILTIN_PACK
|
||||
}
|
||||
|
||||
function soundCandidates(name: TuiAttentionSoundName) {
|
||||
return [input.config.attention.sounds[name], currentPack().sounds[name], BUILTIN_PACK.sounds[name]].filter(
|
||||
(item, index, list): item is string => typeof item === "string" && list.indexOf(item) === index,
|
||||
)
|
||||
}
|
||||
|
||||
async function playSound(name: TuiAttentionSoundName, volume: number) {
|
||||
try {
|
||||
for (const file of soundCandidates(name)) {
|
||||
const current = await audio.loadSoundFile(file).catch((error) => {
|
||||
log.debug("failed to load attention sound", { file, error })
|
||||
return null
|
||||
})
|
||||
if (disposed) return false
|
||||
if (current == null) continue
|
||||
if (audio.play(current, { volume }) != null) return true
|
||||
}
|
||||
return false
|
||||
} catch (error) {
|
||||
log.debug("failed to play attention sound", { error })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
async notify(request) {
|
||||
try {
|
||||
if (!input.config.attention.enabled) return skipped("attention_disabled")
|
||||
if (disposed || input.renderer.isDestroyed) return skipped("renderer_destroyed")
|
||||
|
||||
const message = normalizeText(request.message, "", MESSAGE_LIMIT)
|
||||
if (!message) return skipped("empty_message")
|
||||
|
||||
const requestedNotification = typeof request.notification === "object" ? request.notification : undefined
|
||||
const notificationSkip = focusSkip(requestedNotification?.when ?? "blurred", focus)
|
||||
const notificationRequested = input.config.attention.notifications && request.notification !== false
|
||||
const shouldNotify = notificationRequested && !notificationSkip
|
||||
const notification = shouldNotify
|
||||
? (() => {
|
||||
try {
|
||||
return input.renderer.triggerNotification(
|
||||
message,
|
||||
normalizeText(request.title, DEFAULT_TITLE, TITLE_LIMIT),
|
||||
)
|
||||
} catch (error) {
|
||||
log.debug("failed to trigger attention notification", { error })
|
||||
return false
|
||||
}
|
||||
})()
|
||||
: false
|
||||
const volume = soundVolume(request, input.config)
|
||||
const requestedSound = typeof request.sound === "object" ? request.sound : undefined
|
||||
const soundSkip = volume === undefined ? undefined : focusSkip(requestedSound?.when ?? "always", focus)
|
||||
const soundName =
|
||||
requestedSound?.name && isAttentionSoundName(requestedSound.name) ? requestedSound.name : "default"
|
||||
const sound = volume === undefined || soundSkip ? false : await playSound(soundName, volume)
|
||||
|
||||
if (!notification && !sound) {
|
||||
if (notificationRequested && notificationSkip) return skipped(notificationSkip)
|
||||
if (soundSkip) return skipped(soundSkip)
|
||||
}
|
||||
|
||||
return {
|
||||
ok: notification || sound,
|
||||
notification,
|
||||
sound,
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug("failed to handle attention notification", { error })
|
||||
return {
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
}
|
||||
}
|
||||
},
|
||||
soundboard: {
|
||||
registerPack(pack) {
|
||||
const next = normalizePack(pack)
|
||||
if (!next) return () => {}
|
||||
packs.set(next.id, next)
|
||||
let disposed = false
|
||||
return () => {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
if (packs.get(next.id) === next) packs.delete(next.id)
|
||||
}
|
||||
},
|
||||
activate(id, options) {
|
||||
const pack = packs.get(id)
|
||||
if (!pack) return false
|
||||
activePackID = pack.id
|
||||
if (options?.persist) input.kv?.set(KV_SOUND_PACK, pack.id)
|
||||
return true
|
||||
},
|
||||
current() {
|
||||
return currentPack().id
|
||||
},
|
||||
list(): TuiAttentionSoundPackInfo[] {
|
||||
const current = currentPack().id
|
||||
return Array.from(packs.values()).map((pack) => ({
|
||||
id: pack.id,
|
||||
name: pack.name,
|
||||
active: pack.id === current,
|
||||
builtin: pack.builtin,
|
||||
}))
|
||||
},
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return
|
||||
disposed = true
|
||||
input.renderer.off("focus", onFocus)
|
||||
input.renderer.off("blur", onBlur)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { BoxRenderable, MouseButton, MouseEvent, RGBA, TextAttributes } from "@o
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { For, createMemo, createSignal, onCleanup, onMount, type JSX } from "solid-js"
|
||||
import { useTheme, tint } from "@tui/context/theme"
|
||||
import * as Sound from "@tui/util/sound"
|
||||
import { go, logo } from "@/cli/logo"
|
||||
|
||||
export type LogoShape = {
|
||||
@@ -563,7 +562,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
|
||||
const [now, setNow] = createSignal(0)
|
||||
let box: BoxRenderable | undefined
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
let hum = false
|
||||
|
||||
const stop = () => {
|
||||
if (!timer) return
|
||||
@@ -575,10 +573,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
|
||||
const t = performance.now()
|
||||
setNow(t)
|
||||
const item = hold()
|
||||
if (item && !hum && t - item.at >= HOLD) {
|
||||
hum = true
|
||||
Sound.start()
|
||||
}
|
||||
if (item && t - item.at >= CHARGE) {
|
||||
burst(item.x, item.y)
|
||||
}
|
||||
@@ -605,8 +599,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
hum = false
|
||||
Sound.dispose()
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
@@ -626,14 +618,12 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
|
||||
setNow(t)
|
||||
if (!last) setRelease(undefined)
|
||||
setHold({ x, y, at: t, glyph: select(x, y, ctx) })
|
||||
hum = false
|
||||
start()
|
||||
}
|
||||
|
||||
const burst = (x: number, y: number) => {
|
||||
const item = hold()
|
||||
if (!item) return
|
||||
hum = false
|
||||
const t = performance.now()
|
||||
const age = t - item.at
|
||||
const rise = ramp(age, HOLD, CHARGE)
|
||||
@@ -655,7 +645,6 @@ export function Logo(props: { shape?: LogoShape; ink?: RGBA; idle?: boolean } =
|
||||
])
|
||||
setNow(t)
|
||||
start()
|
||||
Sound.pulse(lerp(0.8, 1, level))
|
||||
}
|
||||
|
||||
const frame = createMemo(() => {
|
||||
|
||||
@@ -1,12 +1,47 @@
|
||||
import { ConfigPlugin } from "@/config/plugin"
|
||||
import { TuiKeybind } from "./keybind"
|
||||
import { Schema } from "effect"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { TuiAttentionSoundNames, type TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
|
||||
|
||||
export type TuiAttentionSoundPaths = Partial<Record<TuiAttentionSoundName, string>>
|
||||
|
||||
export function isAttentionSoundName(value: string): value is TuiAttentionSoundName {
|
||||
return TuiAttentionSoundNames.includes(value as TuiAttentionSoundName)
|
||||
}
|
||||
|
||||
export function resolveAttentionSoundPaths(
|
||||
root: string,
|
||||
sounds: unknown,
|
||||
options?: { trim?: boolean },
|
||||
): TuiAttentionSoundPaths {
|
||||
if (!isRecord(sounds)) return {}
|
||||
return Object.fromEntries(
|
||||
Object.entries(sounds).flatMap(([name, file]) => {
|
||||
if (!isAttentionSoundName(name)) return []
|
||||
if (typeof file !== "string") return []
|
||||
const value = options?.trim ? file.trim() : file
|
||||
if (!value) return []
|
||||
return [[name, Filesystem.resolveFilePath(root, value)]]
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export const KeymapLeaderTimeoutDefault = 2000
|
||||
const KeymapLeaderTimeout = Schema.Int.check(Schema.isGreaterThan(0)).annotate({
|
||||
description: "Leader key timeout in milliseconds",
|
||||
})
|
||||
|
||||
const TuiAttentionSounds = Schema.Struct({
|
||||
default: Schema.optional(Schema.String),
|
||||
question: Schema.optional(Schema.String),
|
||||
permission: Schema.optional(Schema.String),
|
||||
error: Schema.optional(Schema.String),
|
||||
done: Schema.optional(Schema.String),
|
||||
subagent_done: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const ScrollSpeed = Schema.Number.check(Schema.isGreaterThanOrEqualTo(0.001))
|
||||
|
||||
export const ScrollAcceleration = Schema.Struct({
|
||||
@@ -17,6 +52,15 @@ export const DiffStyle = Schema.Literals(["auto", "stacked"]).annotate({
|
||||
description: "Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column",
|
||||
})
|
||||
|
||||
export const Attention = Schema.Struct({
|
||||
enabled: Schema.optional(Schema.Boolean),
|
||||
notifications: Schema.optional(Schema.Boolean),
|
||||
sound: Schema.optional(Schema.Boolean),
|
||||
volume: Schema.optional(Schema.Number.check(Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(1))),
|
||||
sound_pack: Schema.optional(Schema.String),
|
||||
sounds: Schema.optional(TuiAttentionSounds),
|
||||
}).annotate({ description: "Attention notification and sound settings" })
|
||||
|
||||
export const TuiInfo = Schema.Struct({
|
||||
$schema: Schema.optional(Schema.String),
|
||||
theme: Schema.optional(Schema.String),
|
||||
@@ -24,6 +68,7 @@ export const TuiInfo = Schema.Struct({
|
||||
plugin: Schema.optional(Schema.Array(ConfigPlugin.Spec)),
|
||||
plugin_enabled: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
leader_timeout: Schema.optional(KeymapLeaderTimeout),
|
||||
attention: Schema.optional(Attention),
|
||||
scroll_speed: Schema.optional(ScrollSpeed).annotate({
|
||||
description: "TUI scroll speed",
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * as TuiConfig from "./tui"
|
||||
|
||||
import path from "path"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { mergeDeep, unique } from "remeda"
|
||||
import { Context, Effect, Fiber, Layer, Schema } from "effect"
|
||||
@@ -7,7 +8,7 @@ import { ConfigParse } from "@/config/parse"
|
||||
import { InvalidError } from "@/config/error"
|
||||
import * as ConfigPaths from "@/config/paths"
|
||||
import { migrateTuiConfig } from "./tui-migrate"
|
||||
import { KeymapLeaderTimeoutDefault, TuiInfo } from "./tui-schema"
|
||||
import { KeymapLeaderTimeoutDefault, resolveAttentionSoundPaths, TuiInfo } from "./tui-schema"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -22,6 +23,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { ConfigVariable } from "@/config/variable"
|
||||
import { Npm } from "@opencode-ai/core/npm"
|
||||
import type { DeepMutable } from "@opencode-ai/core/schema"
|
||||
import type { TuiAttentionSoundName } from "@opencode-ai/plugin/tui"
|
||||
|
||||
const log = Log.create({ service: "tui.config" })
|
||||
|
||||
@@ -33,7 +35,15 @@ type Acc = {
|
||||
plugin_origins: ConfigPlugin.Origin[]
|
||||
}
|
||||
|
||||
export type Resolved = Omit<Info, "keybinds" | "leader_timeout"> & {
|
||||
export type Resolved = Omit<Info, "attention" | "keybinds" | "leader_timeout"> & {
|
||||
attention: {
|
||||
enabled: boolean
|
||||
notifications: boolean
|
||||
sound: boolean
|
||||
volume: number
|
||||
sound_pack: string
|
||||
sounds: Partial<Record<TuiAttentionSoundName, string>>
|
||||
}
|
||||
keybinds: TuiKeybind.BindingLookupView
|
||||
leader_timeout: number
|
||||
// Internal resolved plugin list used by runtime loading.
|
||||
@@ -101,7 +111,16 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
})
|
||||
}
|
||||
}
|
||||
const validated = ConfigParse.schema(Info, normalized, configFilepath)
|
||||
const parsed = ConfigParse.schema(Info, normalized, configFilepath)
|
||||
const validated = parsed.attention?.sounds
|
||||
? {
|
||||
...parsed,
|
||||
attention: {
|
||||
...parsed.attention,
|
||||
sounds: resolveAttentionSoundPaths(path.dirname(configFilepath), parsed.attention.sounds),
|
||||
},
|
||||
}
|
||||
: parsed
|
||||
return yield* resolvePlugins(validated, configFilepath)
|
||||
}).pipe(
|
||||
// catchCause (not tapErrorCause + orElseSucceed) because JSONC parsing and validation
|
||||
@@ -197,6 +216,14 @@ const loadState = Effect.fn("TuiConfig.loadState")(function* (ctx: { directory:
|
||||
const parsedKeybinds = TuiKeybind.parse(keybinds)
|
||||
const result: Resolved = {
|
||||
...acc.result,
|
||||
attention: {
|
||||
enabled: acc.result.attention?.enabled ?? false,
|
||||
notifications: acc.result.attention?.notifications ?? true,
|
||||
sound: acc.result.attention?.sound ?? true,
|
||||
volume: acc.result.attention?.volume ?? 0.4,
|
||||
sound_pack: acc.result.attention?.sound_pack ?? "opencode.default",
|
||||
sounds: acc.result.attention?.sounds ?? {},
|
||||
},
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(parsedKeybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
|
||||
@@ -1,11 +1,52 @@
|
||||
import { createMemo, For } from "solid-js"
|
||||
import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import { createMemo, For, type Accessor } from "solid-js"
|
||||
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { useCommandShortcut } from "../../keymap"
|
||||
|
||||
const themeCount = Object.keys(DEFAULT_THEMES).length
|
||||
const themeTip = `Use {highlight}/themes{/highlight} or {highlight}Ctrl+X T{/highlight} to switch between ${themeCount} built-in themes`
|
||||
|
||||
type TipPart = { text: string; highlight: boolean }
|
||||
type TipShortcut = Accessor<string>
|
||||
type Shortcuts = {
|
||||
agentCycle: TipShortcut
|
||||
childFirst: TipShortcut
|
||||
childNext: TipShortcut
|
||||
childPrevious: TipShortcut
|
||||
commandList: TipShortcut
|
||||
editorOpen: TipShortcut
|
||||
helpShow: TipShortcut
|
||||
inputClear: TipShortcut
|
||||
inputNewline: TipShortcut
|
||||
inputPaste: TipShortcut
|
||||
inputUndo: TipShortcut
|
||||
leader: TipShortcut
|
||||
messagesCopy: TipShortcut
|
||||
messagesFirst: TipShortcut
|
||||
messagesLast: TipShortcut
|
||||
messagesPageDown: TipShortcut
|
||||
messagesPageUp: TipShortcut
|
||||
messagesToggleConceal: TipShortcut
|
||||
modelCycleRecent: TipShortcut
|
||||
modelList: TipShortcut
|
||||
sessionCycleRecent: TipShortcut
|
||||
sessionCycleRecentReverse: TipShortcut
|
||||
sessionExport: TipShortcut
|
||||
sessionInterrupt: TipShortcut
|
||||
sessionList: TipShortcut
|
||||
sessionNew: TipShortcut
|
||||
sessionParent: TipShortcut
|
||||
sessionPinToggle: TipShortcut
|
||||
sessionQuickSwitch1: TipShortcut
|
||||
sessionQuickSwitch9: TipShortcut
|
||||
sessionSidebarToggle: TipShortcut
|
||||
sessionTimeline: TipShortcut
|
||||
sessionToggleRecent: TipShortcut
|
||||
statusView: TipShortcut
|
||||
terminalSuspend: TipShortcut
|
||||
themeList: TipShortcut
|
||||
}
|
||||
type Tip = string | ((shortcuts: Shortcuts) => string | undefined)
|
||||
|
||||
function parse(tip: string): TipPart[] {
|
||||
const parts: TipPart[] = []
|
||||
@@ -33,17 +74,86 @@ function parse(tip: string): TipPart[] {
|
||||
|
||||
const NO_MODELS_TIP = "Run {highlight}/connect{/highlight} to add an AI provider and start coding"
|
||||
|
||||
export function Tips(props: { connected?: boolean }) {
|
||||
function shortcutText(value: string) {
|
||||
return `{highlight}${value}{/highlight}`
|
||||
}
|
||||
|
||||
function commandText(command: string, shortcut: string) {
|
||||
if (!shortcut) return shortcutText(command)
|
||||
return `${shortcutText(command)} or ${shortcutText(shortcut)}`
|
||||
}
|
||||
|
||||
function press(shortcut: string, text: string) {
|
||||
if (!shortcut) return undefined
|
||||
return `Press ${shortcutText(shortcut)} ${text}`
|
||||
}
|
||||
|
||||
function configShortcut(api: TuiPluginApi, command: string): TipShortcut {
|
||||
return () =>
|
||||
api.tuiConfig.keybinds
|
||||
.get(command)
|
||||
.map((binding) => api.keys.formatSequence(Array.from(api.keymap.parseKeySequence(binding.key))))
|
||||
.filter(Boolean)
|
||||
.join(", ")
|
||||
}
|
||||
|
||||
export function Tips(props: { api: TuiPluginApi; connected?: boolean }) {
|
||||
const theme = useTheme().theme
|
||||
const randomTip = TIPS[Math.floor(Math.random() * TIPS.length)]
|
||||
const parts = createMemo(() => parse(props.connected === false ? NO_MODELS_TIP : randomTip))
|
||||
const tipOffset = Math.random()
|
||||
const shortcuts: Shortcuts = {
|
||||
agentCycle: useCommandShortcut("agent.cycle"),
|
||||
childFirst: configShortcut(props.api, "session.child.first"),
|
||||
childNext: configShortcut(props.api, "session.child.next"),
|
||||
childPrevious: configShortcut(props.api, "session.child.previous"),
|
||||
commandList: useCommandShortcut("command.palette.show"),
|
||||
editorOpen: useCommandShortcut("prompt.editor"),
|
||||
helpShow: useCommandShortcut("help.show"),
|
||||
inputClear: useCommandShortcut("prompt.clear"),
|
||||
inputNewline: useCommandShortcut("input.newline"),
|
||||
inputPaste: useCommandShortcut("prompt.paste"),
|
||||
inputUndo: useCommandShortcut("input.undo"),
|
||||
leader: configShortcut(props.api, "leader"),
|
||||
messagesCopy: configShortcut(props.api, "messages.copy"),
|
||||
messagesFirst: configShortcut(props.api, "session.first"),
|
||||
messagesLast: configShortcut(props.api, "session.last"),
|
||||
messagesPageDown: configShortcut(props.api, "session.page.down"),
|
||||
messagesPageUp: configShortcut(props.api, "session.page.up"),
|
||||
messagesToggleConceal: configShortcut(props.api, "session.toggle.conceal"),
|
||||
modelCycleRecent: useCommandShortcut("model.cycle_recent"),
|
||||
modelList: useCommandShortcut("model.list"),
|
||||
sessionCycleRecent: useCommandShortcut("session.cycle_recent"),
|
||||
sessionCycleRecentReverse: useCommandShortcut("session.cycle_recent_reverse"),
|
||||
sessionExport: configShortcut(props.api, "session.export"),
|
||||
sessionInterrupt: configShortcut(props.api, "session.interrupt"),
|
||||
sessionList: useCommandShortcut("session.list"),
|
||||
sessionNew: useCommandShortcut("session.new"),
|
||||
sessionParent: configShortcut(props.api, "session.parent"),
|
||||
sessionPinToggle: configShortcut(props.api, "session.pin.toggle"),
|
||||
sessionQuickSwitch1: useCommandShortcut("session.quick_switch.1"),
|
||||
sessionQuickSwitch9: useCommandShortcut("session.quick_switch.9"),
|
||||
sessionSidebarToggle: configShortcut(props.api, "session.sidebar.toggle"),
|
||||
sessionTimeline: configShortcut(props.api, "session.timeline"),
|
||||
sessionToggleRecent: configShortcut(props.api, "session.toggle.recent"),
|
||||
statusView: useCommandShortcut("opencode.status"),
|
||||
terminalSuspend: useCommandShortcut("terminal.suspend"),
|
||||
themeList: useCommandShortcut("theme.switch"),
|
||||
}
|
||||
const tip = createMemo(() => {
|
||||
if (props.connected === false) return NO_MODELS_TIP
|
||||
const tips = TIPS.flatMap((item) => {
|
||||
const value = typeof item === "string" ? item : item(shortcuts)
|
||||
return value ? [value] : []
|
||||
})
|
||||
return tips[Math.floor(tipOffset * tips.length)] ?? NO_MODELS_TIP
|
||||
})
|
||||
const parts = createMemo(() => parse(tip()))
|
||||
|
||||
return (
|
||||
<box flexDirection="row" maxWidth="100%">
|
||||
<text flexShrink={0} style={{ fg: theme.warning }}>
|
||||
● Tip{" "}
|
||||
</text>
|
||||
<text flexShrink={1}>
|
||||
<text flexShrink={1} wrapMode="word">
|
||||
<For each={parts()}>
|
||||
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
|
||||
</For>
|
||||
@@ -52,46 +162,66 @@ export function Tips(props: { connected?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
const TIPS = [
|
||||
const TIPS: Tip[] = [
|
||||
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
|
||||
"Start a message with {highlight}!{/highlight} to run shell commands directly (e.g., {highlight}!ls -la{/highlight})",
|
||||
"Press {highlight}Tab{/highlight} to cycle between Build and Plan agents",
|
||||
(shortcuts) => press(shortcuts.agentCycle(), "to cycle between Build and Plan agents"),
|
||||
"Use {highlight}/undo{/highlight} to revert the last message and file changes",
|
||||
"Use {highlight}/redo{/highlight} to restore previously undone messages and file changes",
|
||||
"Run {highlight}/share{/highlight} to create a public link to your conversation at opencode.ai",
|
||||
"Drag and drop images or PDFs into the terminal to add them as context",
|
||||
"Press {highlight}Ctrl+V{/highlight} to paste images from your clipboard into the prompt",
|
||||
"Press {highlight}Ctrl+X E{/highlight} or {highlight}/editor{/highlight} to compose messages in your external editor",
|
||||
(shortcuts) => press(shortcuts.inputPaste(), "to paste images from your clipboard into the prompt"),
|
||||
(shortcuts) => `Use ${commandText("/editor", shortcuts.editorOpen())} to compose messages in your external editor`,
|
||||
"Run {highlight}/init{/highlight} to auto-generate project rules based on your codebase",
|
||||
"Run {highlight}/models{/highlight} or {highlight}Ctrl+X M{/highlight} to see and switch between available AI models",
|
||||
themeTip,
|
||||
"Press {highlight}Ctrl+X N{/highlight} or {highlight}/new{/highlight} to start a fresh conversation session",
|
||||
"Use {highlight}/sessions{/highlight} or {highlight}Ctrl+X L{/highlight} to list and continue previous conversations",
|
||||
(shortcuts) => `Use ${commandText("/models", shortcuts.modelList())} to see and switch between available AI models`,
|
||||
(shortcuts) => `Use ${commandText("/themes", shortcuts.themeList())} to switch between ${themeCount} built-in themes`,
|
||||
(shortcuts) => `Use ${commandText("/new", shortcuts.sessionNew())} to start a fresh conversation session`,
|
||||
(shortcuts) => `Use ${commandText("/sessions", shortcuts.sessionList())} to list and continue previous conversations`,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SESSION_SWITCHING
|
||||
? [
|
||||
"Press {highlight}Ctrl+F{/highlight} in the session list to pin a session so it stays at the top",
|
||||
"Pinned and recent sessions are bound to {highlight}Ctrl+X 1{/highlight} through {highlight}Ctrl+X 9{/highlight} for one-press switching",
|
||||
"Press {highlight}Ctrl+X ]{/highlight} / {highlight}Ctrl+X [{/highlight} to cycle through recently visited sessions",
|
||||
"Press {highlight}Ctrl+H{/highlight} in the session list to show or hide a session in the Recent group",
|
||||
]
|
||||
? ([
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionPinToggle(), "in the session list to pin a session so it stays at the top"),
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionQuickSwitch1() && shortcuts.sessionQuickSwitch9()
|
||||
? `Pinned and recent sessions are bound to ${shortcutText(shortcuts.sessionQuickSwitch1())} through ${shortcutText(shortcuts.sessionQuickSwitch9())} for one-press switching`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
shortcuts.sessionCycleRecent() && shortcuts.sessionCycleRecentReverse()
|
||||
? `Press ${shortcutText(shortcuts.sessionCycleRecent())} / ${shortcutText(shortcuts.sessionCycleRecentReverse())} to cycle through recently visited sessions`
|
||||
: undefined,
|
||||
(shortcuts) =>
|
||||
press(shortcuts.sessionToggleRecent(), "in the session list to show or hide a session in the Recent group"),
|
||||
] satisfies Tip[])
|
||||
: []),
|
||||
"Run {highlight}/compact{/highlight} to summarize long sessions near context limits",
|
||||
"Press {highlight}Ctrl+X X{/highlight} or {highlight}/export{/highlight} to save the conversation as Markdown",
|
||||
"Press {highlight}Ctrl+X Y{/highlight} to copy the assistant's last message to clipboard",
|
||||
"Press {highlight}Ctrl+P{/highlight} to see all available actions and commands",
|
||||
(shortcuts) => `Use ${commandText("/export", shortcuts.sessionExport())} to save the conversation as Markdown`,
|
||||
(shortcuts) => press(shortcuts.messagesCopy(), "to copy the assistant's last message to clipboard"),
|
||||
(shortcuts) => press(shortcuts.commandList(), "to see all available actions and commands"),
|
||||
"Run {highlight}/connect{/highlight} to add API keys for 75+ supported LLM providers",
|
||||
"The leader key is {highlight}Ctrl+X{/highlight}; combine with other keys for quick actions",
|
||||
"Press {highlight}F2{/highlight} to quickly switch between recently used models",
|
||||
"Press {highlight}Ctrl+X B{/highlight} to show/hide the sidebar panel",
|
||||
"Use {highlight}PageUp{/highlight}/{highlight}PageDown{/highlight} to navigate through conversation history",
|
||||
"Press {highlight}Ctrl+G{/highlight} or {highlight}Home{/highlight} to jump to the beginning of the conversation",
|
||||
"Press {highlight}Ctrl+Alt+G{/highlight} or {highlight}End{/highlight} to jump to the most recent message",
|
||||
"Press {highlight}Shift+Enter{/highlight} or {highlight}Ctrl+J{/highlight} to add newlines in your prompt",
|
||||
"Press {highlight}Ctrl+C{/highlight} when typing to clear the input field",
|
||||
"Press {highlight}Escape{/highlight} to stop the AI mid-response",
|
||||
(shortcuts) => `The leader key is ${shortcutText(shortcuts.leader())}; combine with other keys for quick actions`,
|
||||
(shortcuts) => press(shortcuts.modelCycleRecent(), "to quickly switch between recently used models"),
|
||||
(shortcuts) => press(shortcuts.sessionSidebarToggle(), "in a session to show or hide the sidebar panel"),
|
||||
(shortcuts) =>
|
||||
shortcuts.messagesPageUp() && shortcuts.messagesPageDown()
|
||||
? `Use ${shortcutText(shortcuts.messagesPageUp())}/${shortcutText(shortcuts.messagesPageDown())} to navigate through conversation history`
|
||||
: undefined,
|
||||
(shortcuts) => press(shortcuts.messagesFirst(), "to jump to the beginning of the conversation"),
|
||||
(shortcuts) => press(shortcuts.messagesLast(), "to jump to the most recent message"),
|
||||
(shortcuts) => press(shortcuts.inputNewline(), "to add newlines in your prompt"),
|
||||
(shortcuts) => press(shortcuts.inputClear(), "when typing to clear the input field"),
|
||||
(shortcuts) => press(shortcuts.sessionInterrupt(), "to stop the AI mid-response"),
|
||||
"Switch to {highlight}Plan{/highlight} agent to get suggestions without making actual changes",
|
||||
"Use {highlight}@agent-name{/highlight} in prompts to invoke specialized subagents",
|
||||
"Press {highlight}Ctrl+X Right/Left{/highlight} to cycle through parent and child sessions",
|
||||
(shortcuts) => {
|
||||
const items = [
|
||||
shortcuts.sessionParent(),
|
||||
shortcuts.childFirst(),
|
||||
shortcuts.childPrevious(),
|
||||
shortcuts.childNext(),
|
||||
].filter(Boolean)
|
||||
if (!items.length) return undefined
|
||||
return `Use ${items.map(shortcutText).join(" / ")} to move between parent and child sessions`
|
||||
},
|
||||
"Create {highlight}opencode.json{/highlight} for server settings and {highlight}tui.json{/highlight} for TUI settings",
|
||||
"Place TUI settings in {highlight}~/.config/opencode/tui.json{/highlight} for global config",
|
||||
"Add {highlight}$schema{/highlight} to your config for autocomplete in your editor",
|
||||
@@ -99,22 +229,21 @@ const TIPS = [
|
||||
"Override any keybind in {highlight}tui.json{/highlight} via the {highlight}keybinds{/highlight} section",
|
||||
"Set any keybind to {highlight}none{/highlight} to disable it completely",
|
||||
"Configure local or remote MCP servers in the {highlight}mcp{/highlight} config section",
|
||||
"OpenCode auto-handles OAuth for remote MCP servers requiring auth",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/command/{/highlight} to define reusable custom prompts",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/commands/{/highlight} to define reusable custom prompts",
|
||||
"Use {highlight}$ARGUMENTS{/highlight}, {highlight}$1{/highlight}, {highlight}$2{/highlight} in custom commands for dynamic input",
|
||||
"Use backticks in commands to inject shell output (e.g., {highlight}`git status`{/highlight})",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agent/{/highlight} for specialized AI personas",
|
||||
"Add {highlight}.md{/highlight} files to {highlight}.opencode/agents/{/highlight} for specialized AI personas",
|
||||
"Configure per-agent permissions for {highlight}edit{/highlight}, {highlight}bash{/highlight}, and {highlight}webfetch{/highlight} tools",
|
||||
'Use patterns like {highlight}"git *": "allow"{/highlight} for granular bash permissions',
|
||||
'Set {highlight}"rm -rf *": "deny"{/highlight} to block destructive commands',
|
||||
'Configure {highlight}"git push": "ask"{/highlight} to require approval before pushing',
|
||||
"OpenCode auto-formats files using prettier, gofmt, ruff, and more",
|
||||
'Set {highlight}"formatter": false{/highlight} in config to disable all auto-formatting',
|
||||
'Set {highlight}"formatter": true{/highlight} in config to enable built-in formatters like prettier, gofmt, and ruff',
|
||||
'Set {highlight}"formatter": false{/highlight} in config to disable formatters enabled by another config layer',
|
||||
"Define custom formatter commands with file extensions in config",
|
||||
"OpenCode uses LSP servers for intelligent code analysis",
|
||||
'Set {highlight}"lsp": true{/highlight} in config to enable built-in LSP servers for code analysis',
|
||||
"Create {highlight}.ts{/highlight} files in {highlight}.opencode/tools/{/highlight} to define new LLM tools",
|
||||
"Tool definitions can invoke scripts written in Python, Go, etc",
|
||||
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugin/{/highlight} for event hooks",
|
||||
"Add {highlight}.ts{/highlight} files to {highlight}.opencode/plugins/{/highlight} for event hooks",
|
||||
"Use plugins to send OS notifications when sessions complete",
|
||||
"Create a plugin to prevent OpenCode from reading sensitive files",
|
||||
"Use {highlight}opencode run{/highlight} for non-interactive scripting",
|
||||
@@ -133,7 +262,7 @@ const TIPS = [
|
||||
'Use {highlight}"theme": "system"{/highlight} to match your terminal\'s colors',
|
||||
"Create JSON theme files in {highlight}.opencode/themes/{/highlight} directory",
|
||||
"Themes support dark/light variants for both modes",
|
||||
"Reference ANSI colors 0-255 in custom themes",
|
||||
"Use numeric xterm color codes 0-255 in custom theme JSON",
|
||||
"Use {highlight}{env:VAR_NAME}{/highlight} syntax to reference environment variables in config",
|
||||
"Use {highlight}{file:path}{/highlight} to include file contents in config values",
|
||||
"Use {highlight}instructions{/highlight} in config to load additional rules files",
|
||||
@@ -149,18 +278,23 @@ const TIPS = [
|
||||
"Permission {highlight}external_directory{/highlight} protects files outside project",
|
||||
"Run {highlight}opencode debug config{/highlight} to troubleshoot configuration",
|
||||
"Use {highlight}--print-logs{/highlight} flag to see detailed logs in stderr",
|
||||
"Press {highlight}Ctrl+X G{/highlight} or {highlight}/timeline{/highlight} to jump to specific messages",
|
||||
"Press {highlight}Ctrl+X H{/highlight} to toggle code block visibility in messages",
|
||||
"Press {highlight}Ctrl+X S{/highlight} or {highlight}/status{/highlight} to see system status info",
|
||||
(shortcuts) => `Use ${commandText("/timeline", shortcuts.sessionTimeline())} to jump to specific messages`,
|
||||
(shortcuts) => press(shortcuts.messagesToggleConceal(), "to toggle code block visibility in messages"),
|
||||
(shortcuts) => `Use ${commandText("/status", shortcuts.statusView())} to see system status info`,
|
||||
"Enable {highlight}scroll_acceleration{/highlight} in {highlight}tui.json{/highlight} for smooth macOS-style scrolling",
|
||||
"Toggle username display in chat via command palette ({highlight}Ctrl+P{/highlight})",
|
||||
(shortcuts) =>
|
||||
shortcuts.commandList()
|
||||
? `Toggle username display in chat via the command palette (${shortcutText(shortcuts.commandList())})`
|
||||
: "Toggle username display in chat via the command palette",
|
||||
"Run {highlight}docker run -it --rm ghcr.io/anomalyco/opencode{/highlight} for containerized use",
|
||||
"Use {highlight}/connect{/highlight} with OpenCode Zen for curated, tested models",
|
||||
"Commit your project's {highlight}AGENTS.md{/highlight} file to Git for team sharing",
|
||||
"Use {highlight}/review{/highlight} to review uncommitted changes, branches, or PRs",
|
||||
"Run {highlight}/help{/highlight} or {highlight}Ctrl+X H{/highlight} to show the help dialog",
|
||||
(shortcuts) => `Use ${commandText("/help", shortcuts.helpShow())} to show the help dialog`,
|
||||
"Use {highlight}/rename{/highlight} to rename the current session",
|
||||
...(process.platform === "win32"
|
||||
? ["Press {highlight}Ctrl+Z{/highlight} to undo changes in your prompt"]
|
||||
: ["Press {highlight}Ctrl+Z{/highlight} to suspend the terminal and return to your shell"]),
|
||||
? ([(shortcuts) => press(shortcuts.inputUndo(), "to undo changes in your prompt")] satisfies Tip[])
|
||||
: ([
|
||||
(shortcuts) => press(shortcuts.terminalSuspend(), "to suspend the terminal and return to your shell"),
|
||||
] satisfies Tip[])),
|
||||
]
|
||||
|
||||
@@ -24,9 +24,9 @@ function View(props: { api: TuiPluginApi; hidden: boolean; show: boolean; connec
|
||||
}))
|
||||
|
||||
return (
|
||||
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<box width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
|
||||
<Show when={props.show}>
|
||||
<Tips connected={props.connected} />
|
||||
<Tips api={props.api} connected={props.connected} />
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { Event } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionSoundName, TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { InternalTuiPlugin } from "../../plugin/internal"
|
||||
|
||||
const id = "internal:notifications"
|
||||
|
||||
type SessionError = Extract<Event, { type: "session.error" }>["properties"]["error"]
|
||||
|
||||
function notify(api: TuiPluginApi, sessionID: string | undefined, message: string, sound: TuiAttentionSoundName) {
|
||||
const session = sessionID ? api.state.session.get(sessionID) : undefined
|
||||
const isSubagent = session?.parentID !== undefined
|
||||
void api.attention.notify({
|
||||
title: session?.title,
|
||||
message,
|
||||
notification: isSubagent ? false : { when: "blurred" },
|
||||
sound: { name: sound, when: "always" },
|
||||
})
|
||||
}
|
||||
|
||||
function sessionErrorMessage(error: SessionError) {
|
||||
if (error?.name === "MessageAbortedError") return "Session aborted"
|
||||
const data = error?.data
|
||||
if (data && typeof data === "object" && "message" in data && data.message === "SSE read timed out") {
|
||||
return "Model stopped responding"
|
||||
}
|
||||
return "Session error"
|
||||
}
|
||||
|
||||
const tui: TuiPlugin = async (api) => {
|
||||
const active = new Set<string>()
|
||||
const errored = new Set<string>()
|
||||
const questions = new Set<string>()
|
||||
const permissions = new Set<string>()
|
||||
|
||||
api.event.on("question.asked", (event) => {
|
||||
if (questions.has(event.properties.id)) return
|
||||
questions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Question needs input", "question")
|
||||
})
|
||||
|
||||
api.event.on("question.replied", (event) => {
|
||||
questions.delete(event.properties.requestID)
|
||||
})
|
||||
|
||||
api.event.on("question.rejected", (event) => {
|
||||
questions.delete(event.properties.requestID)
|
||||
})
|
||||
|
||||
api.event.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.properties.id)) return
|
||||
permissions.add(event.properties.id)
|
||||
notify(api, event.properties.sessionID, "Permission needs input", "permission")
|
||||
})
|
||||
|
||||
api.event.on("permission.replied", (event) => {
|
||||
permissions.delete(event.properties.requestID)
|
||||
})
|
||||
|
||||
api.event.on("session.status", (event) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
if (event.properties.status.type === "busy" || event.properties.status.type === "retry") {
|
||||
active.add(sessionID)
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (event.properties.status.type !== "idle") return
|
||||
if (!active.has(sessionID)) return
|
||||
active.delete(sessionID)
|
||||
|
||||
if (errored.has(sessionID)) {
|
||||
errored.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
const session = api.state.session.get(sessionID)
|
||||
notify(api, sessionID, "Session done", session?.parentID ? "subagent_done" : "done")
|
||||
})
|
||||
|
||||
api.event.on("session.error", (event) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
if (!sessionID) return
|
||||
if (!active.has(sessionID)) return
|
||||
errored.add(sessionID)
|
||||
notify(api, sessionID, sessionErrorMessage(event.properties.error), "error")
|
||||
})
|
||||
}
|
||||
|
||||
const plugin: InternalTuiPlugin = {
|
||||
id,
|
||||
tui,
|
||||
}
|
||||
|
||||
export default plugin
|
||||
@@ -40,6 +40,7 @@ type Input = {
|
||||
theme: ReturnType<typeof useTheme>
|
||||
toast: ReturnType<typeof useToast>
|
||||
renderer: TuiPluginApi["renderer"]
|
||||
attention: TuiPluginApi["attention"]
|
||||
}
|
||||
|
||||
function routeRegister(routes: RouteMap, list: TuiRouteDefinition[], bump: () => void) {
|
||||
@@ -206,6 +207,7 @@ export function createTuiApi(input: Input): TuiPluginApi {
|
||||
}
|
||||
return {
|
||||
app: appApi(),
|
||||
attention: input.attention,
|
||||
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||
command: createCommandShim(input.keymap, input.dialog, input.tuiConfig.keybinds),
|
||||
keys: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import SidebarTodo from "../feature-plugins/sidebar/todo"
|
||||
import SidebarFiles from "../feature-plugins/sidebar/files"
|
||||
import SidebarFooter from "../feature-plugins/sidebar/footer"
|
||||
import PluginManager from "../feature-plugins/system/plugins"
|
||||
import Notifications from "../feature-plugins/system/notifications"
|
||||
import SessionV2Debug from "../feature-plugins/system/session-v2"
|
||||
import WhichKey from "../feature-plugins/system/which-key"
|
||||
import type { TuiPlugin, TuiPluginModule } from "@opencode-ai/plugin/tui"
|
||||
@@ -27,6 +28,7 @@ export const INTERNAL_TUI_PLUGINS: InternalTuiPlugin[] = [
|
||||
SidebarTodo,
|
||||
SidebarFiles,
|
||||
SidebarFooter,
|
||||
Notifications,
|
||||
PluginManager,
|
||||
WhichKey,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_EVENT_SYSTEM ? [SessionV2Debug] : []),
|
||||
|
||||
@@ -17,6 +17,7 @@ import { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { errorData, errorMessage } from "@/util/error"
|
||||
import { isRecord } from "@/util/record"
|
||||
import { resolveAttentionSoundPaths } from "../config/tui-schema"
|
||||
import {
|
||||
readPackageThemes,
|
||||
readPluginId,
|
||||
@@ -51,7 +52,7 @@ type PluginLoad = {
|
||||
id: string
|
||||
module: TuiPluginModule
|
||||
origin: ConfigPlugin.Origin
|
||||
theme_root: string
|
||||
plugin_root: string
|
||||
theme_files: string[]
|
||||
}
|
||||
|
||||
@@ -106,6 +107,7 @@ const ScopedKeymapMethods = new Set<PropertyKey>([
|
||||
type RuntimeState = {
|
||||
directory: string
|
||||
api: Api
|
||||
dispose?: () => void
|
||||
slots: HostSlots
|
||||
plugins: PluginEntry[]
|
||||
plugins_by_id: Map<string, PluginEntry>
|
||||
@@ -156,6 +158,37 @@ function createScopedKeymap(keymap: TuiPluginApi["keymap"], scope: PluginScope):
|
||||
})
|
||||
}
|
||||
|
||||
function createScopedAttention(
|
||||
attention: TuiPluginApi["attention"],
|
||||
scope: PluginScope,
|
||||
root: string,
|
||||
): TuiPluginApi["attention"] {
|
||||
return {
|
||||
notify(input) {
|
||||
return attention.notify(input)
|
||||
},
|
||||
soundboard: {
|
||||
registerPack(pack) {
|
||||
return scope.track(
|
||||
attention.soundboard.registerPack({
|
||||
...pack,
|
||||
sounds: resolveAttentionSoundPaths(root, pack.sounds, { trim: true }),
|
||||
}),
|
||||
)
|
||||
},
|
||||
activate(id, options) {
|
||||
return attention.soundboard.activate(id, options)
|
||||
},
|
||||
current() {
|
||||
return attention.soundboard.current()
|
||||
},
|
||||
list() {
|
||||
return attention.soundboard.list()
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type CleanupResult = { type: "ok" } | { type: "error"; error: unknown } | { type: "timeout" }
|
||||
|
||||
function runCleanup(fn: () => unknown, ms: number): Promise<CleanupResult> {
|
||||
@@ -204,8 +237,7 @@ function createThemeInstaller(
|
||||
plugin: PluginEntry,
|
||||
): TuiTheme["install"] {
|
||||
return async (file) => {
|
||||
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
||||
const src = path.isAbsolute(raw) ? raw : path.resolve(root, raw)
|
||||
const src = Filesystem.resolveFilePath(root, file)
|
||||
const name = path.basename(src, path.extname(src))
|
||||
const source_dir = path.dirname(meta.source)
|
||||
const local_dir =
|
||||
@@ -330,7 +362,7 @@ function loadInternalPlugin(item: InternalTuiPlugin): PluginLoad {
|
||||
scope: "global",
|
||||
source: target,
|
||||
},
|
||||
theme_root: process.cwd(),
|
||||
plugin_root: process.cwd(),
|
||||
theme_files: [],
|
||||
}
|
||||
}
|
||||
@@ -352,7 +384,7 @@ async function readThemeFiles(spec: string, pkg?: PluginPackage) {
|
||||
async function syncPluginThemes(plugin: PluginEntry) {
|
||||
if (!plugin.load.theme_files.length) return
|
||||
if (plugin.meta.state === "same") return
|
||||
const install = createThemeInstaller(plugin.load.origin, plugin.load.theme_root, plugin.load.spec, plugin)
|
||||
const install = createThemeInstaller(plugin.load.origin, plugin.load.plugin_root, plugin.load.spec, plugin)
|
||||
for (const file of plugin.load.theme_files) {
|
||||
await install(file).catch((error) => {
|
||||
warn("failed to sync tui plugin oc-themes", { path: plugin.load.spec, id: plugin.id, theme: file, error })
|
||||
@@ -552,7 +584,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||
}
|
||||
|
||||
const theme: TuiPluginApi["theme"] = Object.assign(Object.create(api.theme), {
|
||||
install: createThemeInstaller(load.origin, load.theme_root, load.spec, plugin),
|
||||
install: createThemeInstaller(load.origin, load.plugin_root, load.spec, plugin),
|
||||
})
|
||||
|
||||
const event: TuiPluginApi["event"] = {
|
||||
@@ -576,6 +608,7 @@ function pluginApi(runtime: RuntimeState, plugin: PluginEntry, scope: PluginScop
|
||||
|
||||
return {
|
||||
app: api.app,
|
||||
attention: createScopedAttention(api.attention, scope, load.plugin_root),
|
||||
// Keep deprecated `api.command` working for v1 plugins; remove in v2.
|
||||
command: createCommandShim(keymap, api.ui.dialog, api.tuiConfig.keybinds),
|
||||
keys: api.keys,
|
||||
@@ -682,7 +715,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P
|
||||
id,
|
||||
module: mod,
|
||||
origin,
|
||||
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
||||
plugin_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
||||
theme_files,
|
||||
}
|
||||
},
|
||||
@@ -709,7 +742,7 @@ async function resolveExternalPlugins(list: ConfigPlugin.Origin[], wait: () => P
|
||||
id,
|
||||
module: EMPTY_TUI,
|
||||
origin,
|
||||
theme_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
||||
plugin_root: loaded.pkg?.dir ?? resolveRoot(loaded.target),
|
||||
theme_files,
|
||||
}
|
||||
},
|
||||
@@ -967,7 +1000,7 @@ let loaded: Promise<void> | undefined
|
||||
let runtime: RuntimeState | undefined
|
||||
export const Slot = View
|
||||
|
||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved }) {
|
||||
export async function init(input: { api: HostPluginApi; config: TuiConfig.Resolved; dispose?: () => void }) {
|
||||
const cwd = process.cwd()
|
||||
if (loaded) {
|
||||
if (dir !== cwd) {
|
||||
@@ -1014,15 +1047,17 @@ export async function dispose() {
|
||||
for (const plugin of queue) {
|
||||
await deactivatePluginEntry(state, plugin, false)
|
||||
}
|
||||
state.dispose?.()
|
||||
}
|
||||
|
||||
async function load(input: { api: Api; config: TuiConfig.Resolved }) {
|
||||
async function load(input: { api: Api; config: TuiConfig.Resolved; dispose?: () => void }) {
|
||||
const { api, config } = input
|
||||
const cwd = process.cwd()
|
||||
const slots = setupSlots(api)
|
||||
const next: RuntimeState = {
|
||||
directory: cwd,
|
||||
api,
|
||||
dispose: input.dispose,
|
||||
slots,
|
||||
plugins: [],
|
||||
plugins_by_id: new Map(),
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Audio, type AudioErrorContext, type AudioPlayOptions, type AudioSound, type AudioVoice } from "@opentui/core"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const log = Log.create({ service: "tui.audio" })
|
||||
|
||||
let audio: Audio | null | undefined
|
||||
const sounds = new Map<string, Promise<AudioSound | null>>()
|
||||
|
||||
function getAudio() {
|
||||
if (audio !== undefined) return audio
|
||||
try {
|
||||
const next = Audio.create({ autoStart: false })
|
||||
next.on("error", (error: Error, context: AudioErrorContext) => {
|
||||
log.debug("tui audio error", { error, context })
|
||||
})
|
||||
audio = next
|
||||
return next
|
||||
} catch (error) {
|
||||
log.debug("failed to create tui audio", { error })
|
||||
audio = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSoundFile(file: string) {
|
||||
const current = getAudio()
|
||||
if (!current) return Promise.resolve(null)
|
||||
const cached = sounds.get(file)
|
||||
if (cached) return cached
|
||||
const task = Bun.file(file)
|
||||
.bytes()
|
||||
.then((bytes) => current.loadSound(bytes))
|
||||
.catch((error) => {
|
||||
log.debug("failed to load tui sound", { file, error })
|
||||
return null
|
||||
})
|
||||
sounds.set(file, task)
|
||||
return task
|
||||
}
|
||||
|
||||
export function play(sound: AudioSound, options?: AudioPlayOptions) {
|
||||
const current = getAudio()
|
||||
if (!current) return null
|
||||
if (!current.isStarted() && !current.start()) return null
|
||||
return current.play(sound, options)
|
||||
}
|
||||
|
||||
export function stopVoice(voice: AudioVoice) {
|
||||
return audio?.stopVoice(voice) ?? false
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
audio?.dispose()
|
||||
audio = undefined
|
||||
sounds.clear()
|
||||
}
|
||||
|
||||
export * as TuiAudio from "./audio"
|
||||
@@ -1,156 +0,0 @@
|
||||
import { Player } from "cli-sound"
|
||||
import { mkdirSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import { basename, join } from "node:path"
|
||||
import { Process } from "@/util/process"
|
||||
import { which } from "@/util/which"
|
||||
import pulseA from "../asset/pulse-a.wav" with { type: "file" }
|
||||
import pulseB from "../asset/pulse-b.wav" with { type: "file" }
|
||||
import pulseC from "../asset/pulse-c.wav" with { type: "file" }
|
||||
import charge from "../asset/charge.wav" with { type: "file" }
|
||||
|
||||
const FILE = [pulseA, pulseB, pulseC]
|
||||
|
||||
const HUM = charge
|
||||
const DIR = join(tmpdir(), "opencode-sfx")
|
||||
|
||||
const LIST = [
|
||||
"ffplay",
|
||||
"mpv",
|
||||
"mpg123",
|
||||
"mpg321",
|
||||
"mplayer",
|
||||
"afplay",
|
||||
"play",
|
||||
"omxplayer",
|
||||
"aplay",
|
||||
"cmdmp3",
|
||||
"cvlc",
|
||||
"powershell.exe",
|
||||
] as const
|
||||
|
||||
type Kind = (typeof LIST)[number]
|
||||
|
||||
function args(kind: Kind, file: string, volume: number) {
|
||||
if (kind === "ffplay") return [kind, "-autoexit", "-nodisp", "-af", `volume=${volume}`, file]
|
||||
if (kind === "mpv")
|
||||
return [kind, "--no-video", "--audio-display=no", "--volume", String(Math.round(volume * 100)), file]
|
||||
if (kind === "mpg123" || kind === "mpg321") return [kind, "-g", String(Math.round(volume * 100)), file]
|
||||
if (kind === "mplayer") return [kind, "-vo", "null", "-volume", String(Math.round(volume * 100)), file]
|
||||
if (kind === "afplay" || kind === "omxplayer" || kind === "aplay" || kind === "cmdmp3") return [kind, file]
|
||||
if (kind === "play") return [kind, "-v", String(volume), file]
|
||||
if (kind === "cvlc") return [kind, `--gain=${volume}`, "--play-and-exit", file]
|
||||
return [kind, "-c", `(New-Object Media.SoundPlayer '${file.replace(/'/g, "''")}').PlaySync()`]
|
||||
}
|
||||
|
||||
let item: Player | null | undefined
|
||||
let kind: Kind | null | undefined
|
||||
let proc: Process.Child | undefined
|
||||
let tail: ReturnType<typeof setTimeout> | undefined
|
||||
let cache: Promise<{ hum: string; pulse: string[] }> | undefined
|
||||
let seq = 0
|
||||
let shot = 0
|
||||
|
||||
function load() {
|
||||
if (item !== undefined) return item
|
||||
try {
|
||||
item = new Player({ volume: 0.35 })
|
||||
} catch {
|
||||
item = null
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
async function file(path: string) {
|
||||
mkdirSync(DIR, { recursive: true })
|
||||
const next = join(DIR, basename(path))
|
||||
const out = Bun.file(next)
|
||||
if (await out.exists()) return next
|
||||
await Bun.write(out, Bun.file(path))
|
||||
return next
|
||||
}
|
||||
|
||||
function asset() {
|
||||
cache ??= Promise.all([file(HUM), Promise.all(FILE.map(file))]).then(([hum, pulse]) => ({ hum, pulse }))
|
||||
return cache
|
||||
}
|
||||
|
||||
function pick() {
|
||||
if (kind !== undefined) return kind
|
||||
kind = LIST.find((item) => which(item)) ?? null
|
||||
return kind
|
||||
}
|
||||
|
||||
function run(file: string, volume: number) {
|
||||
const kind = pick()
|
||||
if (!kind) return
|
||||
return Process.spawn(args(kind, file, volume), {
|
||||
stdin: "ignore",
|
||||
stdout: "ignore",
|
||||
stderr: "ignore",
|
||||
})
|
||||
}
|
||||
|
||||
function clear() {
|
||||
if (!tail) return
|
||||
clearTimeout(tail)
|
||||
tail = undefined
|
||||
}
|
||||
|
||||
function play(file: string, volume: number) {
|
||||
const item = load()
|
||||
if (!item) return run(file, volume)?.exited
|
||||
return item.play(file, { volume }).catch(() => run(file, volume)?.exited)
|
||||
}
|
||||
|
||||
export function start() {
|
||||
stop()
|
||||
const id = ++seq
|
||||
void asset().then(({ hum }) => {
|
||||
if (id !== seq) return
|
||||
const next = run(hum, 0.24)
|
||||
if (!next) return
|
||||
proc = next
|
||||
void next.exited.then(
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
() => {
|
||||
if (id !== seq) return
|
||||
if (proc === next) proc = undefined
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export function stop(delay = 0) {
|
||||
seq++
|
||||
clear()
|
||||
if (!proc) return
|
||||
const next = proc
|
||||
if (delay <= 0) {
|
||||
proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
return
|
||||
}
|
||||
tail = setTimeout(() => {
|
||||
tail = undefined
|
||||
if (proc === next) proc = undefined
|
||||
void Process.stop(next).catch(() => undefined)
|
||||
}, delay)
|
||||
}
|
||||
|
||||
export function pulse(scale = 1) {
|
||||
stop(140)
|
||||
const index = shot++ % FILE.length
|
||||
void asset()
|
||||
.then(({ pulse }) => play(pulse[index], 0.26 + 0.14 * scale))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
export function dispose() {
|
||||
stop()
|
||||
}
|
||||
|
||||
export * as Sound from "./sound"
|
||||
@@ -55,6 +55,16 @@ function mergeConfigConcatArrays(target: Info, source: Info): Info {
|
||||
if (target.instructions && source.instructions) {
|
||||
merged.instructions = Array.from(new Set([...target.instructions, ...source.instructions]))
|
||||
}
|
||||
// Accumulate permission layers for later merging as rulesets.
|
||||
// This preserves the ordering semantics: later rules override earlier rules.
|
||||
// Each layer keeps the raw shape the user wrote on disk; consumers should use
|
||||
// ConfigPermission.toLayers to normalise.
|
||||
if (source.permission) {
|
||||
merged.permission = [
|
||||
...ConfigPermission.toLayers(target.permission),
|
||||
...ConfigPermission.toLayers(source.permission),
|
||||
]
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -228,7 +238,12 @@ export const Info = Schema.Struct({
|
||||
description: "Additional instruction files or patterns to include",
|
||||
}),
|
||||
layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }),
|
||||
permission: Schema.optional(ConfigPermission.Info),
|
||||
permission: Schema.optional(
|
||||
Schema.Union([ConfigPermission.Info, Schema.mutable(Schema.Array(ConfigPermission.Info))]),
|
||||
).annotate({
|
||||
description:
|
||||
"Permission configuration. Accepts a single object (per-tool action map) or an array of layered configs; arrays are merged in order so later layers override earlier ones.",
|
||||
}),
|
||||
tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)),
|
||||
attachment: Schema.optional(ConfigAttachment.Info).annotate({
|
||||
description: "Attachment processing configuration, including image size limits and resizing behavior",
|
||||
@@ -261,10 +276,10 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
tail_turns: Schema.optional(NonNegativeInt).annotate({
|
||||
description:
|
||||
"Number of recent user turns, including their following assistant/tool responses, to serialize into the compaction summary (default: 2)",
|
||||
"Number of recent user turns, including their following assistant/tool responses, to keep verbatim during compaction (default: 2)",
|
||||
}),
|
||||
preserve_recent_tokens: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "Maximum number of tokens from recent turns to serialize into the compaction summary",
|
||||
description: "Maximum number of tokens from recent turns to preserve verbatim after compaction",
|
||||
}),
|
||||
reserved: Schema.optional(NonNegativeInt).annotate({
|
||||
description: "Token buffer for compaction. Leaves enough window to avoid overflow during compaction.",
|
||||
@@ -704,11 +719,12 @@ export const layer = Layer.effect(
|
||||
}
|
||||
|
||||
if (Flag.OPENCODE_PERMISSION) {
|
||||
result.permission = mergeDeep(result.permission ?? {}, JSON.parse(Flag.OPENCODE_PERMISSION))
|
||||
const envPermission = JSON.parse(Flag.OPENCODE_PERMISSION) as ConfigPermission.Info
|
||||
result.permission = [...ConfigPermission.toLayers(result.permission), envPermission]
|
||||
}
|
||||
|
||||
if (result.tools) {
|
||||
const perms: Record<string, ConfigPermission.Action> = {}
|
||||
const perms: ConfigPermission.Info = {}
|
||||
for (const [tool, enabled] of Object.entries(result.tools)) {
|
||||
const action: ConfigPermission.Action = enabled ? "allow" : "deny"
|
||||
if (tool === "write" || tool === "edit" || tool === "patch") {
|
||||
@@ -717,7 +733,8 @@ export const layer = Layer.effect(
|
||||
}
|
||||
perms[tool] = action
|
||||
}
|
||||
result.permission = mergeDeep(perms, result.permission ?? {})
|
||||
// Tools permissions come before other permissions (they can be overridden)
|
||||
result.permission = [perms, ...ConfigPermission.toLayers(result.permission)]
|
||||
}
|
||||
|
||||
if (!result.username) result.username = os.userInfo().username
|
||||
|
||||
@@ -56,3 +56,11 @@ export const Info = InputSchema.pipe(
|
||||
).annotate({ identifier: "PermissionConfig" })
|
||||
type _Info = Schema.Schema.Type<typeof InputObject>
|
||||
export type Info = { -readonly [K in keyof _Info]: _Info[K] }
|
||||
|
||||
// Top-level config accepts either a single permission object or an array of
|
||||
// layered configs. Internal merging produces arrays; this helper normalises
|
||||
// either shape into the array form expected by consumers.
|
||||
export function toLayers(value: Info | Info[] | undefined): Info[] {
|
||||
if (!value) return []
|
||||
return Array.isArray(value) ? value : [value]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Config, ConfigProvider, Context, Effect, Layer } from "effect"
|
||||
import { ConfigService } from "@/effect/config-service"
|
||||
|
||||
const bool = (name: string) => Config.boolean(name).pipe(Config.withDefault(false))
|
||||
const experimental = bool("OPENCODE_EXPERIMENTAL")
|
||||
const enabledByExperimental = (name: string) =>
|
||||
Config.all({ experimental, enabled: bool(name) }).pipe(Config.map((flags) => flags.experimental || flags.enabled))
|
||||
|
||||
export class Service extends ConfigService.Service<Service>()("@opencode/RuntimeFlags", {
|
||||
pure: bool("OPENCODE_PURE"),
|
||||
disableDefaultPlugins: bool("OPENCODE_DISABLE_DEFAULT_PLUGINS"),
|
||||
enableExa: Config.all({
|
||||
experimental,
|
||||
enabled: bool("OPENCODE_ENABLE_EXA"),
|
||||
legacy: bool("OPENCODE_EXPERIMENTAL_EXA"),
|
||||
}).pipe(Config.map((flags) => flags.experimental || flags.enabled || flags.legacy)),
|
||||
enableParallel: Config.all({
|
||||
enabled: bool("OPENCODE_ENABLE_PARALLEL"),
|
||||
legacy: bool("OPENCODE_EXPERIMENTAL_PARALLEL"),
|
||||
}).pipe(Config.map((flags) => flags.enabled || flags.legacy)),
|
||||
enableQuestionTool: bool("OPENCODE_ENABLE_QUESTION_TOOL"),
|
||||
experimentalScout: enabledByExperimental("OPENCODE_EXPERIMENTAL_SCOUT"),
|
||||
experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"),
|
||||
experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"),
|
||||
client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")),
|
||||
}) {}
|
||||
|
||||
export type Info = Context.Service.Shape<typeof Service>
|
||||
|
||||
const emptyConfigLayer = Service.defaultLayer.pipe(
|
||||
Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown({}))),
|
||||
Layer.orDie,
|
||||
)
|
||||
|
||||
export const layer = (overrides: Partial<Info> = {}) =>
|
||||
Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* Service
|
||||
return Service.of({ ...flags, ...overrides })
|
||||
}),
|
||||
).pipe(Layer.provide(emptyConfigLayer))
|
||||
|
||||
export const defaultLayer = Service.defaultLayer.pipe(Layer.orDie)
|
||||
|
||||
export * as RuntimeFlags from "./runtime-flags"
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
|
||||
import type { Model } from "@opencode-ai/sdk/v2"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { createServer } from "http"
|
||||
|
||||
const log = Log.create({ service: "plugin.digitalocean" })
|
||||
|
||||
const DO_OAUTH_CLIENT_ID = "b1a6c5158156caac821fd1b30253ca8acb52454a48fa744420e41889cb589f82"
|
||||
const DO_AUTHORIZE_URL = "https://cloud.digitalocean.com/v1/oauth/authorize"
|
||||
const DO_API_BASE = "https://api.digitalocean.com"
|
||||
const DO_INFERENCE_BASE = "https://inference.do-ai.run/v1"
|
||||
const OAUTH_PORT = 1456
|
||||
const OAUTH_REDIRECT_PATH = "/auth/callback"
|
||||
const OAUTH_TOKEN_PATH = "/auth/token"
|
||||
const ROUTER_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAK_NAME_PREFIX = "opencode-oauth"
|
||||
|
||||
interface ImplicitTokenPayload {
|
||||
access_token: string
|
||||
expires_in: number
|
||||
state: string
|
||||
}
|
||||
|
||||
interface PendingOAuth {
|
||||
state: string
|
||||
resolve: (tokens: ImplicitTokenPayload) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
interface ApiKeyInfo {
|
||||
uuid: string
|
||||
name: string
|
||||
secret_key: string
|
||||
}
|
||||
|
||||
interface RouterEntry {
|
||||
name: string
|
||||
uuid?: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
let oauthServer: ReturnType<typeof createServer> | undefined
|
||||
let pendingOAuth: PendingOAuth | undefined
|
||||
|
||||
function generateState(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32))
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, "0"))
|
||||
.join("")
|
||||
}
|
||||
|
||||
function redirectUri(): string {
|
||||
return `http://localhost:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}`
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
response_type: "token",
|
||||
client_id: DO_OAUTH_CLIENT_ID,
|
||||
redirect_uri: redirectUri(),
|
||||
scope: "read write",
|
||||
state,
|
||||
})
|
||||
return `${DO_AUTHORIZE_URL}?${params.toString()}`
|
||||
}
|
||||
|
||||
const HTML_CALLBACK = `<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>OpenCode - DigitalOcean Authorization</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0b1220; color: #e8eef9; }
|
||||
.container { text-align: center; padding: 2rem; max-width: 32rem; }
|
||||
h1 { color: #e8eef9; margin-bottom: 1rem; }
|
||||
p { color: #9aa9c0; }
|
||||
.error { color: #ff917b; font-family: monospace; margin-top: 1rem; padding: 1rem; background: #3c140d; border-radius: 0.5rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1 id="title">Finishing sign-in...</h1>
|
||||
<p id="msg">You can close this window once it says you're signed in.</p>
|
||||
</div>
|
||||
<script>
|
||||
(async function() {
|
||||
const params = new URLSearchParams((window.location.hash || "").slice(1))
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const error = params.get("error") || search.get("error")
|
||||
const errorDescription = params.get("error_description") || search.get("error_description")
|
||||
const titleEl = document.getElementById("title")
|
||||
const msgEl = document.getElementById("msg")
|
||||
try {
|
||||
const body = error
|
||||
? { error, error_description: errorDescription || "" }
|
||||
: { access_token: params.get("access_token") || "", expires_in: params.get("expires_in") || "0", state: params.get("state") || "" }
|
||||
await fetch(${JSON.stringify(OAUTH_TOKEN_PATH)}, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
if (error) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = errorDescription || error
|
||||
msgEl.className = "error"
|
||||
return
|
||||
}
|
||||
titleEl.textContent = "Authorization Successful"
|
||||
msgEl.textContent = "You can close this window and return to OpenCode."
|
||||
setTimeout(function () { window.close() }, 2000)
|
||||
} catch (e) {
|
||||
titleEl.textContent = "Authorization Failed"
|
||||
msgEl.textContent = String(e && e.message ? e.message : e)
|
||||
msgEl.className = "error"
|
||||
}
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
async function startOAuthServer(): Promise<void> {
|
||||
if (oauthServer) return
|
||||
oauthServer = createServer((req, res) => {
|
||||
const url = new URL(req.url || "/", `http://localhost:${OAUTH_PORT}`)
|
||||
|
||||
if (req.method === "GET" && url.pathname === OAUTH_REDIRECT_PATH) {
|
||||
res.writeHead(200, { "Content-Type": "text/html" })
|
||||
res.end(HTML_CALLBACK)
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === OAUTH_TOKEN_PATH) {
|
||||
const chunks: Buffer[] = []
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk))
|
||||
req.on("end", () => {
|
||||
const raw = Buffer.concat(chunks).toString("utf8")
|
||||
let body: Record<string, string> = {}
|
||||
try {
|
||||
body = raw ? JSON.parse(raw) : {}
|
||||
} catch {
|
||||
body = {}
|
||||
}
|
||||
if (!pendingOAuth) {
|
||||
res.writeHead(409, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "no_pending_oauth" }))
|
||||
return
|
||||
}
|
||||
if (body.error) {
|
||||
const message = body.error_description || body.error || "OAuth error"
|
||||
pendingOAuth.reject(new Error(String(message)))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
return
|
||||
}
|
||||
if (!body.access_token) {
|
||||
pendingOAuth.reject(new Error("Missing access_token in callback"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "missing_access_token" }))
|
||||
return
|
||||
}
|
||||
if (body.state !== pendingOAuth.state) {
|
||||
pendingOAuth.reject(new Error("Invalid state - potential CSRF attack"))
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(400, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ error: "invalid_state" }))
|
||||
return
|
||||
}
|
||||
const expires = parseInt(body.expires_in || "0", 10)
|
||||
pendingOAuth.resolve({
|
||||
access_token: body.access_token,
|
||||
expires_in: Number.isFinite(expires) && expires > 0 ? expires : 60 * 60 * 24 * 30,
|
||||
state: body.state,
|
||||
})
|
||||
pendingOAuth = undefined
|
||||
res.writeHead(200, { "Content-Type": "application/json" })
|
||||
res.end(JSON.stringify({ ok: true }))
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404)
|
||||
res.end("Not found")
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
oauthServer!.listen(OAUTH_PORT, () => {
|
||||
log.info("digitalocean oauth server started", { port: OAUTH_PORT })
|
||||
resolve()
|
||||
})
|
||||
oauthServer!.on("error", reject)
|
||||
})
|
||||
}
|
||||
|
||||
function stopOAuthServer() {
|
||||
if (!oauthServer) return
|
||||
oauthServer.close(() => log.info("digitalocean oauth server stopped"))
|
||||
oauthServer = undefined
|
||||
}
|
||||
|
||||
function waitForOAuthCallback(state: string): Promise<ImplicitTokenPayload> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => {
|
||||
if (pendingOAuth) {
|
||||
pendingOAuth = undefined
|
||||
reject(new Error("OAuth callback timeout - authorization took too long"))
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000,
|
||||
)
|
||||
pendingOAuth = {
|
||||
state,
|
||||
resolve: (tokens) => {
|
||||
clearTimeout(timeout)
|
||||
resolve(tokens)
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function createModelAccessKey(bearer: string): Promise<ApiKeyInfo> {
|
||||
// Suffix-on-collision strategy keeps re-`/connect` non-destructive.
|
||||
const name = `${MAK_NAME_PREFIX}-${Math.floor(Date.now() / 1000)}`
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/api_keys`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "")
|
||||
throw new Error(`Failed to create Model Access Key (${res.status}): ${body}`)
|
||||
}
|
||||
const data = (await res.json()) as { api_key_info?: ApiKeyInfo }
|
||||
if (!data.api_key_info?.secret_key) throw new Error("Model Access Key response missing secret_key")
|
||||
return data.api_key_info
|
||||
}
|
||||
|
||||
async function listRouters(
|
||||
bearer: string,
|
||||
): Promise<{ ok: true; routers: RouterEntry[] } | { ok: false; status: number }> {
|
||||
const res = await fetch(`${DO_API_BASE}/v2/gen-ai/models/routers`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `opencode/${InstallationVersion}`,
|
||||
},
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(() => undefined)
|
||||
if (!res) return { ok: false, status: 0 }
|
||||
if (!res.ok) return { ok: false, status: res.status }
|
||||
const body = (await res.json().catch(() => undefined)) as { model_routers?: RouterEntry[] } | undefined
|
||||
return { ok: true, routers: body?.model_routers ?? [] }
|
||||
}
|
||||
|
||||
function routerModel(router: RouterEntry, providerID: string): Model {
|
||||
const id = `router:${router.name}`
|
||||
return {
|
||||
id,
|
||||
providerID,
|
||||
name: router.name,
|
||||
family: "digitalocean-inference-routers",
|
||||
api: { id, url: DO_INFERENCE_BASE, npm: "@ai-sdk/openai-compatible" },
|
||||
status: "active",
|
||||
headers: {},
|
||||
options: {},
|
||||
cost: { input: 0, output: 0, cache: { read: 0, write: 0 } },
|
||||
limit: { context: 128_000, output: 8_192 },
|
||||
capabilities: {
|
||||
temperature: true,
|
||||
reasoning: false,
|
||||
attachment: false,
|
||||
toolcall: true,
|
||||
input: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||
interleaved: false,
|
||||
},
|
||||
release_date: "",
|
||||
variants: {},
|
||||
}
|
||||
}
|
||||
|
||||
function parseRoutersJSON(raw: string | undefined): RouterEntry[] {
|
||||
if (!raw) return []
|
||||
try {
|
||||
const parsed = JSON.parse(raw)
|
||||
if (!Array.isArray(parsed)) return []
|
||||
return parsed.flatMap((r) =>
|
||||
r && typeof r.name === "string" ? [{ name: r.name, uuid: r.uuid, description: r.description }] : [],
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function DigitalOceanAuthPlugin(input: PluginInput): Promise<Hooks> {
|
||||
return {
|
||||
provider: {
|
||||
id: "digitalocean",
|
||||
async models(provider, ctx) {
|
||||
const baseModels = provider.models
|
||||
if (ctx.auth?.type !== "api") return baseModels
|
||||
|
||||
const metadata = ctx.auth.metadata ?? {}
|
||||
const oauthAccess = metadata["oauth_access"]
|
||||
const oauthExpires = parseInt(metadata["oauth_expires"] || "0", 10)
|
||||
const fetchedAt = parseInt(metadata["routers_fetched_at"] || "0", 10)
|
||||
const cached = parseRoutersJSON(metadata["routers"])
|
||||
|
||||
let routers = cached
|
||||
const stale = Date.now() - fetchedAt > ROUTER_REFRESH_INTERVAL_MS
|
||||
const bearerValid = oauthAccess && oauthExpires > Date.now()
|
||||
|
||||
if (bearerValid && stale) {
|
||||
const result = await listRouters(oauthAccess)
|
||||
if (result.ok) {
|
||||
routers = result.routers
|
||||
const updated: Record<string, string> = {
|
||||
...metadata,
|
||||
routers: JSON.stringify(routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description }))),
|
||||
routers_fetched_at: String(Date.now()),
|
||||
}
|
||||
await input.client.auth
|
||||
.set({
|
||||
path: { id: "digitalocean" },
|
||||
body: { type: "api", key: ctx.auth.key, metadata: updated },
|
||||
})
|
||||
.catch((err) => log.warn("failed to persist refreshed routers", { error: err }))
|
||||
} else if (result.status === 401 || result.status === 403) {
|
||||
log.warn("digitalocean oauth bearer rejected; using cached routers", { status: result.status })
|
||||
} else if (result.status !== 0) {
|
||||
log.warn("digitalocean router refresh failed", { status: result.status })
|
||||
}
|
||||
}
|
||||
|
||||
const merged: Record<string, Model> = { ...baseModels }
|
||||
for (const router of routers) {
|
||||
const id = `router:${router.name}`
|
||||
if (merged[id]) continue
|
||||
merged[id] = routerModel(router, "digitalocean")
|
||||
}
|
||||
return merged
|
||||
},
|
||||
},
|
||||
auth: {
|
||||
provider: "digitalocean",
|
||||
methods: [
|
||||
{
|
||||
type: "oauth",
|
||||
label: "Login with DigitalOcean",
|
||||
async authorize() {
|
||||
await startOAuthServer()
|
||||
const state = generateState()
|
||||
const callbackPromise = waitForOAuthCallback(state)
|
||||
return {
|
||||
url: buildAuthorizeUrl(state),
|
||||
instructions:
|
||||
"Sign in to DigitalOcean in your browser. OpenCode will create a Model Access Key named opencode-oauth-* and load your Inference Routers. Re-run /connect to refresh routers later.",
|
||||
method: "auto" as const,
|
||||
async callback() {
|
||||
try {
|
||||
const tokens = await callbackPromise
|
||||
const apiKeyInfo = await createModelAccessKey(tokens.access_token)
|
||||
const routerResult = await listRouters(tokens.access_token)
|
||||
const routers = routerResult.ok ? routerResult.routers : []
|
||||
if (!routerResult.ok) {
|
||||
log.warn("digitalocean initial router fetch failed", { status: routerResult.status })
|
||||
}
|
||||
return {
|
||||
type: "success" as const,
|
||||
provider: "digitalocean",
|
||||
key: apiKeyInfo.secret_key,
|
||||
metadata: {
|
||||
mak_uuid: apiKeyInfo.uuid,
|
||||
mak_name: apiKeyInfo.name,
|
||||
oauth_access: tokens.access_token,
|
||||
oauth_expires: String(Date.now() + tokens.expires_in * 1000),
|
||||
routers: JSON.stringify(
|
||||
routers.map((r) => ({ name: r.name, uuid: r.uuid, description: r.description })),
|
||||
),
|
||||
routers_fetched_at: String(Date.now()),
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
log.error("digitalocean oauth callback failed", { error: err })
|
||||
return { type: "failed" as const }
|
||||
} finally {
|
||||
stopOAuthServer()
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "api",
|
||||
label: "Paste Model Access Key",
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Config } from "@/config/config"
|
||||
import { Bus } from "../bus"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { CodexAuthPlugin } from "./codex"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -19,6 +18,7 @@ import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth"
|
||||
import { PoeAuthPlugin } from "opencode-poe-auth"
|
||||
import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare"
|
||||
import { AzureAuthPlugin } from "./azure"
|
||||
import { DigitalOceanAuthPlugin } from "./digitalocean"
|
||||
import { Effect, Layer, Context, Stream } from "effect"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceState } from "@/effect/instance-state"
|
||||
@@ -27,6 +27,7 @@ import { PluginLoader } from "./loader"
|
||||
import { parsePluginSpecifier, readPluginId, readV1Plugin, resolvePluginId } from "./shared"
|
||||
import { registerAdapter } from "@/control-plane/adapters"
|
||||
import type { WorkspaceAdapter } from "@/control-plane/types"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const log = Log.create({ service: "plugin" })
|
||||
|
||||
@@ -64,6 +65,7 @@ const INTERNAL_PLUGINS: PluginInstance[] = [
|
||||
CloudflareWorkersAuthPlugin,
|
||||
CloudflareAIGatewayAuthPlugin,
|
||||
AzureAuthPlugin,
|
||||
DigitalOceanAuthPlugin,
|
||||
]
|
||||
|
||||
function isServerPlugin(value: unknown): value is PluginInstance {
|
||||
@@ -110,6 +112,7 @@ export const layer = Layer.effect(
|
||||
Effect.gen(function* () {
|
||||
const bus = yield* Bus.Service
|
||||
const config = yield* Config.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const state = yield* InstanceState.make<State>(
|
||||
Effect.fn("Plugin.state")(function* (ctx) {
|
||||
@@ -146,7 +149,7 @@ export const layer = Layer.effect(
|
||||
$: typeof Bun === "undefined" ? undefined : Bun.$,
|
||||
}
|
||||
|
||||
for (const plugin of INTERNAL_PLUGINS) {
|
||||
for (const plugin of flags.disableDefaultPlugins ? [] : INTERNAL_PLUGINS) {
|
||||
log.info("loading internal plugin", { name: plugin.name })
|
||||
const init = yield* Effect.tryPromise({
|
||||
try: () => plugin(input),
|
||||
@@ -157,8 +160,8 @@ export const layer = Layer.effect(
|
||||
if (init._tag === "Some") hooks.push(init.value)
|
||||
}
|
||||
|
||||
const plugins = Flag.OPENCODE_PURE ? [] : (cfg.plugin_origins ?? [])
|
||||
if (Flag.OPENCODE_PURE && cfg.plugin_origins?.length) {
|
||||
const plugins = flags.pure ? [] : (cfg.plugin_origins ?? [])
|
||||
if (flags.pure && cfg.plugin_origins?.length) {
|
||||
log.info("skipping external plugins in pure mode", { count: cfg.plugin_origins.length })
|
||||
}
|
||||
if (plugins.length) yield* config.waitForDependencies()
|
||||
@@ -283,6 +286,10 @@ export const layer = Layer.effect(
|
||||
}),
|
||||
)
|
||||
|
||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer))
|
||||
export const defaultLayer = layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
)
|
||||
|
||||
export * as Plugin from "."
|
||||
|
||||
@@ -197,6 +197,7 @@ export const layer: Layer.Layer<Service, never, Auth.Service | Plugin.Service> =
|
||||
yield* auth.set(input.providerID, {
|
||||
type: "api",
|
||||
key: result.key,
|
||||
...(result.metadata ? { metadata: result.metadata } : {}),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -10,11 +10,23 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||
import { CatalogModelStatus } from "./model-status"
|
||||
|
||||
const CostTier = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Finite,
|
||||
}),
|
||||
})
|
||||
|
||||
const Cost = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache_read: Schema.optional(Schema.Finite),
|
||||
cache_write: Schema.optional(Schema.Finite),
|
||||
tiers: Schema.optional(Schema.Array(CostTier)),
|
||||
context_over_200k: Schema.optional(
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
|
||||
@@ -869,10 +869,21 @@ const ProviderCacheCost = Schema.Struct({
|
||||
write: Schema.Finite,
|
||||
})
|
||||
|
||||
const ProviderCostTier = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: ProviderCacheCost,
|
||||
tier: Schema.Struct({
|
||||
type: Schema.Literal("context"),
|
||||
size: Schema.Finite,
|
||||
}),
|
||||
})
|
||||
|
||||
const ProviderCost = Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
output: Schema.Finite,
|
||||
cache: ProviderCacheCost,
|
||||
tiers: optionalOmitUndefined(Schema.Array(ProviderCostTier)),
|
||||
experimentalOver200K: optionalOmitUndefined(
|
||||
Schema.Struct({
|
||||
input: Schema.Finite,
|
||||
@@ -977,6 +988,17 @@ function cost(c: ModelsDev.Model["cost"]): Model["cost"] {
|
||||
write: c?.cache_write ?? 0,
|
||||
},
|
||||
}
|
||||
if (c?.tiers) {
|
||||
result.tiers = c.tiers.map((item) => ({
|
||||
input: item.input,
|
||||
output: item.output,
|
||||
cache: {
|
||||
read: item.cache_read ?? 0,
|
||||
write: item.cache_write ?? 0,
|
||||
},
|
||||
tier: item.tier,
|
||||
}))
|
||||
}
|
||||
if (c?.context_over_200k) {
|
||||
result.experimentalOver200K = {
|
||||
cache: {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import * as InstanceState from "@/effect/instance-state"
|
||||
import { InstanceRef, WorkspaceRef } from "@/effect/instance-ref"
|
||||
import { Agent } from "@/agent/agent"
|
||||
import { Bus } from "@/bus"
|
||||
import { Command } from "@/command"
|
||||
@@ -275,18 +273,12 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session",
|
||||
params: { sessionID: SessionID }
|
||||
payload: typeof PromptPayload.Type
|
||||
}) {
|
||||
const instance = yield* InstanceState.context
|
||||
const workspace = yield* InstanceState.workspaceID
|
||||
const message = yield* promptSvc
|
||||
.prompt({
|
||||
...ctx.payload,
|
||||
sessionID: ctx.params.sessionID,
|
||||
})
|
||||
.pipe(
|
||||
Effect.provideService(InstanceRef, instance),
|
||||
Effect.provideService(WorkspaceRef, workspace),
|
||||
Effect.mapError(() => new HttpApiError.BadRequest({})),
|
||||
)
|
||||
.pipe(Effect.mapError(() => new HttpApiError.BadRequest({})))
|
||||
return HttpServerResponse.stream(Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), {
|
||||
contentType: "application/json",
|
||||
})
|
||||
|
||||
@@ -79,10 +79,12 @@ Rules:
|
||||
type Turn = {
|
||||
start: number
|
||||
end: number
|
||||
id: MessageID
|
||||
}
|
||||
|
||||
type Tail = {
|
||||
start: number
|
||||
id: MessageID
|
||||
}
|
||||
|
||||
type CompletedCompaction = {
|
||||
@@ -119,41 +121,19 @@ function completedCompactions(messages: MessageV2.WithParts[]) {
|
||||
})
|
||||
}
|
||||
|
||||
function buildPrompt(input: { previousSummary?: string; context: string[]; tail?: string }) {
|
||||
const source = input.tail
|
||||
? "the conversation history above and the serialized recent conversation tail below"
|
||||
: "the conversation history above"
|
||||
function buildPrompt(input: { previousSummary?: string; context: string[] }) {
|
||||
const anchor = input.previousSummary
|
||||
? [
|
||||
`Update the anchored summary below using ${source}.`,
|
||||
"Update the anchored summary below using the conversation history above.",
|
||||
"Preserve still-true details, remove stale details, and merge in the new facts.",
|
||||
"<previous-summary>",
|
||||
input.previousSummary,
|
||||
"</previous-summary>",
|
||||
].join("\n")
|
||||
: `Create a new anchored summary from ${source}.`
|
||||
const tail = input.tail
|
||||
? [
|
||||
"Fold this serialized recent conversation tail into the summary; it is not provider message history.",
|
||||
"<recent-conversation-tail>",
|
||||
input.tail,
|
||||
"</recent-conversation-tail>",
|
||||
].join("\n")
|
||||
: undefined
|
||||
return [anchor, ...(tail ? [tail] : []), SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||
: "Create a new anchored summary from the conversation history above."
|
||||
return [anchor, SUMMARY_TEMPLATE, ...input.context].join("\n\n")
|
||||
}
|
||||
|
||||
const serialize = Effect.fn("SessionCompaction.serialize")(function* (input: {
|
||||
messages: MessageV2.WithParts[]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const messages = yield* MessageV2.toModelMessagesEffect(input.messages, input.model, {
|
||||
stripMedia: true,
|
||||
toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS,
|
||||
})
|
||||
return messages.length ? JSON.stringify(messages, null, 2) : undefined
|
||||
})
|
||||
|
||||
function preserveRecentBudget(input: { cfg: Config.Info; model: Provider.Model }) {
|
||||
return (
|
||||
input.cfg.compaction?.preserve_recent_tokens ??
|
||||
@@ -170,6 +150,7 @@ function turns(messages: MessageV2.WithParts[]) {
|
||||
result.push({
|
||||
start: i,
|
||||
end: messages.length,
|
||||
id: msg.info.id,
|
||||
})
|
||||
}
|
||||
for (let i = 0; i < result.length - 1; i++) {
|
||||
@@ -196,6 +177,7 @@ function splitTurn(input: {
|
||||
if (size > input.budget) continue
|
||||
return {
|
||||
start,
|
||||
id: input.messages[start]!.info.id,
|
||||
} satisfies Tail
|
||||
}
|
||||
return undefined
|
||||
@@ -262,7 +244,8 @@ export const layer: Layer.Layer<
|
||||
messages: MessageV2.WithParts[]
|
||||
model: Provider.Model
|
||||
}) {
|
||||
return Token.estimate((yield* serialize(input)) ?? "")
|
||||
const msgs = yield* MessageV2.toModelMessagesEffect(input.messages, input.model)
|
||||
return Token.estimate(JSON.stringify(msgs))
|
||||
})
|
||||
|
||||
const select = Effect.fn("SessionCompaction.select")(function* (input: {
|
||||
@@ -271,10 +254,10 @@ export const layer: Layer.Layer<
|
||||
model: Provider.Model
|
||||
}) {
|
||||
const limit = input.cfg.compaction?.tail_turns ?? DEFAULT_TAIL_TURNS
|
||||
if (limit <= 0) return { head: input.messages, tail: [] }
|
||||
if (limit <= 0) return { head: input.messages, tail_start_id: undefined }
|
||||
const budget = preserveRecentBudget({ cfg: input.cfg, model: input.model })
|
||||
const all = turns(input.messages)
|
||||
if (!all.length) return { head: input.messages, tail: [] }
|
||||
if (!all.length) return { head: input.messages, tail_start_id: undefined }
|
||||
const recent = all.slice(-limit)
|
||||
const sizes = yield* Effect.forEach(
|
||||
recent,
|
||||
@@ -293,7 +276,7 @@ export const layer: Layer.Layer<
|
||||
const size = sizes[i]
|
||||
if (total + size <= budget) {
|
||||
total += size
|
||||
keep = { start: turn.start }
|
||||
keep = { start: turn.start, id: turn.id }
|
||||
continue
|
||||
}
|
||||
const remaining = budget - total
|
||||
@@ -309,10 +292,10 @@ export const layer: Layer.Layer<
|
||||
break
|
||||
}
|
||||
|
||||
if (!keep) return { head: input.messages, tail: [] }
|
||||
if (!keep || keep.start === 0) return { head: input.messages, tail_start_id: undefined }
|
||||
return {
|
||||
head: input.messages.slice(0, keep.start),
|
||||
tail: input.messages.slice(keep.start),
|
||||
tail_start_id: keep.id,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -423,10 +406,7 @@ export const layer: Layer.Layer<
|
||||
{ sessionID: input.sessionID },
|
||||
{ context: [], prompt: undefined },
|
||||
)
|
||||
const tailMessages = structuredClone(selected.tail)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: tailMessages })
|
||||
const tail = yield* serialize({ messages: tailMessages, model })
|
||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context, tail })
|
||||
const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context })
|
||||
const msgs = structuredClone(selected.head)
|
||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||
const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, {
|
||||
@@ -493,6 +473,13 @@ export const layer: Layer.Layer<
|
||||
return "stop"
|
||||
}
|
||||
|
||||
if (compactionPart && selected.tail_start_id && compactionPart.tail_start_id !== selected.tail_start_id) {
|
||||
yield* session.updatePart({
|
||||
...compactionPart,
|
||||
tail_start_id: selected.tail_start_id,
|
||||
})
|
||||
}
|
||||
|
||||
if (result === "continue" && input.auto) {
|
||||
if (replay) {
|
||||
const original = replay.info
|
||||
@@ -588,6 +575,7 @@ export const layer: Layer.Layer<
|
||||
sessionID: input.sessionID,
|
||||
timestamp: DateTime.makeUnsafe(Date.now()),
|
||||
text: summary ?? "",
|
||||
include: selected.tail_start_id,
|
||||
})
|
||||
}
|
||||
yield* bus.publish(Event.Compacted, { sessionID: input.sessionID })
|
||||
|
||||
@@ -772,13 +772,12 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
|
||||
return part.metadata?.anthropic?.signature != null
|
||||
})
|
||||
for (const part of msg.parts) {
|
||||
if (msg.info.summary && part.type !== "text") continue
|
||||
if (part.type === "text") {
|
||||
const text = part.text === "" && hasSignedReasoning ? " " : part.text
|
||||
assistantMessage.parts.push({
|
||||
type: "text",
|
||||
text,
|
||||
...(differentModel || msg.info.summary ? {} : { providerMetadata: part.metadata }),
|
||||
...(differentModel ? {} : { providerMetadata: part.metadata }),
|
||||
})
|
||||
}
|
||||
if (part.type === "step-start")
|
||||
@@ -1004,16 +1003,53 @@ export function get(input: { sessionID: SessionID; messageID: MessageID }): With
|
||||
export function filterCompacted(msgs: Iterable<WithParts>) {
|
||||
const result = [] as WithParts[]
|
||||
const completed = new Set<string>()
|
||||
let retain: MessageID | undefined
|
||||
for (const msg of msgs) {
|
||||
result.push(msg)
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id)) {
|
||||
if (msg.parts.some((item): item is CompactionPart => item.type === "compaction")) break
|
||||
if (retain) {
|
||||
if (msg.info.id === retain) break
|
||||
continue
|
||||
}
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id)) {
|
||||
const part = msg.parts.find((item): item is CompactionPart => item.type === "compaction")
|
||||
if (!part) continue
|
||||
if (!part.tail_start_id) break
|
||||
retain = part.tail_start_id
|
||||
if (msg.info.id === retain) break
|
||||
continue
|
||||
}
|
||||
if (msg.info.role === "user" && completed.has(msg.info.id) && msg.parts.some((part) => part.type === "compaction"))
|
||||
break
|
||||
if (msg.info.role === "assistant" && msg.info.summary && msg.info.finish && !msg.info.error)
|
||||
completed.add(msg.info.parentID)
|
||||
}
|
||||
result.reverse()
|
||||
const compactionIndex = result.findLastIndex(
|
||||
(msg) =>
|
||||
msg.info.role === "user" &&
|
||||
msg.parts.some((item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined),
|
||||
)
|
||||
const compaction = result[compactionIndex]
|
||||
const part = compaction?.parts.find(
|
||||
(item): item is CompactionPart => item.type === "compaction" && item.tail_start_id !== undefined,
|
||||
)
|
||||
const summaryIndex = compaction
|
||||
? result.findIndex(
|
||||
(msg, index) =>
|
||||
index > compactionIndex &&
|
||||
msg.info.role === "assistant" &&
|
||||
msg.info.summary &&
|
||||
msg.info.parentID === compaction.info.id,
|
||||
)
|
||||
: -1
|
||||
const tailIndex = part?.tail_start_id ? result.findIndex((msg) => msg.info.id === part.tail_start_id) : -1
|
||||
if (tailIndex >= 0 && tailIndex < compactionIndex && summaryIndex > compactionIndex) {
|
||||
return [
|
||||
...result.slice(compactionIndex, summaryIndex + 1),
|
||||
...result.slice(tailIndex, compactionIndex),
|
||||
...result.slice(summaryIndex + 1),
|
||||
]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
|
||||
@@ -418,10 +418,14 @@ export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsa
|
||||
},
|
||||
}
|
||||
|
||||
const contextTokens = inputTokens
|
||||
const costInfo =
|
||||
input.model.cost?.experimentalOver200K && tokens.input + tokens.cache.read > 200_000
|
||||
input.model.cost?.tiers
|
||||
?.filter((item) => item.tier.type === "context" && contextTokens > item.tier.size)
|
||||
.sort((a, b) => b.tier.size - a.tier.size)[0] ??
|
||||
(input.model.cost?.experimentalOver200K && contextTokens > 200_000
|
||||
? input.model.cost.experimentalOver200K
|
||||
: input.model.cost
|
||||
: input.model.cost)
|
||||
return {
|
||||
cost: safe(
|
||||
new Decimal(0)
|
||||
|
||||
@@ -24,7 +24,6 @@ import { ProviderID, type ModelID } from "../provider/schema"
|
||||
import { WebSearchTool } from "./websearch"
|
||||
import { RepoCloneTool } from "./repo_clone"
|
||||
import { RepoOverviewTool } from "./repo_overview"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { LspTool } from "./lsp"
|
||||
import * as Truncate from "./truncate"
|
||||
@@ -50,13 +49,11 @@ import { Git } from "@/git"
|
||||
import { Skill } from "../skill"
|
||||
import { Permission } from "@/permission"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
export function webSearchEnabled(
|
||||
providerID: ProviderID,
|
||||
flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL },
|
||||
) {
|
||||
export function webSearchEnabled(providerID: ProviderID, flags = { exa: false, parallel: false }) {
|
||||
return providerID === ProviderID.opencode || flags.exa || flags.parallel
|
||||
}
|
||||
|
||||
@@ -101,6 +98,7 @@ export const layer: Layer.Layer<
|
||||
| Ripgrep.Service
|
||||
| Format.Service
|
||||
| Truncate.Service
|
||||
| RuntimeFlags.Service
|
||||
> = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
@@ -109,6 +107,7 @@ export const layer: Layer.Layer<
|
||||
const agents = yield* Agent.Service
|
||||
const skill = yield* Skill.Service
|
||||
const truncate = yield* Truncate.Service
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
const invalid = yield* InvalidTool
|
||||
const task = yield* TaskTool
|
||||
@@ -160,11 +159,13 @@ export const layer: Layer.Layer<
|
||||
const result = yield* Effect.promise(() => def.execute(args as any, pluginCtx))
|
||||
const output = typeof result === "string" ? result : result.output
|
||||
const metadata = typeof result === "string" ? {} : (result.metadata ?? {})
|
||||
const attachments = typeof result === "string" ? undefined : result.attachments
|
||||
const info = yield* agent.get(toolCtx.agent)
|
||||
const out = yield* truncate.output(output, {}, info)
|
||||
return {
|
||||
title: "",
|
||||
title: typeof result === "string" ? "" : (result.title ?? ""),
|
||||
output: out.truncated ? out.content : output,
|
||||
attachments,
|
||||
metadata: {
|
||||
...metadata,
|
||||
truncated: out.truncated,
|
||||
@@ -207,8 +208,7 @@ export const layer: Layer.Layer<
|
||||
}
|
||||
|
||||
yield* config.get()
|
||||
const questionEnabled =
|
||||
["app", "cli", "desktop"].includes(Flag.OPENCODE_CLIENT) || Flag.OPENCODE_ENABLE_QUESTION_TOOL
|
||||
const questionEnabled = ["app", "cli", "desktop"].includes(flags.client) || flags.enableQuestionTool
|
||||
|
||||
const tool = yield* Effect.all({
|
||||
invalid: Tool.init(invalid),
|
||||
@@ -246,11 +246,11 @@ export const layer: Layer.Layer<
|
||||
tool.fetch,
|
||||
tool.todo,
|
||||
tool.search,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_SCOUT ? [tool.repo_clone, tool.repo_overview] : []),
|
||||
...(flags.experimentalScout ? [tool.repo_clone, tool.repo_overview] : []),
|
||||
tool.skill,
|
||||
tool.patch,
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_LSP_TOOL ? [tool.lsp] : []),
|
||||
...(Flag.OPENCODE_EXPERIMENTAL_PLAN_MODE && Flag.OPENCODE_CLIENT === "cli" ? [tool.plan] : []),
|
||||
...(flags.experimentalLspTool ? [tool.lsp] : []),
|
||||
...(flags.experimentalPlanMode && flags.client === "cli" ? [tool.plan] : []),
|
||||
],
|
||||
task: tool.task,
|
||||
read: tool.read,
|
||||
@@ -304,7 +304,7 @@ export const layer: Layer.Layer<
|
||||
const tools: Interface["tools"] = Effect.fn("ToolRegistry.tools")(function* (input) {
|
||||
const filtered = (yield* all()).filter((tool) => {
|
||||
if (tool.id === WebSearchTool.id) {
|
||||
return webSearchEnabled(input.providerID)
|
||||
return webSearchEnabled(input.providerID, { exa: flags.enableExa, parallel: flags.enableParallel })
|
||||
}
|
||||
|
||||
const usePatch =
|
||||
@@ -378,6 +378,7 @@ export const defaultLayer = Layer.suspend(() =>
|
||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.defaultLayer),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ import { HttpClient } from "effect/unstable/http"
|
||||
import * as Tool from "./tool"
|
||||
import * as McpWebSearch from "./mcp-websearch"
|
||||
import DESCRIPTION from "./websearch.txt"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
export const Parameters = Schema.Struct({
|
||||
query: Schema.String.annotate({ description: "Websearch query" }),
|
||||
@@ -27,10 +27,7 @@ export const Parameters = Schema.Struct({
|
||||
const WebSearchProviderSchema = Schema.Literals(["exa", "parallel"])
|
||||
export type WebSearchProvider = Schema.Schema.Type<typeof WebSearchProviderSchema>
|
||||
|
||||
export function selectWebSearchProvider(
|
||||
sessionID: string,
|
||||
flags = { exa: Flag.OPENCODE_ENABLE_EXA, parallel: Flag.OPENCODE_ENABLE_PARALLEL },
|
||||
): WebSearchProvider {
|
||||
export function selectWebSearchProvider(sessionID: string, flags = { exa: false, parallel: false }): WebSearchProvider {
|
||||
const override = process.env.OPENCODE_WEBSEARCH_PROVIDER
|
||||
if (override === "exa" || override === "parallel") return override
|
||||
if (flags.parallel) return "parallel"
|
||||
@@ -103,6 +100,7 @@ export const WebSearchTool = Tool.define(
|
||||
"websearch",
|
||||
Effect.gen(function* () {
|
||||
const http = yield* HttpClient.HttpClient
|
||||
const flags = yield* RuntimeFlags.Service
|
||||
|
||||
return {
|
||||
get description() {
|
||||
@@ -111,7 +109,10 @@ export const WebSearchTool = Tool.define(
|
||||
parameters: Parameters,
|
||||
execute: (params: Schema.Schema.Type<typeof Parameters>, ctx: Tool.Context) =>
|
||||
Effect.gen(function* () {
|
||||
const provider = selectWebSearchProvider(ctx.sessionID)
|
||||
const provider = selectWebSearchProvider(ctx.sessionID, {
|
||||
exa: flags.enableExa,
|
||||
parallel: flags.enableParallel,
|
||||
})
|
||||
const title = webSearchProviderLabel(provider)
|
||||
yield* ctx.metadata({ title: `${title} "${params.query}"`, metadata: { provider } })
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { chmod, mkdir, readFile, stat as statFile, writeFile } from "fs/promises"
|
||||
import { createWriteStream, existsSync, statSync } from "fs"
|
||||
import { realpathSync } from "fs"
|
||||
import { dirname, join, relative, resolve as pathResolve, win32 } from "path"
|
||||
import { dirname, isAbsolute, join, relative, resolve as pathResolve, win32 } from "path"
|
||||
import { Readable } from "stream"
|
||||
import { pipeline } from "stream/promises"
|
||||
import { Glob } from "@opencode-ai/core/util/glob"
|
||||
import { fileURLToPath } from "url"
|
||||
|
||||
// Fast sync version for metadata checks
|
||||
export async function exists(p: string): Promise<boolean> {
|
||||
@@ -142,6 +143,12 @@ export function resolve(p: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveFilePath(root: string, file: string): string {
|
||||
const raw = file.startsWith("file://") ? fileURLToPath(file) : file
|
||||
if (isAbsolute(raw)) return raw
|
||||
return pathResolve(root, raw)
|
||||
}
|
||||
|
||||
export function windowsPath(p: string): string {
|
||||
if (process.platform !== "win32") return p
|
||||
return (
|
||||
|
||||
@@ -27,6 +27,19 @@ import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Agent.defaultLayer)
|
||||
|
||||
function testAgent(input: {
|
||||
name: string
|
||||
mode: Agent.Info["mode"]
|
||||
permission: Parameters<typeof Permission.fromConfig>[0]
|
||||
}) {
|
||||
return {
|
||||
name: input.name,
|
||||
mode: input.mode,
|
||||
permission: Permission.fromConfig(input.permission),
|
||||
options: {},
|
||||
} satisfies Agent.Info
|
||||
}
|
||||
|
||||
// `deriveSubagentSessionPermission` is imported from production. The test
|
||||
// exercises the actual helper that task.ts uses to build the subagent's
|
||||
// session permission, so any regression in that helper trips this test.
|
||||
@@ -123,3 +136,77 @@ it.instance(
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.effect("[#26700] controller self-restrictions do not erase executor permissions", () =>
|
||||
Effect.sync(() => {
|
||||
const controller = testAgent({
|
||||
name: "controller",
|
||||
mode: "primary",
|
||||
permission: {
|
||||
"*": "deny",
|
||||
read: "deny",
|
||||
bash: "deny",
|
||||
task: {
|
||||
"*": "deny",
|
||||
executor: "allow",
|
||||
},
|
||||
edit: "deny",
|
||||
write: "deny",
|
||||
},
|
||||
})
|
||||
const executor = testAgent({
|
||||
name: "executor",
|
||||
mode: "subagent",
|
||||
permission: {
|
||||
"*": "deny",
|
||||
read: "allow",
|
||||
bash: "allow",
|
||||
task: {
|
||||
"*": "deny",
|
||||
worker: "allow",
|
||||
},
|
||||
edit: "deny",
|
||||
write: "deny",
|
||||
},
|
||||
})
|
||||
|
||||
const effective = Permission.merge(
|
||||
executor.permission,
|
||||
deriveSubagentSessionPermission({
|
||||
parentSessionPermission: [],
|
||||
parentAgent: controller,
|
||||
subagent: executor,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Permission.evaluate("read", "README.md", effective).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "git status", effective).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "worker", effective).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "other", effective).action).toBe("deny")
|
||||
expect(Permission.disabled(["edit", "write", "apply_patch"], effective)).toEqual(
|
||||
new Set(["edit", "write", "apply_patch"]),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("subagent inherits parent session deny rules as hard runtime ceilings", () =>
|
||||
Effect.sync(() => {
|
||||
const executor = testAgent({
|
||||
name: "executor",
|
||||
mode: "subagent",
|
||||
permission: {
|
||||
bash: "allow",
|
||||
},
|
||||
})
|
||||
const effective = Permission.merge(
|
||||
executor.permission,
|
||||
deriveSubagentSessionPermission({
|
||||
parentSessionPermission: Permission.fromConfig({ bash: "deny" }),
|
||||
parentAgent: undefined,
|
||||
subagent: executor,
|
||||
}),
|
||||
)
|
||||
|
||||
expect(Permission.evaluate("bash", "git status", effective).action).toBe("deny")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { Agent } from "../../src/agent/agent"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { Plugin } from "../../src/plugin"
|
||||
import { AccountTest } from "../fake/account"
|
||||
import { AuthTest } from "../fake/auth"
|
||||
@@ -29,7 +30,11 @@ const configLayer = Config.layer.pipe(
|
||||
Layer.provide(AccountTest.empty),
|
||||
Layer.provide(NpmTest.noop),
|
||||
)
|
||||
const pluginLayer = Plugin.layer.pipe(Layer.provide(Bus.layer), Layer.provide(configLayer))
|
||||
const pluginLayer = Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
|
||||
)
|
||||
const agentLayer = Agent.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(AuthTest.empty),
|
||||
|
||||
@@ -0,0 +1,484 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { AudioPlayOptions, AudioSound } from "@opentui/core"
|
||||
import { createTuiAttention } from "@/cli/cmd/tui/attention"
|
||||
import type { TuiConfig } from "@/cli/cmd/tui/config/tui"
|
||||
|
||||
type FocusEvent = "focus" | "blur"
|
||||
|
||||
type AttentionConfig = Pick<TuiConfig.Resolved, "attention">
|
||||
|
||||
class FakeRenderer {
|
||||
isDestroyed = false
|
||||
notificationResult = true
|
||||
notificationThrows = false
|
||||
notifications: { message: string; title: string | undefined }[] = []
|
||||
listeners: Record<FocusEvent, Set<() => void>> = {
|
||||
focus: new Set(),
|
||||
blur: new Set(),
|
||||
}
|
||||
|
||||
on(event: FocusEvent, listener: () => void) {
|
||||
this.listeners[event].add(listener)
|
||||
return this
|
||||
}
|
||||
|
||||
off(event: FocusEvent, listener: () => void) {
|
||||
this.listeners[event].delete(listener)
|
||||
return this
|
||||
}
|
||||
|
||||
emit(event: FocusEvent) {
|
||||
for (const listener of this.listeners[event]) listener()
|
||||
}
|
||||
|
||||
listenerCount(event: FocusEvent) {
|
||||
return this.listeners[event].size
|
||||
}
|
||||
|
||||
triggerNotification(message: string, title?: string) {
|
||||
if (this.notificationThrows) throw new Error("notification failed")
|
||||
this.notifications.push({ message, title })
|
||||
return this.notificationResult
|
||||
}
|
||||
}
|
||||
|
||||
class FakeAudioEngine {
|
||||
loadResult: AudioSound | null = 1
|
||||
playResult: number | null = 1
|
||||
loadCalls = 0
|
||||
playCalls = 0
|
||||
volumes: (number | undefined)[] = []
|
||||
loadPaths: string[] = []
|
||||
rejectLoad = false
|
||||
rejectPaths = new Set<string>()
|
||||
|
||||
async loadSoundFile(path: string) {
|
||||
this.loadCalls += 1
|
||||
this.loadPaths.push(path)
|
||||
if (this.rejectLoad || this.rejectPaths.has(path)) throw new Error("decode failed")
|
||||
return this.loadResult
|
||||
}
|
||||
|
||||
play(_sound: AudioSound, options?: AudioPlayOptions) {
|
||||
this.playCalls += 1
|
||||
this.volumes.push(options?.volume)
|
||||
return this.playResult
|
||||
}
|
||||
}
|
||||
|
||||
class FakeKV {
|
||||
store: Record<string, unknown> = {}
|
||||
|
||||
get ready() {
|
||||
return true
|
||||
}
|
||||
|
||||
get<Value = unknown>(key: string, fallback?: Value) {
|
||||
return (this.store[key] ?? fallback) as Value
|
||||
}
|
||||
|
||||
set(key: string, value: unknown) {
|
||||
this.store[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
function config(attention: Partial<AttentionConfig["attention"]> = {}): AttentionConfig {
|
||||
return {
|
||||
attention: {
|
||||
enabled: true,
|
||||
notifications: true,
|
||||
sound: true,
|
||||
volume: 0.4,
|
||||
sound_pack: "opencode.default",
|
||||
sounds: {},
|
||||
...attention,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe("createTuiAttention", () => {
|
||||
test("defaults to sound always and notification blurred", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
expect(await attention.notify({ message: "hello" })).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("supports blurred-only requests", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
expect(await attention.notify({ message: "unknown", sound: { when: "blurred" } })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focus_unknown",
|
||||
})
|
||||
renderer.emit("focus")
|
||||
expect(await attention.notify({ message: "focused", sound: { when: "blurred" } })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focused",
|
||||
})
|
||||
renderer.emit("blur")
|
||||
expect(await attention.notify({ message: "blurred", sound: { when: "blurred" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.playCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("supports focused-only requests", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
|
||||
|
||||
expect(await attention.notify({ message: "unknown", notification: { when: "focused" }, sound: false })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focus_unknown",
|
||||
})
|
||||
renderer.emit("blur")
|
||||
expect(await attention.notify({ message: "blurred", notification: { when: "focused" }, sound: false })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "blurred",
|
||||
})
|
||||
renderer.emit("focus")
|
||||
expect(await attention.notify({ message: "focused", notification: { when: "focused" }, sound: false })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "focused" }])
|
||||
})
|
||||
|
||||
test("notification can deliver while focused when requested", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("focus")
|
||||
|
||||
expect(await attention.notify({ message: "hello", notification: { when: "always" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.playCalls).toBe(1)
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
|
||||
})
|
||||
|
||||
test("notifies while blurred", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
|
||||
renderer.emit("blur")
|
||||
|
||||
expect(await attention.notify({ title: "opencode", message: "hello", sound: false })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello" }])
|
||||
})
|
||||
|
||||
test("when requested, blurred-only calls do not notify or play sound while focused", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("focus")
|
||||
|
||||
expect(await attention.notify({ message: "hello", sound: { when: "blurred" } })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "focused",
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.loadCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("can play sound always while notification is blurred-only", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("focus")
|
||||
|
||||
expect(
|
||||
await attention.notify({
|
||||
message: "hello",
|
||||
sound: { name: "question" },
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
|
||||
renderer.emit("blur")
|
||||
expect(
|
||||
await attention.notify({
|
||||
message: "hello again",
|
||||
sound: { name: "question" },
|
||||
}),
|
||||
).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toEqual([{ title: "opencode", message: "hello again" }])
|
||||
})
|
||||
|
||||
test("can disable notification per call while still playing sound", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
expect(await attention.notify({ message: "hello", notification: false })).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
})
|
||||
|
||||
test("skips empty messages and disabled attention", async () => {
|
||||
const empty = new FakeRenderer()
|
||||
empty.emit("blur")
|
||||
const disabled = new FakeRenderer()
|
||||
disabled.emit("blur")
|
||||
|
||||
expect(await createTuiAttention({ renderer: empty, config: config() }).notify({ message: " \n " })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "empty_message",
|
||||
})
|
||||
expect(
|
||||
await createTuiAttention({ renderer: disabled, config: config({ enabled: false }) }).notify({ message: "hello" }),
|
||||
).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "attention_disabled",
|
||||
})
|
||||
})
|
||||
|
||||
test("respects notification and sound config independently", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config({ notifications: false }), audio })
|
||||
renderer.emit("blur")
|
||||
|
||||
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: true,
|
||||
notification: false,
|
||||
sound: true,
|
||||
})
|
||||
expect(renderer.notifications).toHaveLength(0)
|
||||
expect(audio.playCalls).toBe(1)
|
||||
|
||||
const soundDisabledRenderer = new FakeRenderer()
|
||||
const soundDisabledAudio = new FakeAudioEngine()
|
||||
const soundDisabled = createTuiAttention({
|
||||
renderer: soundDisabledRenderer,
|
||||
config: config({ sound: false }),
|
||||
audio: soundDisabledAudio,
|
||||
})
|
||||
soundDisabledRenderer.emit("blur")
|
||||
|
||||
expect(await soundDisabled.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(soundDisabledAudio.loadCalls).toBe(0)
|
||||
})
|
||||
|
||||
test("loads audio lazily only for eligible sound requests", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
|
||||
await attention.notify({ message: "unknown", sound: { when: "blurred" } })
|
||||
expect(audio.loadCalls).toBe(0)
|
||||
|
||||
renderer.emit("blur")
|
||||
expect(await attention.notify({ message: "blurred", sound: { volume: 2 } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.loadCalls).toBe(1)
|
||||
expect(audio.volumes).toEqual([1])
|
||||
})
|
||||
|
||||
test("handles unavailable playback and delegates sound loading", async () => {
|
||||
const unavailableRenderer = new FakeRenderer()
|
||||
const unavailableAudio = new FakeAudioEngine()
|
||||
unavailableAudio.playResult = null
|
||||
const unavailable = createTuiAttention({ renderer: unavailableRenderer, config: config(), audio: unavailableAudio })
|
||||
unavailableRenderer.emit("blur")
|
||||
|
||||
expect(await unavailable.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: false,
|
||||
})
|
||||
expect(unavailableAudio.loadCalls).toBe(1)
|
||||
expect(unavailableAudio.playCalls).toBe(1)
|
||||
|
||||
const repeatedRenderer = new FakeRenderer()
|
||||
const repeatedAudio = new FakeAudioEngine()
|
||||
const repeated = createTuiAttention({ renderer: repeatedRenderer, config: config(), audio: repeatedAudio })
|
||||
repeatedRenderer.emit("blur")
|
||||
|
||||
await repeated.notify({ message: "one", sound: true })
|
||||
await repeated.notify({ message: "two", sound: true })
|
||||
expect(repeatedAudio.loadCalls).toBe(2)
|
||||
expect(repeatedAudio.playCalls).toBe(2)
|
||||
})
|
||||
|
||||
test("plays named sounds from the active sound pack", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("blur")
|
||||
|
||||
const dispose = attention.soundboard.registerPack({
|
||||
id: "acme.soft",
|
||||
name: "Soft Alerts",
|
||||
sounds: {
|
||||
question: "/tmp/question.mp3",
|
||||
},
|
||||
})
|
||||
|
||||
expect(attention.soundboard.activate("acme.soft")).toBe(true)
|
||||
expect(attention.soundboard.current()).toBe("acme.soft")
|
||||
expect(attention.soundboard.list()).toContainEqual({
|
||||
id: "acme.soft",
|
||||
name: "Soft Alerts",
|
||||
active: true,
|
||||
builtin: false,
|
||||
})
|
||||
|
||||
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.loadPaths).toEqual(["/tmp/question.mp3"])
|
||||
|
||||
dispose()
|
||||
expect(attention.soundboard.current()).toBe("opencode.default")
|
||||
})
|
||||
|
||||
test("uses config sound overrides before active pack sounds and falls back on load failure", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
audio.rejectPaths.add("/tmp/bad-question.mp3")
|
||||
const attention = createTuiAttention({
|
||||
renderer,
|
||||
config: config({ sounds: { question: "/tmp/bad-question.mp3" } }),
|
||||
audio,
|
||||
})
|
||||
renderer.emit("blur")
|
||||
|
||||
attention.soundboard.registerPack({
|
||||
id: "acme.soft",
|
||||
sounds: {
|
||||
question: "/tmp/good-question.mp3",
|
||||
},
|
||||
})
|
||||
attention.soundboard.activate("acme.soft")
|
||||
|
||||
expect(await attention.notify({ message: "question", sound: { name: "question" } })).toEqual({
|
||||
ok: true,
|
||||
notification: true,
|
||||
sound: true,
|
||||
})
|
||||
expect(audio.loadPaths).toEqual(["/tmp/bad-question.mp3", "/tmp/good-question.mp3"])
|
||||
})
|
||||
|
||||
test("persists activated sound pack in KV", () => {
|
||||
const kv = new FakeKV()
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), kv })
|
||||
|
||||
attention.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
|
||||
|
||||
expect(attention.soundboard.activate("missing", { persist: true })).toBe(false)
|
||||
expect(kv.store.attention_sound_pack).toBeUndefined()
|
||||
expect(attention.soundboard.activate("acme.soft", { persist: true })).toBe(true)
|
||||
expect(kv.store.attention_sound_pack).toBe("acme.soft")
|
||||
|
||||
const next = createTuiAttention({ renderer: new FakeRenderer(), config: config(), kv })
|
||||
next.soundboard.registerPack({ id: "acme.soft", sounds: { done: "/tmp/done.mp3" } })
|
||||
expect(next.soundboard.current()).toBe("acme.soft")
|
||||
})
|
||||
|
||||
test("does not throw for notification or sound failures", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
renderer.notificationThrows = true
|
||||
audio.rejectLoad = true
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("blur")
|
||||
|
||||
expect(await attention.notify({ message: "hello", sound: true })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
})
|
||||
})
|
||||
|
||||
test("strips unsafe notification text", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio: new FakeAudioEngine() })
|
||||
renderer.emit("blur")
|
||||
|
||||
await attention.notify({
|
||||
title: "\u001b[31m danger\n title\u0007",
|
||||
message: "\u001b[32m hello\n world\u0000",
|
||||
})
|
||||
|
||||
expect(renderer.notifications).toEqual([{ title: "danger title", message: "hello world" }])
|
||||
})
|
||||
|
||||
test("disposes renderer listeners", async () => {
|
||||
const renderer = new FakeRenderer()
|
||||
const audio = new FakeAudioEngine()
|
||||
const attention = createTuiAttention({ renderer, config: config(), audio })
|
||||
renderer.emit("blur")
|
||||
await attention.notify({ message: "hello", sound: true })
|
||||
|
||||
expect(renderer.listenerCount("focus")).toBe(1)
|
||||
expect(renderer.listenerCount("blur")).toBe(1)
|
||||
|
||||
attention.dispose()
|
||||
renderer.isDestroyed = true
|
||||
|
||||
expect(renderer.listenerCount("focus")).toBe(0)
|
||||
expect(renderer.listenerCount("blur")).toBe(0)
|
||||
expect(audio.loadCalls).toBe(1)
|
||||
expect(await attention.notify({ message: "hello" })).toEqual({
|
||||
ok: false,
|
||||
notification: false,
|
||||
sound: false,
|
||||
skipped: "renderer_destroyed",
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,267 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import Notifications from "@/cli/cmd/tui/feature-plugins/system/notifications"
|
||||
import type { Event, PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiAttentionNotifyInput } from "@opencode-ai/plugin/tui"
|
||||
import { createTuiPluginApi } from "../../../fixture/tui-plugin"
|
||||
|
||||
async function setup() {
|
||||
const notifications: TuiAttentionNotifyInput[] = []
|
||||
const handlers = new Map<Event["type"], ((event: Event) => void)[]>()
|
||||
const session = (id: string, title: string, parentID?: string): Session => ({
|
||||
id,
|
||||
title,
|
||||
slug: id,
|
||||
projectID: "project",
|
||||
directory: "/workspace",
|
||||
...(parentID && { parentID }),
|
||||
version: "0.0.0-test",
|
||||
time: { created: 0, updated: 0 },
|
||||
})
|
||||
const sessions: Record<string, Session> = {
|
||||
session: session("session", "Demo session"),
|
||||
subagent: session("subagent", "Subagent session", "session"),
|
||||
abort: session("abort", "Abort session"),
|
||||
timeout: session("timeout", "Timeout session"),
|
||||
}
|
||||
|
||||
await Notifications.tui(
|
||||
createTuiPluginApi({
|
||||
attention: {
|
||||
async notify(input) {
|
||||
notifications.push(input)
|
||||
return { ok: true, notification: true, sound: true }
|
||||
},
|
||||
},
|
||||
event: {
|
||||
on: <Type extends Event["type"]>(type: Type, handler: (event: Extract<Event, { type: Type }>) => void) => {
|
||||
const list = handlers.get(type) ?? []
|
||||
const wrapped = handler as (event: Event) => void
|
||||
list.push(wrapped)
|
||||
handlers.set(type, list)
|
||||
return () => {
|
||||
handlers.set(
|
||||
type,
|
||||
(handlers.get(type) ?? []).filter((item) => item !== wrapped),
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
state: {
|
||||
session: {
|
||||
get: (sessionID: string) => sessions[sessionID],
|
||||
},
|
||||
},
|
||||
}),
|
||||
undefined,
|
||||
{} as never,
|
||||
)
|
||||
|
||||
return {
|
||||
notifications,
|
||||
emit(event: Event) {
|
||||
for (const handler of handlers.get(event.type) ?? []) handler(event)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function question(id: string, sessionID = "session"): QuestionRequest {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
questions: [],
|
||||
}
|
||||
}
|
||||
|
||||
function permission(id: string, sessionID = "session"): PermissionRequest {
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
permission: "edit",
|
||||
patterns: [],
|
||||
metadata: {},
|
||||
always: [],
|
||||
}
|
||||
}
|
||||
|
||||
const questionNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
message: "Question needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "question", when: "always" },
|
||||
}
|
||||
|
||||
const permissionNotification: TuiAttentionNotifyInput = {
|
||||
title: "Demo session",
|
||||
message: "Permission needs input",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "permission", when: "always" },
|
||||
}
|
||||
|
||||
describe("internal notifications TUI plugin", () => {
|
||||
test("notifies for question and permission requests with blurred notifications and always-on sounds", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "permission.asked", properties: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([questionNotification, permissionNotification])
|
||||
})
|
||||
|
||||
test("dedupes pending questions and permissions until they are resolved", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({ id: "event-2", type: "question.asked", properties: question("question-1") })
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "question.replied",
|
||||
properties: { sessionID: "session", requestID: "question-1", answers: [] },
|
||||
})
|
||||
harness.emit({ id: "event-4", type: "question.asked", properties: question("question-1") })
|
||||
|
||||
harness.emit({ id: "event-5", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({ id: "event-6", type: "permission.asked", properties: permission("permission-1") })
|
||||
harness.emit({
|
||||
id: "event-7",
|
||||
type: "permission.replied",
|
||||
properties: { sessionID: "session", requestID: "permission-1", reply: "once" },
|
||||
})
|
||||
harness.emit({ id: "event-8", type: "permission.asked", properties: permission("permission-1") })
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
questionNotification,
|
||||
questionNotification,
|
||||
permissionNotification,
|
||||
permissionNotification,
|
||||
])
|
||||
})
|
||||
|
||||
test("notifies when an active session becomes idle and suppresses no-op idle", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session done",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("uses sound-only notifications and subagent_done sound for subagent sessions", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({ id: "event-1", type: "question.asked", properties: question("question-1", "subagent") })
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "subagent", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Subagent session",
|
||||
message: "Question needs input",
|
||||
notification: false,
|
||||
sound: { name: "question", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Subagent session",
|
||||
message: "Session done",
|
||||
notification: false,
|
||||
sound: { name: "subagent_done", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("notifies session errors once and suppresses the following idle done notification", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "session", error: { name: "UnknownError", data: { message: "boom" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "session", status: { type: "idle" } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Demo session",
|
||||
message: "Session error",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test("special-cases aborts and model response timeouts", async () => {
|
||||
const harness = await setup()
|
||||
|
||||
harness.emit({
|
||||
id: "event-1",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "abort", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-2",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "abort", error: { name: "MessageAbortedError", data: { message: "Aborted" } } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-3",
|
||||
type: "session.status",
|
||||
properties: { sessionID: "timeout", status: { type: "busy" } },
|
||||
})
|
||||
harness.emit({
|
||||
id: "event-4",
|
||||
type: "session.error",
|
||||
properties: { sessionID: "timeout", error: { name: "UnknownError", data: { message: "SSE read timed out" } } },
|
||||
})
|
||||
|
||||
expect(harness.notifications).toEqual([
|
||||
{
|
||||
title: "Abort session",
|
||||
message: "Session aborted",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
{
|
||||
title: "Timeout session",
|
||||
message: "Model stopped responding",
|
||||
notification: { when: "blurred" },
|
||||
sound: { name: "error", when: "always" },
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,13 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import { fileURLToPath } from "url"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { disposeAllInstances, provideTestInstance, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(AppFileSystem.defaultLayer)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -14,35 +19,40 @@ afterEach(async () => {
|
||||
// has lost the outer InstanceRef. Services that read `InstanceState.context`
|
||||
// then fall back to `Instance.current` ALS, which must be installed at the JS
|
||||
// callback boundary (Node ALS persists across awaits, Effect's fiber context
|
||||
// does not). `provideTestInstance` mirrors effectCmd's load + ALS-restore wrap.
|
||||
// does not). `it.instance` provides the loaded InstanceRef; the explicit
|
||||
// Instance.restore mirrors effectCmd's load + ALS-restore wrap.
|
||||
// Pins effect-cmd.ts directly: the pattern test below exercises the load +
|
||||
// Instance.restore + dispose triple via the shared `provideTestInstance` fixture,
|
||||
// Instance.restore boundary via the shared `it.instance` fixture,
|
||||
// so a regression that removed `Instance.restore` from effect-cmd.ts wouldn't
|
||||
// fail it. This grep guards the actual production callsite.
|
||||
test("effect-cmd.ts wraps the handler body in Instance.restore", async () => {
|
||||
const source = await fs.readFile(new URL("../../src/cli/effect-cmd.ts", import.meta.url), "utf8")
|
||||
expect(source).toContain("Instance.restore(ctx")
|
||||
})
|
||||
it.live("effect-cmd.ts wraps the handler body in Instance.restore", () =>
|
||||
Effect.gen(function* () {
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const source = yield* fs.readFileString(fileURLToPath(new URL("../../src/cli/effect-cmd.ts", import.meta.url)))
|
||||
expect(source).toContain("Instance.restore(ctx")
|
||||
}),
|
||||
)
|
||||
|
||||
test("Instance.current reachable from inner runPromise inside Effect.promise(async)", async () => {
|
||||
await using dir = await tmpdir({ git: true })
|
||||
await provideTestInstance({
|
||||
directory: dir.path,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.promise(async () => {
|
||||
await new Promise((r) => setTimeout(r, 5))
|
||||
const current = await Effect.runPromise(
|
||||
Effect.sync(() => {
|
||||
try {
|
||||
return Instance.current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
)
|
||||
expect(current?.directory).toBe(dir.path)
|
||||
it.instance(
|
||||
"Instance.current reachable after await inside restored Effect.promise(async)",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) throw new Error("InstanceRef not provided")
|
||||
|
||||
const current = yield* Effect.promise(() =>
|
||||
Instance.restore(ctx, async () => {
|
||||
await Promise.resolve()
|
||||
try {
|
||||
return Instance.current
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}),
|
||||
),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
expect(current?.directory).toBe(test.directory)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import type { KeyEvent, Renderable } from "@opentui/core"
|
||||
import type { Binding } from "@opentui/keymap"
|
||||
import { createBindingLookup } from "@opentui/keymap/extras"
|
||||
import { OpencodeClient, type Provider } from "@opencode-ai/sdk/v2"
|
||||
import { TuiConfig, type Resolved } from "@/cli/cmd/tui/config/tui"
|
||||
import { formatBindings } from "@/cli/cmd/run/keymap.shared"
|
||||
import { TuiKeybind } from "@/cli/cmd/tui/config/keybind"
|
||||
import { resolveDiffStyle, resolveFooterKeybinds, resolveModelInfo } from "@/cli/cmd/run/runtime.boot"
|
||||
|
||||
type RunBinding = Binding<Renderable, KeyEvent>
|
||||
import { createTuiResolvedConfig } from "../../fixture/tui-runtime"
|
||||
|
||||
function model(id: string, providerID: string, context: number, variants?: Record<string, Record<string, never>>) {
|
||||
return {
|
||||
@@ -61,45 +56,37 @@ function model(id: string, providerID: string, context: number, variants?: Recor
|
||||
}
|
||||
}
|
||||
|
||||
function bindings(...keys: string[]) {
|
||||
return keys.map((key) => ({ key }))
|
||||
}
|
||||
|
||||
function config(input?: {
|
||||
leader?: string
|
||||
leaderTimeout?: number
|
||||
diff_style?: "auto" | "stacked"
|
||||
bindings?: Partial<{
|
||||
commandList: RunBinding[]
|
||||
variantCycle: RunBinding[]
|
||||
interrupt: RunBinding[]
|
||||
historyPrevious: RunBinding[]
|
||||
historyNext: RunBinding[]
|
||||
inputClear: RunBinding[]
|
||||
inputSubmit: RunBinding[]
|
||||
inputNewline: RunBinding[]
|
||||
commandList: string[]
|
||||
variantCycle: string[]
|
||||
interrupt: string[]
|
||||
historyPrevious: string[]
|
||||
historyNext: string[]
|
||||
inputClear: string[]
|
||||
inputSubmit: string[]
|
||||
inputNewline: string[]
|
||||
}>
|
||||
}): Resolved {
|
||||
const bind = input?.bindings
|
||||
const keybinds = TuiKeybind.Keybinds.parse({
|
||||
...(input?.leader && { leader: input.leader }),
|
||||
...(bind?.commandList && { command_list: bind.commandList }),
|
||||
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
|
||||
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
|
||||
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
|
||||
...(bind?.historyNext && { history_next: bind.historyNext }),
|
||||
...(bind?.inputClear && { input_clear: bind.inputClear }),
|
||||
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
|
||||
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
|
||||
})
|
||||
return {
|
||||
return createTuiResolvedConfig({
|
||||
diff_style: input?.diff_style,
|
||||
keybinds: createBindingLookup(TuiKeybind.toBindingConfig(keybinds), {
|
||||
commandMap: TuiKeybind.CommandMap,
|
||||
bindingDefaults: TuiKeybind.bindingDefaults(),
|
||||
}),
|
||||
leader_timeout: input?.leaderTimeout ?? 2000,
|
||||
}
|
||||
leader_timeout: input?.leaderTimeout,
|
||||
keybinds: {
|
||||
...(input?.leader && { leader: input.leader }),
|
||||
...(bind?.commandList && { command_list: bind.commandList }),
|
||||
...(bind?.variantCycle && { variant_cycle: bind.variantCycle }),
|
||||
...(bind?.interrupt && { session_interrupt: bind.interrupt }),
|
||||
...(bind?.historyPrevious && { history_previous: bind.historyPrevious }),
|
||||
...(bind?.historyNext && { history_next: bind.historyNext }),
|
||||
...(bind?.inputClear && { input_clear: bind.inputClear }),
|
||||
...(bind?.inputSubmit && { input_submit: bind.inputSubmit }),
|
||||
...(bind?.inputNewline && { input_newline: bind.inputNewline }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe("run runtime boot", () => {
|
||||
@@ -112,14 +99,14 @@ describe("run runtime boot", () => {
|
||||
config({
|
||||
leader: "ctrl+g",
|
||||
bindings: {
|
||||
commandList: bindings("ctrl+p"),
|
||||
variantCycle: bindings("ctrl+t", "alt+t"),
|
||||
interrupt: bindings("ctrl+c"),
|
||||
historyPrevious: bindings("k"),
|
||||
historyNext: bindings("j"),
|
||||
inputClear: bindings("ctrl+l"),
|
||||
inputSubmit: bindings("ctrl+s"),
|
||||
inputNewline: bindings("alt+return"),
|
||||
commandList: ["ctrl+p"],
|
||||
variantCycle: ["ctrl+t", "alt+t"],
|
||||
interrupt: ["ctrl+c"],
|
||||
historyPrevious: ["k"],
|
||||
historyNext: ["j"],
|
||||
inputClear: ["ctrl+l"],
|
||||
inputSubmit: ["ctrl+s"],
|
||||
inputNewline: ["alt+return"],
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@ import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { createTestKeymap } from "@opentui/keymap/testing"
|
||||
import type { TuiAttentionSoundPack } from "@opencode-ai/plugin/tui"
|
||||
import { tmpdir } from "../../fixture/fixture"
|
||||
import { createTuiPluginApi } from "../../fixture/tui-plugin"
|
||||
import { createTuiResolvedConfig, mockTuiRuntime } from "../../fixture/tui-runtime"
|
||||
@@ -854,6 +855,85 @@ test("plugin keymap proxy preserves real keymap receiver", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("auto-disposes plugin attention sound packs and resolves sound paths", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
const file = path.join(dir, "attention-soundpack-plugin.ts")
|
||||
const spec = pathToFileURL(file).href
|
||||
const absolute = path.join(dir, "sounds", "default.mp3")
|
||||
const url = pathToFileURL(path.join(dir, "sounds", "error.mp3")).href
|
||||
|
||||
await Bun.write(
|
||||
file,
|
||||
`export default {
|
||||
id: "demo.attention.soundpack",
|
||||
tui: async (api) => {
|
||||
api.attention.soundboard.registerPack({
|
||||
id: "demo.pack",
|
||||
sounds: {
|
||||
default: ${JSON.stringify(absolute)},
|
||||
question: "sounds/question.mp3",
|
||||
done: " sounds/done.mp3 ",
|
||||
subagent_done: "sounds/subagent-done.mp3",
|
||||
error: ${JSON.stringify(url)},
|
||||
nope: "sounds/nope.mp3",
|
||||
permission: "",
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
`,
|
||||
)
|
||||
|
||||
return { spec }
|
||||
},
|
||||
})
|
||||
|
||||
const packs: TuiAttentionSoundPack[] = []
|
||||
let dropped = 0
|
||||
const attention = {
|
||||
soundboard: {
|
||||
registerPack(pack: TuiAttentionSoundPack) {
|
||||
packs.push(pack)
|
||||
return () => {
|
||||
dropped += 1
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
const wait = spyOn(TuiConfig, "waitForDependencies").mockResolvedValue()
|
||||
const cwd = spyOn(process, "cwd").mockImplementation(() => tmp.path)
|
||||
|
||||
try {
|
||||
await TuiPluginRuntime.init({
|
||||
api: createTuiPluginApi({ attention }),
|
||||
config: createTuiResolvedConfig({
|
||||
plugin: [tmp.extra.spec],
|
||||
plugin_origins: [{ spec: tmp.extra.spec, scope: "local", source: path.join(tmp.path, "tui.json") }],
|
||||
}),
|
||||
})
|
||||
|
||||
expect(packs).toEqual([
|
||||
{
|
||||
id: "demo.pack",
|
||||
sounds: {
|
||||
default: path.join(tmp.path, "sounds", "default.mp3"),
|
||||
question: path.join(tmp.path, "sounds", "question.mp3"),
|
||||
done: path.join(tmp.path, "sounds", "done.mp3"),
|
||||
subagent_done: path.join(tmp.path, "sounds", "subagent-done.mp3"),
|
||||
error: path.join(tmp.path, "sounds", "error.mp3"),
|
||||
},
|
||||
},
|
||||
])
|
||||
expect(dropped).toBe(0)
|
||||
} finally {
|
||||
await TuiPluginRuntime.dispose()
|
||||
expect(dropped).toBe(1)
|
||||
cwd.mockRestore()
|
||||
wait.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
test("auto-disposes plugin keymap transformers", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
|
||||
@@ -1,58 +1,50 @@
|
||||
import { test, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import path from "path"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { Config } from "@/config/config"
|
||||
import { Agent as AgentSvc } from "../../src/agent/agent"
|
||||
import { Color } from "@/util/color"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(Config.defaultLayer, AgentSvc.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const writeConfig = (dir: string, agent: Config.Info["agent"]) =>
|
||||
Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
agent,
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("agent color parsed from project config", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* writeConfig(dir, {
|
||||
build: { color: "#FFA500" },
|
||||
plan: { color: "primary" },
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const cfg = yield* Effect.promise(() => AppRuntime.runPromise(Config.Service.use((svc) => svc.get())))
|
||||
it.instance(
|
||||
"agent color parsed from project config",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const cfg = yield* Config.Service.use((svc) => svc.get())
|
||||
expect(cfg.agent?.["build"]?.color).toBe("#FFA500")
|
||||
expect(cfg.agent?.["plan"]?.color).toBe("primary")
|
||||
}).pipe(provideInstance(dir))
|
||||
}),
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
build: { color: "#FFA500" },
|
||||
plan: { color: "primary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
it.live("Agent.get includes color from config", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped()
|
||||
yield* writeConfig(dir, {
|
||||
plan: { color: "#A855F7" },
|
||||
build: { color: "accent" },
|
||||
})
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
it.instance(
|
||||
"Agent.get includes color from config",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const plan = yield* AgentSvc.Service.use((svc) => svc.get("plan"))
|
||||
expect(plan?.color).toBe("#A855F7")
|
||||
const build = yield* AgentSvc.Service.use((svc) => svc.get("build"))
|
||||
expect(build?.color).toBe("accent")
|
||||
}).pipe(provideInstance(dir))
|
||||
}),
|
||||
}),
|
||||
{
|
||||
git: true,
|
||||
config: {
|
||||
agent: {
|
||||
plan: { color: "#A855F7" },
|
||||
build: { color: "accent" },
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
test("Color.hexToAnsiBold converts valid hex to ANSI", () => {
|
||||
|
||||
@@ -3,7 +3,9 @@ import { Effect, Layer, Option } from "effect"
|
||||
import { NodeFileSystem, NodePath } from "@effect/platform-node"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigManaged } from "@/config/managed"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { ConfigParse } from "../../src/config/parse"
|
||||
import { Permission } from "../../src/permission"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
|
||||
import { Instance } from "../../src/project/instance"
|
||||
@@ -276,6 +278,40 @@ test("updates global config and omits empty shell key in json", async () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("global config update preserves single-object permission shape on disk", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
shell: "bash",
|
||||
permission: { bash: "ask" },
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
const prev = Global.Path.config
|
||||
;(Global.Path as { config: string }).config = tmp.path
|
||||
await clear(true)
|
||||
|
||||
try {
|
||||
// Updating an unrelated key must not rewrite `permission` from object to array form.
|
||||
await saveGlobal({ shell: "zsh" })
|
||||
|
||||
const written = await Filesystem.readJson<{ permission?: unknown; shell?: string }>(
|
||||
path.join(tmp.path, "opencode.json"),
|
||||
)
|
||||
expect(written.shell).toBe("zsh")
|
||||
expect(Array.isArray(written.permission)).toBe(false)
|
||||
expect(written.permission).toEqual({ bash: "ask" })
|
||||
} finally {
|
||||
;(Global.Path as { config: string }).config = prev
|
||||
await clear(true)
|
||||
}
|
||||
})
|
||||
|
||||
test("updates global config and omits empty shell key in jsonc", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
@@ -1713,7 +1749,10 @@ test("permission config preserves user key order", async () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
expect(Object.keys(config.permission!)).toEqual([
|
||||
// load() goes through the merge pipeline, producing the layered array form
|
||||
expect(config.permission).toHaveLength(1)
|
||||
const perm = (config.permission as ConfigPermission.Info[])[0]
|
||||
expect(Object.keys(perm)).toEqual([
|
||||
"*",
|
||||
"edit",
|
||||
"write",
|
||||
@@ -1729,6 +1768,129 @@ test("permission config preserves user key order", async () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Global bash "rm *" deny is inherited, but user's top-level "*" ask comes after and overrides it
|
||||
test("user top-level catchall overrides inherited bash rules", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
"*": "ask",
|
||||
bash: { "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// No top-level catchall, so global bash "rm *" deny is preserved
|
||||
test("inherited bash rules apply when no user top-level catchall", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("deny")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
// User's bash "*" catchall overrides global "rm *" deny
|
||||
test("user bash catchall overrides inherited bash rules", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Filesystem.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "rm *": "deny" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const opencodeDir = path.join(dir, ".opencode")
|
||||
await fs.mkdir(opencodeDir, { recursive: true })
|
||||
await Filesystem.write(
|
||||
path.join(opencodeDir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
permission: {
|
||||
bash: { "*": "ask", "ls *": "allow" },
|
||||
},
|
||||
}),
|
||||
)
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const layers = ConfigPermission.toLayers(config.permission)
|
||||
const ruleset = Permission.merge(...layers.map((p) => Permission.fromConfig(p)))
|
||||
|
||||
expect(Permission.evaluate("bash", "rm -rf /", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("bash", "ls -la", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("bash", "echo hello", ruleset).action).toBe("ask")
|
||||
|
||||
// Non-bash permissions should use the top-level "*" rule
|
||||
expect(Permission.evaluate("read", "foo.txt", ruleset).action).toBe("ask")
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("config parser preserves permission order while rejecting unknown top-level keys", () => {
|
||||
const config = ConfigParse.schema(
|
||||
Config.Info,
|
||||
@@ -1742,7 +1904,8 @@ test("config parser preserves permission order while rejecting unknown top-level
|
||||
"test",
|
||||
)
|
||||
|
||||
expect(Object.keys(config.permission!)).toEqual(["bash", "*", "edit"])
|
||||
// ConfigParse.schema preserves the raw shape the user wrote
|
||||
expect(Object.keys(config.permission as ConfigPermission.Info)).toEqual(["bash", "*", "edit"])
|
||||
try {
|
||||
ConfigParse.schema(Config.Info, { invalid_field: true }, "test")
|
||||
throw new Error("expected config parse to fail")
|
||||
@@ -2579,11 +2742,12 @@ test("parseManagedPlist parses permission rules", async () => {
|
||||
),
|
||||
"test:mobileconfig",
|
||||
)
|
||||
expect(config.permission?.["*"]).toBe("ask")
|
||||
expect(config.permission?.grep).toBe("allow")
|
||||
expect(config.permission?.webfetch).toBe("ask")
|
||||
expect(config.permission?.["~/.ssh/*"]).toBe("deny")
|
||||
const bash = config.permission?.bash as Record<string, string>
|
||||
const perm = config.permission as ConfigPermission.Info
|
||||
expect(perm?.["*"]).toBe("ask")
|
||||
expect(perm?.grep).toBe("allow")
|
||||
expect(perm?.webfetch).toBe("ask")
|
||||
expect(perm?.["~/.ssh/*"]).toBe("deny")
|
||||
const bash = perm?.bash as Record<string, string>
|
||||
expect(bash?.["rm -rf *"]).toBe("deny")
|
||||
expect(bash?.["curl *"]).toBe("deny")
|
||||
})
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,13 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Context, Effect, Layer, Logger } from "effect"
|
||||
import { Context, Deferred, Effect, Fiber, Layer, Logger } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { AppLayer } from "../../src/effect/app-runtime"
|
||||
import { EffectBridge } from "@/effect/bridge"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import * as EffectLogger from "@opencode-ai/core/effect/logger"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import * as Observability from "@opencode-ai/core/effect/observability"
|
||||
import { attach } from "../../src/effect/run-service"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(CrossSpawnSpawner.defaultLayer)
|
||||
@@ -35,17 +36,8 @@ it.live("makeRuntime installs EffectLogger through Observability.layer", () =>
|
||||
}),
|
||||
)
|
||||
|
||||
const current = yield* Effect.promise(() => makeRuntime(Dummy, layer).runPromise((svc) => svc.current()))
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("AppRuntime also installs EffectLogger through Observability.layer", () =>
|
||||
Effect.gen(function* () {
|
||||
const current = yield* Effect.promise(() =>
|
||||
AppRuntime.runPromise(Effect.map(Effect.service(Logger.CurrentLoggers), check)),
|
||||
const current = yield* Dummy.use((svc) => svc.current()).pipe(
|
||||
Effect.provide(Layer.provideMerge(layer, Observability.layer)),
|
||||
)
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
@@ -53,46 +45,61 @@ it.live("AppRuntime also installs EffectLogger through Observability.layer", ()
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("AppRuntime attaches InstanceRef from ALS", () =>
|
||||
it.live("AppLayer also installs EffectLogger through Observability.layer", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const current = yield* Effect.promise(() =>
|
||||
AppRuntime.runPromise(
|
||||
const current = yield* Effect.map(Effect.service(Logger.CurrentLoggers), check).pipe(Effect.provide(AppLayer))
|
||||
|
||||
expect(current.effectLogger).toBe(true)
|
||||
expect(current.defaultLogger).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"attach preserves InstanceRef from the current fiber context",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const current = yield* attach(
|
||||
Effect.gen(function* () {
|
||||
return (yield* InstanceRef)?.directory
|
||||
}),
|
||||
),
|
||||
).pipe(provideInstance(dir))
|
||||
)
|
||||
|
||||
expect(current).toBe(dir)
|
||||
}),
|
||||
expect(current).toBe(test.directory)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("EffectBridge preserves logger and instance context across async boundaries", () =>
|
||||
Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const result = yield* Effect.promise(() =>
|
||||
AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const bridge = yield* EffectBridge.make()
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.resolve().then(() =>
|
||||
bridge.promise(
|
||||
Effect.gen(function* () {
|
||||
return {
|
||||
directory: (yield* InstanceRef)?.directory,
|
||||
...check(yield* Effect.service(Logger.CurrentLoggers)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
it.instance(
|
||||
"EffectBridge preserves logger and instance context across async boundaries",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const bridge = yield* EffectBridge.make()
|
||||
const started = yield* Deferred.make<void>()
|
||||
|
||||
const fiber = yield* Effect.gen(function* () {
|
||||
yield* Deferred.succeed(started, undefined)
|
||||
return yield* Effect.promise(() =>
|
||||
Promise.resolve().then(() =>
|
||||
bridge.promise(
|
||||
Effect.gen(function* () {
|
||||
return {
|
||||
directory: (yield* InstanceRef)?.directory,
|
||||
...check(yield* Effect.service(Logger.CurrentLoggers)),
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
).pipe(provideInstance(dir))
|
||||
),
|
||||
)
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
expect(result.directory).toBe(dir)
|
||||
expect(result.effectLogger).toBe(true)
|
||||
expect(result.defaultLogger).toBe(false)
|
||||
}),
|
||||
yield* Deferred.await(started)
|
||||
const result = yield* Fiber.join(fiber)
|
||||
|
||||
expect(result.directory).toBe(test.directory)
|
||||
expect(result.effectLogger).toBe(true)
|
||||
expect(result.defaultLogger).toBe(false)
|
||||
}).pipe(Effect.provide(Observability.layer)),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { it } from "../lib/effect"
|
||||
|
||||
const fromConfig = (input: Record<string, unknown>) =>
|
||||
RuntimeFlags.defaultLayer.pipe(Layer.provide(ConfigProvider.layer(ConfigProvider.fromUnknown(input))))
|
||||
|
||||
const readFlags = RuntimeFlags.Service.useSync((flags) => flags)
|
||||
|
||||
describe("RuntimeFlags", () => {
|
||||
it.effect("defaultLayer parses plugin flags from the active ConfigProvider", () =>
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* readFlags.pipe(
|
||||
Effect.provide(
|
||||
fromConfig({
|
||||
OPENCODE_PURE: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL: "true",
|
||||
OPENCODE_ENABLE_EXA: "true",
|
||||
OPENCODE_ENABLE_PARALLEL: "true",
|
||||
OPENCODE_ENABLE_QUESTION_TOOL: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(flags.pure).toBe(true)
|
||||
expect(flags.disableDefaultPlugins).toBe(true)
|
||||
expect(flags.enableExa).toBe(true)
|
||||
expect(flags.enableParallel).toBe(true)
|
||||
expect(flags.enableQuestionTool).toBe(true)
|
||||
expect(flags.experimentalScout).toBe(true)
|
||||
expect(flags.experimentalLspTool).toBe(true)
|
||||
expect(flags.experimentalPlanMode).toBe(true)
|
||||
expect(flags.client).toBe("desktop")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () =>
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* readFlags.pipe(Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })))
|
||||
|
||||
expect(flags.pure).toBe(false)
|
||||
expect(flags.disableDefaultPlugins).toBe(true)
|
||||
expect(flags.enableExa).toBe(false)
|
||||
expect(flags.client).toBe("cli")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("layer ignores the active ConfigProvider for omitted test overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const flags = yield* readFlags.pipe(
|
||||
Effect.provide(RuntimeFlags.layer()),
|
||||
Effect.provide(
|
||||
ConfigProvider.layer(
|
||||
ConfigProvider.fromUnknown({
|
||||
OPENCODE_PURE: "true",
|
||||
OPENCODE_DISABLE_DEFAULT_PLUGINS: "true",
|
||||
OPENCODE_EXPERIMENTAL: "true",
|
||||
OPENCODE_ENABLE_EXA: "true",
|
||||
OPENCODE_CLIENT: "desktop",
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(flags.pure).toBe(false)
|
||||
expect(flags.disableDefaultPlugins).toBe(false)
|
||||
expect(flags.enableExa).toBe(false)
|
||||
expect(flags.client).toBe("cli")
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -12,6 +12,10 @@ type Count = {
|
||||
command_drop: number
|
||||
}
|
||||
|
||||
type AttentionOpts = Partial<Omit<HostPluginApi["attention"], "soundboard">> & {
|
||||
soundboard?: Partial<HostPluginApi["attention"]["soundboard"]>
|
||||
}
|
||||
|
||||
function themeCurrent(): HostPluginApi["theme"]["current"] {
|
||||
const a = RGBA.fromInts(0, 120, 240)
|
||||
const b = RGBA.fromInts(120, 120, 120)
|
||||
@@ -83,6 +87,8 @@ function themeCurrent(): HostPluginApi["theme"]["current"] {
|
||||
type Opts = {
|
||||
client?: HostPluginApi["client"] | (() => HostPluginApi["client"])
|
||||
renderer?: HostPluginApi["renderer"]
|
||||
attention?: AttentionOpts
|
||||
event?: HostPluginApi["event"]
|
||||
count?: Count
|
||||
keymap?: HostPluginApi["keymap"]
|
||||
tuiConfig?: Partial<HostPluginApi["tuiConfig"]>
|
||||
@@ -183,6 +189,17 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
return opts.app?.version ?? "0.0.0-test"
|
||||
},
|
||||
},
|
||||
attention: {
|
||||
async notify(input) {
|
||||
return opts.attention?.notify?.(input) ?? { ok: false, notification: false, sound: false }
|
||||
},
|
||||
soundboard: {
|
||||
registerPack: (pack) => opts.attention?.soundboard?.registerPack?.(pack) ?? (() => {}),
|
||||
activate: (id, options) => opts.attention?.soundboard?.activate?.(id, options) ?? false,
|
||||
current: () => opts.attention?.soundboard?.current?.() ?? "opencode.default",
|
||||
list: () => opts.attention?.soundboard?.list?.() ?? [],
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
formatSequence: () => "",
|
||||
formatBindings: () => undefined,
|
||||
@@ -190,7 +207,7 @@ export function createTuiPluginApi(opts: Opts = {}): HostPluginApi {
|
||||
get client() {
|
||||
return client()
|
||||
},
|
||||
event: {
|
||||
event: opts.event ?? {
|
||||
on: () => {
|
||||
if (count) count.event_add += 1
|
||||
return () => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { TuiConfig } from "../../src/cli/cmd/tui/config/tui"
|
||||
import { TuiKeybind } from "../../src/cli/cmd/tui/config/keybind"
|
||||
|
||||
type PluginSpec = string | [string, Record<string, unknown>]
|
||||
type ResolvedInput = Omit<TuiConfig.Resolved, "keybinds" | "leader_timeout"> & {
|
||||
type ResolvedInput = Omit<TuiConfig.Resolved, "attention" | "keybinds" | "leader_timeout"> & {
|
||||
attention?: Partial<TuiConfig.Resolved["attention"]>
|
||||
keybinds?: Partial<TuiKeybind.Keybinds>
|
||||
leader_timeout?: number
|
||||
}
|
||||
@@ -22,6 +23,15 @@ export function createTuiResolvedConfig(input: ResolvedInput = {}): TuiConfig.Re
|
||||
const keybinds = TuiKeybind.Keybinds.parse(input.keybinds ?? {})
|
||||
return {
|
||||
...input,
|
||||
attention: {
|
||||
enabled: false,
|
||||
notifications: true,
|
||||
sound: true,
|
||||
volume: 0.4,
|
||||
sound_pack: "opencode.default",
|
||||
sounds: {},
|
||||
...input.attention,
|
||||
},
|
||||
keybinds: createTuiResolvedKeybinds(keybinds),
|
||||
leader_timeout: input.leader_timeout ?? 2000,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { afterEach, describe, test, expect } from "bun:test"
|
||||
import { Permission } from "../src/permission"
|
||||
import { Config } from "@/config/config"
|
||||
import { ConfigPermission } from "@/config/permission"
|
||||
import { Instance } from "../src/project/instance"
|
||||
import { WithInstance } from "../src/project/with-instance"
|
||||
import { disposeAllInstances, tmpdir } from "./fixture/fixture"
|
||||
@@ -163,7 +164,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
// general and orchestrator-fast should be allowed, code-reviewer denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "orchestrator-fast", ruleset).action).toBe("allow")
|
||||
@@ -188,7 +191,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
// general and code-reviewer should be ask, orchestrator-* denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("ask")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("ask")
|
||||
@@ -213,7 +218,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
expect(Permission.evaluate("task", "code-reviewer", ruleset).action).toBe("deny")
|
||||
// Unspecified agents default to "ask"
|
||||
@@ -240,7 +247,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Verify task permissions
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
@@ -278,7 +287,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Last matching rule wins - "*" deny is last, so all agents are denied
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("deny")
|
||||
@@ -309,7 +320,9 @@ describe("permission.task with real config files", () => {
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const config = await load()
|
||||
const ruleset = Permission.fromConfig(config.permission ?? {})
|
||||
const ruleset = Permission.merge(
|
||||
...ConfigPermission.toLayers(config.permission).map((p) => Permission.fromConfig(p)),
|
||||
)
|
||||
|
||||
// Evaluate uses findLast - "general" allow comes after "*" deny
|
||||
expect(Permission.evaluate("task", "general", ruleset).action).toBe("allow")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@ import { provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { ProviderAuth } from "@/provider/auth"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { Auth } from "@/auth"
|
||||
import { Bus } from "@/bus"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
@@ -21,6 +22,7 @@ function layer(directory: string, plugins: string[]) {
|
||||
Layer.provide(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(RuntimeFlags.layer()),
|
||||
Layer.provide(
|
||||
TestConfig.layer({
|
||||
get: () =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterAll, afterEach, describe, expect, spyOn } from "bun:test"
|
||||
import { afterEach, describe, expect, spyOn } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
@@ -8,23 +8,13 @@ import { disposeAllInstances, provideInstance, tmpdirScoped } from "../fixture/f
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { PluginLoader } = await import("../../src/plugin/loader")
|
||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||
const { Bus } = await import("../../src/bus")
|
||||
const { Npm } = await import("@opencode-ai/core/npm")
|
||||
const { TestConfig } = await import("../fixture/config")
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
return
|
||||
}
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||
})
|
||||
const { RuntimeFlags } = await import("../../src/effect/runtime-flags")
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
@@ -43,7 +33,7 @@ function withTmp<T, A, E, R>(
|
||||
})
|
||||
}
|
||||
|
||||
function load(dir: string) {
|
||||
function load(dir: string, flags?: Parameters<typeof RuntimeFlags.layer>[0]) {
|
||||
const source = path.join(dir, "opencode.json")
|
||||
return Effect.gen(function* () {
|
||||
const config = yield* Effect.promise(
|
||||
@@ -57,6 +47,7 @@ function load(dir: string) {
|
||||
Effect.provide(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true, ...flags })),
|
||||
Layer.provide(
|
||||
TestConfig.layer({
|
||||
get: () =>
|
||||
@@ -934,25 +925,14 @@ export default {
|
||||
},
|
||||
(tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const pure = process.env.OPENCODE_PURE
|
||||
process.env.OPENCODE_PURE = "1"
|
||||
|
||||
try {
|
||||
yield* load(tmp.path)
|
||||
const called = yield* Effect.promise(() =>
|
||||
fs
|
||||
.readFile(tmp.extra.mark, "utf8")
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
expect(called).toBe(false)
|
||||
} finally {
|
||||
if (pure === undefined) {
|
||||
delete process.env.OPENCODE_PURE
|
||||
} else {
|
||||
process.env.OPENCODE_PURE = pure
|
||||
}
|
||||
}
|
||||
yield* load(tmp.path, { pure: true })
|
||||
const called = yield* Effect.promise(() =>
|
||||
fs
|
||||
.readFile(tmp.extra.mark, "utf8")
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
expect(called).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,26 +1,48 @@
|
||||
import { afterAll, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { Plugin } from "../../src/plugin/index"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const systemHook = "experimental.chat.system.transform"
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
return
|
||||
}
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provide(NpmTest.noop),
|
||||
)
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
|
||||
),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
)
|
||||
const systemHook = "experimental.chat.system.transform"
|
||||
|
||||
function withProject<A, E, R>(source: string, self: Effect.Effect<A, E, R>) {
|
||||
return provideTmpdirInstance((dir) =>
|
||||
|
||||
@@ -1,25 +1,51 @@
|
||||
import { afterAll, afterEach, describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Effect, Layer, Option } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { EffectFlock } from "@opencode-ai/core/util/effect-flock"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { Account } from "../../src/account/account"
|
||||
import { Auth } from "../../src/auth"
|
||||
import { Bus } from "../../src/bus"
|
||||
import { Config } from "../../src/config/config"
|
||||
import { Env } from "../../src/env"
|
||||
import { RuntimeFlags } from "../../src/effect/runtime-flags"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { Plugin } from "../../src/plugin/index"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { NpmTest } from "../fake/npm"
|
||||
|
||||
const disableDefault = process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = "1"
|
||||
|
||||
const { Flag } = await import("@opencode-ai/core/flag/flag")
|
||||
const { Plugin } = await import("../../src/plugin/index")
|
||||
const { Workspace } = await import("../../src/control-plane/workspace")
|
||||
const { InstanceBootstrap } = await import("../../src/project/bootstrap")
|
||||
const { Instance } = await import("../../src/project/instance")
|
||||
const { InstanceStore } = await import("../../src/project/instance-store")
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
const emptyAccount = Layer.mock(Account.Service)({
|
||||
active: () => Effect.succeed(Option.none()),
|
||||
activeOrg: () => Effect.succeed(Option.none()),
|
||||
})
|
||||
const emptyAuth = Layer.mock(Auth.Service)({
|
||||
all: () => Effect.succeed({}),
|
||||
})
|
||||
const configLayer = Config.layer.pipe(
|
||||
Layer.provide(EffectFlock.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Env.defaultLayer),
|
||||
Layer.provide(emptyAuth),
|
||||
Layer.provide(emptyAccount),
|
||||
Layer.provide(NpmTest.noop),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(Plugin.defaultLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const pluginLayer = Plugin.layer.pipe(
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableDefaultPlugins: true })),
|
||||
)
|
||||
const noopBootstrapLayer = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrapLayer))),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(pluginLayer, workspaceLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
const experimental = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
|
||||
@@ -30,12 +56,6 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (disableDefault === undefined) {
|
||||
delete process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS
|
||||
} else {
|
||||
process.env.OPENCODE_DISABLE_DEFAULT_PLUGINS = disableDefault
|
||||
}
|
||||
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = experimental
|
||||
})
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ process.env["OPENCODE_TEST_HOME"] = testHome
|
||||
// Set test managed config directory to isolate tests from system managed settings
|
||||
const testManagedConfigDir = path.join(dir, "managed")
|
||||
process.env["OPENCODE_TEST_MANAGED_CONFIG_DIR"] = testManagedConfigDir
|
||||
process.env["OPENCODE_DISABLE_DEFAULT_PLUGINS"] = "true"
|
||||
|
||||
// Write the cache version file to prevent global/index.ts from clearing the cache
|
||||
const cacheDir = path.join(dir, "cache", "opencode")
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { afterEach, expect, test } from "bun:test"
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { existsSync } from "node:fs"
|
||||
import path from "node:path"
|
||||
import { pathToFileURL } from "node:url"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { bootstrap as cliBootstrap } from "../../src/cli/bootstrap"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { InstanceLayer } from "../../src/project/instance-layer"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { disposeAllInstances, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(InstanceLayer.layer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
// InstanceBootstrap must run before any code touches the instance —
|
||||
// originally tracked by PRs #25389 and #25449, now a permanent
|
||||
@@ -19,58 +24,64 @@ afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
async function bootstrapFixture() {
|
||||
return tmpdir({
|
||||
init: async (dir) => {
|
||||
const marker = path.join(dir, "config-hook-fired")
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
await Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
`const MARKER = ${JSON.stringify(marker)}`,
|
||||
"export default async () => ({",
|
||||
" config: async () => {",
|
||||
' await Bun.write(MARKER, "ran")',
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
await Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
)
|
||||
return marker
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("WithInstance.provide runs InstanceBootstrap before fn", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => "ok",
|
||||
})
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
const bootstrapFixture = Effect.gen(function* () {
|
||||
const dir = yield* tmpdirScoped({ git: true })
|
||||
const marker = path.join(dir, "config-hook-fired")
|
||||
const pluginFile = path.join(dir, "plugin.ts")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
pluginFile,
|
||||
[
|
||||
`const MARKER = ${JSON.stringify(marker)}`,
|
||||
"export default async () => ({",
|
||||
" config: async () => {",
|
||||
' await Bun.write(MARKER, "ran")',
|
||||
" },",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, "opencode.json"),
|
||||
JSON.stringify({
|
||||
$schema: "https://opencode.ai/config.json",
|
||||
plugin: [pathToFileURL(pluginFile).href],
|
||||
}),
|
||||
),
|
||||
)
|
||||
return { directory: dir, marker }
|
||||
})
|
||||
|
||||
test("CLI bootstrap runs InstanceBootstrap before callback", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
it.live("InstanceStore.provide runs InstanceBootstrap before effect", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* bootstrapFixture
|
||||
const store = yield* InstanceStore.Service
|
||||
|
||||
await cliBootstrap(tmp.path, async () => "ok")
|
||||
yield* store.provide({ directory: tmp.directory }, Effect.succeed("ok"))
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
expect(existsSync(tmp.marker)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
test("InstanceRuntime.reloadInstance runs InstanceBootstrap", async () => {
|
||||
await using tmp = await bootstrapFixture()
|
||||
it.live("CLI bootstrap runs InstanceBootstrap before callback", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* bootstrapFixture
|
||||
|
||||
await InstanceRuntime.reloadInstance({ directory: tmp.path })
|
||||
yield* Effect.promise(() => cliBootstrap(tmp.directory, async () => "ok"))
|
||||
|
||||
expect(existsSync(tmp.extra)).toBe(true)
|
||||
})
|
||||
expect(existsSync(tmp.marker)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("InstanceStore.reload runs InstanceBootstrap", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* bootstrapFixture
|
||||
const store = yield* InstanceStore.Service
|
||||
|
||||
yield* store.reload({ directory: tmp.directory })
|
||||
|
||||
expect(existsSync(tmp.marker)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Project } from "@/project/project"
|
||||
import { Database } from "@/storage/db"
|
||||
import { eq } from "drizzle-orm"
|
||||
@@ -8,19 +8,14 @@ import { ProjectID } from "../../src/project/schema"
|
||||
import { SessionID } from "../../src/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { $ } from "bun"
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { Effect } from "effect"
|
||||
import { tmpdirScoped } from "../fixture/fixture"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
Log.init({ print: false })
|
||||
void Log.init({ print: false })
|
||||
|
||||
function run<A>(fn: (svc: Project.Interface) => Effect.Effect<A>) {
|
||||
return Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
}).pipe(Effect.provide(Project.defaultLayer)),
|
||||
)
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
|
||||
function legacySessionID() {
|
||||
// Global-session migration covers persisted IDs from before prefixed session IDs.
|
||||
@@ -63,91 +58,102 @@ function ensureGlobal() {
|
||||
}
|
||||
|
||||
describe("migrateFromGlobal", () => {
|
||||
test("migrates global sessions on first project creation", async () => {
|
||||
// 1. Start with git init but no commits — creates "global" project row
|
||||
await using tmp = await tmpdir()
|
||||
await $`git init`.cwd(tmp.path).quiet()
|
||||
await $`git config user.name "Test"`.cwd(tmp.path).quiet()
|
||||
await $`git config user.email "test@opencode.test"`.cwd(tmp.path).quiet()
|
||||
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
|
||||
const { project: pre } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(pre.id).toBe(ProjectID.global)
|
||||
it.live("migrates global sessions on first project creation", () =>
|
||||
Effect.gen(function* () {
|
||||
// 1. Start with git init but no commits — creates "global" project row
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git config user.name "Test"`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git config user.email "test@opencode.test"`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git config commit.gpgsign false`.cwd(tmp).quiet())
|
||||
const projects = yield* Project.Service
|
||||
const { project: pre } = yield* projects.fromDirectory(tmp)
|
||||
expect(pre.id).toBe(ProjectID.global)
|
||||
|
||||
// 2. Seed a session under "global" with matching directory
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: tmp.path, project: ProjectID.global })
|
||||
// 2. Seed a session under "global" with matching directory
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
|
||||
|
||||
// 3. Make a commit so the project gets a real ID
|
||||
await $`git commit --allow-empty -m "root"`.cwd(tmp.path).quiet()
|
||||
// 3. Make a commit so the project gets a real ID
|
||||
yield* Effect.promise(() => $`git commit --allow-empty -m "root"`.cwd(tmp).quiet())
|
||||
|
||||
const { project: real } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(real.id).not.toBe(ProjectID.global)
|
||||
const { project: real } = yield* projects.fromDirectory(tmp)
|
||||
expect(real.id).not.toBe(ProjectID.global)
|
||||
|
||||
// 4. The session should have been migrated to the real project ID
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(real.id)
|
||||
})
|
||||
// 4. The session should have been migrated to the real project ID
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(real.id)
|
||||
}),
|
||||
)
|
||||
|
||||
test("migrates global sessions even when project row already exists", async () => {
|
||||
// 1. Create a repo with a commit — real project ID created immediately
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
it.live("migrates global sessions even when project row already exists", () =>
|
||||
Effect.gen(function* () {
|
||||
// 1. Create a repo with a commit — real project ID created immediately
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
|
||||
ensureGlobal()
|
||||
// 2. Ensure "global" project row exists (as it would from a prior no-git session)
|
||||
yield* Effect.sync(() => ensureGlobal())
|
||||
|
||||
// 3. Seed a session under "global" with matching directory.
|
||||
// This simulates a session created before git init that wasn't
|
||||
// present when the real project row was first created.
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: tmp.path, project: ProjectID.global })
|
||||
// 3. Seed a session under "global" with matching directory.
|
||||
// This simulates a session created before git init that wasn't
|
||||
// present when the real project row was first created.
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: tmp, project: ProjectID.global }))
|
||||
|
||||
// 4. Call fromDirectory again — project row already exists,
|
||||
// so the current code skips migration entirely. This is the bug.
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
// 4. Call fromDirectory again — project row already exists,
|
||||
// so the current code skips migration entirely. This is the bug.
|
||||
yield* projects.fromDirectory(tmp)
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(project.id)
|
||||
})
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(project.id)
|
||||
}),
|
||||
)
|
||||
|
||||
test("does not claim sessions with empty directory", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
it.live("does not claim sessions with empty directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
ensureGlobal()
|
||||
yield* Effect.sync(() => ensureGlobal())
|
||||
|
||||
// Legacy sessions may lack a directory value.
|
||||
// Without a matching origin directory, they should remain global.
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: "", project: ProjectID.global })
|
||||
// Legacy sessions may lack a directory value.
|
||||
// Without a matching origin directory, they should remain global.
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: "", project: ProjectID.global }))
|
||||
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
yield* projects.fromDirectory(tmp)
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
})
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
}),
|
||||
)
|
||||
|
||||
test("does not steal sessions from unrelated directories", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
const { project } = await run((svc) => svc.fromDirectory(tmp.path))
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
it.live("does not steal sessions from unrelated directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
|
||||
ensureGlobal()
|
||||
yield* Effect.sync(() => ensureGlobal())
|
||||
|
||||
// Seed a session under "global" but for a DIFFERENT directory
|
||||
const id = legacySessionID()
|
||||
seed({ id, dir: "/some/other/dir", project: ProjectID.global })
|
||||
// Seed a session under "global" but for a DIFFERENT directory
|
||||
const id = legacySessionID()
|
||||
yield* Effect.sync(() => seed({ id, dir: "/some/other/dir", project: ProjectID.global }))
|
||||
|
||||
await run((svc) => svc.fromDirectory(tmp.path))
|
||||
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
// Should remain under "global" — not stolen
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
})
|
||||
yield* projects.fromDirectory(tmp)
|
||||
const row = Database.use((db) => db.select().from(SessionTable).where(eq(SessionTable.id, id)).get())
|
||||
expect(row).toBeDefined()
|
||||
// Should remain under "global" — not stolen
|
||||
expect(row!.project_id).toBe(ProjectID.global)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,302 +1,300 @@
|
||||
import { $ } from "bun"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import * as fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { GlobalBus, type GlobalEvent } from "../../src/bus/global"
|
||||
import { Git } from "../../src/git"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceRuntime } from "../../src/project/instance-runtime"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { disposeAllInstances, provideInstance, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { disposeAllInstances, provideInstance, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Worktree.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||
const wintest = process.platform !== "win32" ? it.live : it.live.skip
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(Worktree.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer, Git.defaultLayer),
|
||||
)
|
||||
const wintest = process.platform !== "win32" ? it.instance : it.instance.skip
|
||||
|
||||
function normalize(input: string) {
|
||||
return input.replace(/\\/g, "/").toLowerCase()
|
||||
}
|
||||
|
||||
async function waitReady() {
|
||||
const { GlobalBus } = await import("../../src/bus/global")
|
||||
const waitReady = Effect.fn("WorktreeTest.waitReady")(function* () {
|
||||
const ready = yield* Deferred.make<{ name: string; branch?: string }>()
|
||||
const on = (evt: GlobalEvent) => {
|
||||
if (evt.payload.type !== Worktree.Event.Ready.type) return
|
||||
Deferred.doneUnsafe(ready, Effect.succeed(evt.payload.properties))
|
||||
}
|
||||
|
||||
return await new Promise<{ name: string; branch?: string }>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
GlobalBus.off("event", on)
|
||||
reject(new Error("timed out waiting for worktree.ready"))
|
||||
}, 10_000)
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
function on(evt: { directory?: string; payload: { type: string; properties: { name: string; branch?: string } } }) {
|
||||
if (evt.payload.type !== Worktree.Event.Ready.type) return
|
||||
clearTimeout(timer)
|
||||
GlobalBus.off("event", on)
|
||||
resolve(evt.payload.properties)
|
||||
}
|
||||
return yield* Deferred.await(ready).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "10 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
const removeCreatedWorktree = (directory: string) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const ctx = yield* Effect.sync(() => Instance.current).pipe(provideInstance(directory))
|
||||
yield* Effect.promise(() => InstanceRuntime.disposeInstance(ctx))
|
||||
const ok = yield* svc.remove({ directory })
|
||||
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${directory}`))
|
||||
})
|
||||
}
|
||||
|
||||
const withCreatedWorktree = <A, E, R>(
|
||||
input: Parameters<Worktree.Interface["create"]>[0],
|
||||
use: (created: { info: Worktree.Info; ready: { name: string; branch?: string } }) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const ready = yield* waitReady().pipe(Effect.forkScoped)
|
||||
const info = yield* svc.create(input)
|
||||
const props = yield* Fiber.join(ready)
|
||||
return { info, ready: props }
|
||||
}),
|
||||
use,
|
||||
({ info }) => removeCreatedWorktree(info.directory),
|
||||
)
|
||||
|
||||
const git = Effect.fn("WorktreeTest.git")(function* (cwd: string, args: string[]) {
|
||||
const service = yield* Git.Service
|
||||
const result = yield* service.run(args, { cwd })
|
||||
if (result.exitCode !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr.toString("utf8")}`)
|
||||
return result.text()
|
||||
})
|
||||
|
||||
const gitResult = Effect.fn("WorktreeTest.gitResult")(function* (cwd: string, args: string[]) {
|
||||
const service = yield* Git.Service
|
||||
return yield* service.run(args, { cwd })
|
||||
})
|
||||
|
||||
describe("Worktree", () => {
|
||||
afterEach(() => disposeAllInstances())
|
||||
|
||||
describe("makeWorktreeInfo", () => {
|
||||
it.live("returns info with name, branch, and directory", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo()
|
||||
|
||||
expect(info.name).toBeDefined()
|
||||
expect(typeof info.name).toBe("string")
|
||||
expect(info.branch).toBe(`opencode/${info.name}`)
|
||||
expect(info.directory).toContain(info.name)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("uses provided name as base", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
|
||||
|
||||
expect(info.name).toBe("my-feature")
|
||||
expect(info.branch).toBe("opencode/my-feature")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("slugifies the provided name", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
|
||||
|
||||
expect(info.name).toBe("my-feature-branch")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("omits branch for detached info", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
yield* Effect.promise(() => $`git branch opencode/my-feature`.cwd(dir).quiet())
|
||||
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true })
|
||||
|
||||
expect(info.name).toBe("my-feature")
|
||||
expect(info.branch).toBeUndefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("throws NotGitError for non-git directories", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
it.instance(
|
||||
"returns info with name, branch, and directory",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
|
||||
const info = yield* svc.makeWorktreeInfo()
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
|
||||
expect(info.name).toBeDefined()
|
||||
expect(typeof info.name).toBe("string")
|
||||
expect(info.branch).toBe(`opencode/${info.name}`)
|
||||
expect(info.directory).toContain(info.name)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
wintest("creates detached git worktree when info has no branch", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true })
|
||||
const ready = waitReady()
|
||||
yield* svc.createFromInfo(info)
|
||||
it.instance(
|
||||
"uses provided name as base",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "my-feature" })
|
||||
|
||||
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
|
||||
const normalizedList = normalize(list)
|
||||
const normalizedDir = normalize(info.directory)
|
||||
expect(normalizedList).toContain(normalizedDir)
|
||||
expect(info.name).toBe("my-feature")
|
||||
expect(info.branch).toBe("opencode/my-feature")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
const branch = yield* Effect.promise(() =>
|
||||
$`git symbolic-ref -q --short HEAD`.cwd(info.directory).quiet().nothrow(),
|
||||
)
|
||||
expect(branch.exitCode).not.toBe(0)
|
||||
it.instance(
|
||||
"slugifies the provided name",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "My Feature Branch!" })
|
||||
|
||||
const props = yield* Effect.promise(() => ready)
|
||||
expect(props.name).toBe(info.name)
|
||||
expect(props.branch).toBeUndefined()
|
||||
expect(info.name).toBe("my-feature-branch")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
it.instance(
|
||||
"omits branch for detached info",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const svc = yield* Worktree.Service
|
||||
yield* git(test.directory, ["branch", "opencode/my-feature"])
|
||||
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "my-feature", detached: true })
|
||||
|
||||
expect(info.name).toBe("my-feature")
|
||||
expect(info.branch).toBeUndefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance("throws NotGitError for non-git directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const exit = yield* Effect.exit(svc.makeWorktreeInfo())
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
|
||||
}),
|
||||
)
|
||||
|
||||
wintest(
|
||||
"creates detached git worktree when info has no branch",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "detached-test", detached: true })
|
||||
const ready = yield* waitReady().pipe(Effect.forkScoped)
|
||||
yield* svc.createFromInfo(info)
|
||||
|
||||
const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
|
||||
const normalizedList = normalize(list)
|
||||
const normalizedDir = normalize(info.directory)
|
||||
expect(normalizedList).toContain(normalizedDir)
|
||||
|
||||
const branch = yield* gitResult(info.directory, ["symbolic-ref", "-q", "--short", "HEAD"])
|
||||
expect(branch.exitCode).not.toBe(0)
|
||||
|
||||
const props = yield* Fiber.join(ready)
|
||||
expect(props.name).toBe(info.name)
|
||||
expect(props.branch).toBeUndefined()
|
||||
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("create + remove lifecycle", () => {
|
||||
it.live("create returns worktree info and remove cleans up", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
it.instance(
|
||||
"create returns worktree info and remove cleans up",
|
||||
() =>
|
||||
withCreatedWorktree(undefined, ({ info }) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.create()
|
||||
|
||||
expect(info.name).toBeDefined()
|
||||
expect(info.branch ?? "").toStartWith("opencode/")
|
||||
expect(info.directory).toBeDefined()
|
||||
|
||||
yield* Effect.promise(() => Bun.sleep(1000))
|
||||
|
||||
const ok = yield* svc.remove({ directory: info.directory })
|
||||
expect(ok).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("create returns after setup and fires Event.Ready after bootstrap", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
it.instance(
|
||||
"create returns after setup and fires Event.Ready after bootstrap",
|
||||
() =>
|
||||
withCreatedWorktree(undefined, ({ info, ready }) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const ready = waitReady()
|
||||
const info = yield* svc.create()
|
||||
|
||||
expect(info.name).toBeDefined()
|
||||
expect(info.branch ?? "").toStartWith("opencode/")
|
||||
|
||||
const text = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
|
||||
const next = yield* Effect.promise(() => fs.realpath(info.directory).catch(() => info.directory))
|
||||
expect(normalize(text)).toContain(normalize(next))
|
||||
expect(ready.name).toBe(info.name)
|
||||
expect(ready.branch).toBe(info.branch)
|
||||
|
||||
const props = yield* Effect.promise(() => ready)
|
||||
expect(props.name).toBe(info.name)
|
||||
expect(props.branch).toBe(info.branch)
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
WithInstance.provide({
|
||||
directory: info.directory,
|
||||
fn: () => InstanceRuntime.disposeInstance(Instance.current),
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => Bun.sleep(100))
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
const list = yield* svc.list()
|
||||
expect(list).toContainEqual(expect.objectContaining({ name: info.name, branch: info.branch }))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("create with custom name", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
it.instance(
|
||||
"create with custom name",
|
||||
() =>
|
||||
withCreatedWorktree({ name: "test-workspace" }, ({ info }) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const ready = waitReady()
|
||||
const info = yield* svc.create({ name: "test-workspace" })
|
||||
|
||||
expect(info.name).toBe("test-workspace")
|
||||
expect(info.branch).toBe("opencode/test-workspace")
|
||||
|
||||
yield* Effect.promise(() => ready)
|
||||
yield* Effect.promise(() =>
|
||||
WithInstance.provide({
|
||||
directory: info.directory,
|
||||
fn: () => InstanceRuntime.disposeInstance(Instance.current),
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => Bun.sleep(100))
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("createFromInfo", () => {
|
||||
wintest("creates git worktree and boots asynchronously", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
|
||||
const ready = waitReady()
|
||||
yield* svc.createFromInfo(info)
|
||||
wintest(
|
||||
"creates git worktree and boots asynchronously",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const svc = yield* Worktree.Service
|
||||
const info = yield* svc.makeWorktreeInfo({ name: "from-info-test" })
|
||||
const ready = yield* waitReady().pipe(Effect.forkScoped)
|
||||
yield* svc.createFromInfo(info)
|
||||
|
||||
const list = yield* Effect.promise(() => $`git worktree list --porcelain`.cwd(dir).quiet().text())
|
||||
const normalizedList = list.replace(/\\/g, "/")
|
||||
const normalizedDir = info.directory.replace(/\\/g, "/")
|
||||
expect(normalizedList).toContain(normalizedDir)
|
||||
const list = yield* git(test.directory, ["worktree", "list", "--porcelain"])
|
||||
const normalizedList = list.replace(/\\/g, "/")
|
||||
const normalizedDir = info.directory.replace(/\\/g, "/")
|
||||
expect(normalizedList).toContain(normalizedDir)
|
||||
|
||||
yield* Effect.promise(() => ready)
|
||||
yield* svc.remove({ directory: info.directory })
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
yield* Fiber.join(ready)
|
||||
yield* removeCreatedWorktree(info.directory)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("list", () => {
|
||||
it.live("uses parent folder name when worktree basename matches the primary worktree", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const parent = path.join(path.dirname(dir), `${path.basename(dir)}-parent`)
|
||||
const target = path.join(parent, path.basename(dir))
|
||||
const branch = `same-basename-list-${Date.now()}`
|
||||
it.instance(
|
||||
"uses parent folder name when worktree basename matches the primary worktree",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const fs = yield* AppFileSystem.Service
|
||||
const svc = yield* Worktree.Service
|
||||
const parent = path.join(path.dirname(test.directory), `${path.basename(test.directory)}-parent`)
|
||||
const target = path.join(parent, path.basename(test.directory))
|
||||
const branch = `same-basename-list-${Date.now()}`
|
||||
|
||||
yield* Effect.promise(() => fs.mkdir(parent, { recursive: true }))
|
||||
yield* Effect.promise(() => $`git worktree add -b ${branch} ${target}`.cwd(dir).quiet())
|
||||
yield* fs.ensureDir(parent)
|
||||
yield* git(test.directory, ["worktree", "add", "-b", branch, target])
|
||||
|
||||
const list = yield* svc.list()
|
||||
const directory = yield* Effect.promise(() => fs.realpath(target).catch(() => target))
|
||||
const list = yield* svc.list()
|
||||
const directory = yield* fs.realPath(target).pipe(Effect.catch(() => Effect.succeed(target)))
|
||||
|
||||
expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
|
||||
name: path.basename(parent),
|
||||
branch,
|
||||
directory: normalize(directory),
|
||||
})
|
||||
expect(list.map((item) => ({ ...item, directory: normalize(item.directory) }))).toContainEqual({
|
||||
name: path.basename(parent),
|
||||
branch,
|
||||
directory: normalize(directory),
|
||||
})
|
||||
|
||||
yield* svc.remove({ directory: target })
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
yield* svc.remove({ directory: target })
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
describe("remove edge cases", () => {
|
||||
it.live("remove non-existent directory succeeds silently", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const ok = yield* svc.remove({ directory: path.join(dir, "does-not-exist") })
|
||||
expect(ok).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
it.instance(
|
||||
"remove non-existent directory succeeds silently",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const svc = yield* Worktree.Service
|
||||
const ok = yield* svc.remove({ directory: path.join(test.directory, "does-not-exist") })
|
||||
expect(ok).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("throws NotGitError for non-git directories", () =>
|
||||
provideTmpdirInstance(() =>
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Worktree.Service
|
||||
const exit = yield* Effect.exit(svc.remove({ directory: "/tmp/fake" }))
|
||||
it.instance("throws NotGitError for non-git directories", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const svc = yield* Worktree.Service
|
||||
const exit = yield* Effect.exit(svc.remove({ directory: path.join(test.directory, "fake") }))
|
||||
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
|
||||
}),
|
||||
),
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(Cause.squash(exit.cause)).toBeInstanceOf(Worktree.NotGitError)
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { test, expect, afterEach } from "bun:test"
|
||||
import path from "path"
|
||||
|
||||
import { tmpdir } from "../fixture/fixture"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Provider } from "../../src/provider/provider"
|
||||
import { ProviderID } from "../../src/provider/schema"
|
||||
import { Env } from "../../src/env"
|
||||
import { Effect } from "effect"
|
||||
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||
import { makeRuntime } from "../../src/effect/run-service"
|
||||
|
||||
const envRuntime = makeRuntime(Env.Service, Env.defaultLayer)
|
||||
const set = (k: string, v: string) => envRuntime.runSync((svc) => svc.set(k, v))
|
||||
|
||||
async function list() {
|
||||
return AppRuntime.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const provider = yield* Provider.Service
|
||||
return yield* provider.list()
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
const DIGITALOCEAN = ProviderID.make("digitalocean")
|
||||
|
||||
const originalAuthContent = process.env.OPENCODE_AUTH_CONTENT
|
||||
afterEach(() => {
|
||||
if (originalAuthContent === undefined) delete process.env.OPENCODE_AUTH_CONTENT
|
||||
else process.env.OPENCODE_AUTH_CONTENT = originalAuthContent
|
||||
})
|
||||
|
||||
function injectAuth(metadata: Record<string, string> | undefined) {
|
||||
process.env.OPENCODE_AUTH_CONTENT = JSON.stringify({
|
||||
digitalocean: {
|
||||
type: "api",
|
||||
key: "sk_do_test",
|
||||
...(metadata ? { metadata } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
test("digitalocean provider autoloads from DIGITALOCEAN_ACCESS_TOKEN", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
|
||||
const providers = await list()
|
||||
expect(providers[DIGITALOCEAN]).toBeDefined()
|
||||
expect(providers[DIGITALOCEAN].source).toBe("env")
|
||||
const baseModel = Object.values(providers[DIGITALOCEAN].models)[0]
|
||||
expect(baseModel.api.url).toBe("https://inference.do-ai.run/v1")
|
||||
expect(baseModel.api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
const routerEntries = Object.keys(providers[DIGITALOCEAN].models).filter((id) => id.startsWith("router:"))
|
||||
expect(routerEntries.length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("digitalocean provider.models surfaces cached routers from auth metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
injectAuth({
|
||||
routers: JSON.stringify([
|
||||
{ name: "my-router", uuid: "11f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
|
||||
{ name: "other-router", uuid: "22f1499a-aaaa-bbbb-cccc-4e013e2ddde4" },
|
||||
]),
|
||||
routers_fetched_at: String(Date.now()),
|
||||
oauth_access: "doo_v1_test",
|
||||
oauth_expires: String(Date.now() + 60 * 60 * 1000),
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(models["router:my-router"]).toBeDefined()
|
||||
expect(models["router:my-router"].api.id).toBe("router:my-router")
|
||||
expect(models["router:my-router"].api.url).toBe("https://inference.do-ai.run/v1")
|
||||
expect(models["router:my-router"].api.npm).toBe("@ai-sdk/openai-compatible")
|
||||
expect(models["router:other-router"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("digitalocean provider.models skips refresh when oauth bearer is expired", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
injectAuth({
|
||||
routers: JSON.stringify([{ name: "stale-router", uuid: "stale" }]),
|
||||
routers_fetched_at: "0",
|
||||
oauth_access: "doo_v1_expired",
|
||||
oauth_expires: "1",
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const providers = await list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(models["router:stale-router"]).toBeDefined()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("digitalocean provider.models passes through base models when no auth metadata", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
init: async (dir) => {
|
||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://opencode.ai/config.json" }))
|
||||
},
|
||||
})
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
set("DIGITALOCEAN_ACCESS_TOKEN", "test-token")
|
||||
const providers = await list()
|
||||
const models = providers[DIGITALOCEAN].models
|
||||
expect(Object.keys(models).length).toBeGreaterThan(0)
|
||||
expect(Object.keys(models).filter((id) => id.startsWith("router:")).length).toBe(0)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -1,47 +1,183 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Deferred, Effect, Fiber, Layer } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { GlobalBus, type GlobalEvent } from "@/bus/global"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/groups/experimental"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionTable } from "@/session/session.sql"
|
||||
import { Database } from "@/storage/db"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Worktree } from "../../src/worktree"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { waitGlobalBusEventPromise } from "./global-bus"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const testWorktreeMutations = process.platform === "win32" ? test.skip : test
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer))
|
||||
const testWorktreeMutations = process.platform === "win32" ? it.instance.skip : it.instance
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
function request(path: string, directory: string, init: RequestInit = {}) {
|
||||
return Effect.promise(() => {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set("x-opencode-directory", directory)
|
||||
return Promise.resolve(app().request(path, { ...init, headers }))
|
||||
})
|
||||
}
|
||||
|
||||
function createSession(input?: Session.CreateInput) {
|
||||
return runSession(Session.Service.use((svc) => svc.create(input)))
|
||||
return Session.Service.use((svc) => svc.create(input))
|
||||
}
|
||||
|
||||
async function waitReady(directory: string) {
|
||||
await waitGlobalBusEventPromise({
|
||||
message: "timed out waiting for worktree.ready",
|
||||
predicate: (event) => event.payload.type === Worktree.Event.Ready.type && event.directory === directory,
|
||||
function json<T>(response: Response) {
|
||||
return Effect.promise(() => response.json() as Promise<T>)
|
||||
}
|
||||
|
||||
function waitReady(input: { directory?: string; name?: string }) {
|
||||
return Effect.gen(function* () {
|
||||
const ready = yield* Deferred.make<void>()
|
||||
const on = (event: GlobalEvent) => {
|
||||
if (event.payload.type !== Worktree.Event.Ready.type) return
|
||||
if (input.directory && event.directory !== input.directory) return
|
||||
if (input.name && event.payload.properties.name !== input.name) return
|
||||
Deferred.doneUnsafe(ready, Effect.void)
|
||||
}
|
||||
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
return yield* Deferred.await(ready).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "10 seconds",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for worktree.ready")),
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function insertAccount() {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => {
|
||||
Database.Client()
|
||||
.$client.prepare(
|
||||
"INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
"account-test",
|
||||
"test@example.com",
|
||||
"https://console.example.com",
|
||||
"access",
|
||||
"refresh",
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
)
|
||||
return "account-test"
|
||||
}),
|
||||
(id) =>
|
||||
Effect.sync(() => {
|
||||
Database.Client().$client.prepare("DELETE FROM account WHERE id = ?").run(id)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function setSessionUpdated(session: Session.Info, updated: number) {
|
||||
return Effect.sync(() => {
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ time_updated: updated }).where(eq(SessionTable.id, session.id)).run(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function withCreatedWorktree(directory: string, use: (info: Worktree.Info) => Effect.Effect<void, unknown, never>) {
|
||||
const name = "api-test"
|
||||
const headers = { "content-type": "application/json" }
|
||||
return Effect.acquireUseRelease(
|
||||
Effect.gen(function* () {
|
||||
const ready = yield* waitReady({ name }).pipe(Effect.forkScoped)
|
||||
const created = yield* request(ExperimentalPaths.worktree, directory, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
const info = yield* json<Worktree.Info>(created)
|
||||
expect(info).toMatchObject({ name, branch: "opencode/api-test" })
|
||||
yield* Fiber.join(ready)
|
||||
return info
|
||||
}),
|
||||
use,
|
||||
(info) =>
|
||||
Effect.gen(function* () {
|
||||
const removed = yield* request(ExperimentalPaths.worktree, directory, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
if (removed.status !== 200) return yield* Effect.fail(new Error(`failed to remove worktree: ${removed.status}`))
|
||||
const ok = yield* json<boolean>(removed)
|
||||
if (!ok) return yield* Effect.fail(new Error(`failed to remove worktree ${info.directory}`))
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
describe("experimental HttpApi", () => {
|
||||
test("serves read-only experimental endpoints through the default server app", async () => {
|
||||
await using tmp = await tmpdir({
|
||||
it.instance(
|
||||
"serves read-only experimental endpoints through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const directory = tmp.directory
|
||||
const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = yield* Effect.all(
|
||||
[
|
||||
request(ExperimentalPaths.console, directory),
|
||||
request(ExperimentalPaths.consoleOrgs, directory),
|
||||
request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, directory),
|
||||
request(ExperimentalPaths.toolIDs, directory),
|
||||
request(ExperimentalPaths.worktree, directory),
|
||||
request(ExperimentalPaths.resource, directory),
|
||||
],
|
||||
{ concurrency: "unbounded" },
|
||||
)
|
||||
|
||||
expect(consoleState.status).toBe(200)
|
||||
expect(yield* json(consoleState)).toEqual({
|
||||
consoleManagedProviders: [],
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
|
||||
expect(consoleOrgs.status).toBe(200)
|
||||
expect(yield* json(consoleOrgs)).toEqual({ orgs: [] })
|
||||
|
||||
expect(toolList.status).toBe(200)
|
||||
expect(yield* json<unknown[]>(toolList)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "bash",
|
||||
description: expect.any(String),
|
||||
parameters: expect.any(Object),
|
||||
}),
|
||||
)
|
||||
|
||||
expect(toolIDs.status).toBe(200)
|
||||
expect(yield* json(toolIDs)).toContain("bash")
|
||||
|
||||
expect(worktrees.status).toBe(200)
|
||||
expect(yield* json(worktrees)).toEqual([])
|
||||
|
||||
expect(resources.status).toBe(200)
|
||||
expect(yield* json(resources)).toEqual({})
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
formatter: false,
|
||||
lsp: false,
|
||||
@@ -53,150 +189,88 @@ describe("experimental HttpApi", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const [consoleState, consoleOrgs, toolList, toolIDs, worktrees, resources] = await Promise.all([
|
||||
app().request(ExperimentalPaths.console, { headers }),
|
||||
app().request(ExperimentalPaths.consoleOrgs, { headers }),
|
||||
app().request(`${ExperimentalPaths.tool}?provider=opencode&model=gpt-5`, { headers }),
|
||||
app().request(ExperimentalPaths.toolIDs, { headers }),
|
||||
app().request(ExperimentalPaths.worktree, { headers }),
|
||||
app().request(ExperimentalPaths.resource, { headers }),
|
||||
])
|
||||
it.instance(
|
||||
"serves Console org switch through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const accountID = yield* insertAccount()
|
||||
const switched = yield* request(ExperimentalPaths.consoleSwitch, tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ accountID, orgID: "org-test" }),
|
||||
})
|
||||
|
||||
expect(consoleState.status).toBe(200)
|
||||
expect(await consoleState.json()).toEqual({
|
||||
consoleManagedProviders: [],
|
||||
switchableOrgCount: 0,
|
||||
})
|
||||
|
||||
expect(consoleOrgs.status).toBe(200)
|
||||
expect(await consoleOrgs.json()).toEqual({ orgs: [] })
|
||||
|
||||
expect(toolList.status).toBe(200)
|
||||
expect(await toolList.json()).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "bash",
|
||||
description: expect.any(String),
|
||||
parameters: expect.any(Object),
|
||||
expect(switched.status).toBe(200)
|
||||
expect(yield* json(switched)).toBe(true)
|
||||
}),
|
||||
)
|
||||
{ config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
expect(toolIDs.status).toBe(200)
|
||||
expect(await toolIDs.json()).toContain("bash")
|
||||
it.instance(
|
||||
"serves global session list through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const first = yield* createSession({ title: "page-one" })
|
||||
const second = yield* createSession({ title: "page-two" })
|
||||
yield* setSessionUpdated(first, 1)
|
||||
yield* setSessionUpdated(second, 2)
|
||||
|
||||
expect(worktrees.status).toBe(200)
|
||||
expect(await worktrees.json()).toEqual([])
|
||||
const page = yield* request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.directory, limit: "1" })}`,
|
||||
tmp.directory,
|
||||
)
|
||||
expect(page.status).toBe(200)
|
||||
expect(page.headers.get("x-next-cursor")).toBeTruthy()
|
||||
|
||||
expect(resources.status).toBe(200)
|
||||
expect(await resources.json()).toEqual({})
|
||||
})
|
||||
const body = yield* json<Session.GlobalInfo[]>(page)
|
||||
expect(body.map((session) => session.id)).toEqual([second.id])
|
||||
expect(body[0].project?.id).toBe(second.projectID)
|
||||
|
||||
test("serves Console org switch through the default server app", async () => {
|
||||
await using tmp = await tmpdir({ config: { formatter: false, lsp: false } })
|
||||
Database.Client()
|
||||
.$client.prepare(
|
||||
"INSERT INTO account (id, email, url, access_token, refresh_token, time_created, time_updated) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
)
|
||||
.run(
|
||||
"account-test",
|
||||
"test@example.com",
|
||||
"https://console.example.com",
|
||||
"access",
|
||||
"refresh",
|
||||
Date.now(),
|
||||
Date.now(),
|
||||
)
|
||||
const next = yield* request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({
|
||||
directory: tmp.directory,
|
||||
limit: "10",
|
||||
cursor: body[0].time.updated.toString(),
|
||||
})}`,
|
||||
tmp.directory,
|
||||
)
|
||||
expect(next.status).toBe(200)
|
||||
expect((yield* json<Session.GlobalInfo[]>(next)).map((session) => session.id)).toContain(first.id)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
const switched = await app().request(ExperimentalPaths.consoleSwitch, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ accountID: "account-test", orgID: "org-test" }),
|
||||
})
|
||||
testWorktreeMutations(
|
||||
"serves worktree mutations through the default server app",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
yield* withCreatedWorktree(tmp.directory, (info) =>
|
||||
Effect.gen(function* () {
|
||||
const listed = yield* request(ExperimentalPaths.worktree, tmp.directory)
|
||||
expect(listed.status).toBe(200)
|
||||
expect(yield* json(listed)).toContain(info.directory)
|
||||
|
||||
expect(switched.status).toBe(200)
|
||||
expect(await switched.json()).toBe(true)
|
||||
})
|
||||
const reset = yield* request(ExperimentalPaths.worktreeReset, tmp.directory, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
test("serves global session list through the default server app", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
expect(reset.status).toBe(200)
|
||||
expect(yield* json(reset)).toBe(true)
|
||||
}),
|
||||
)
|
||||
|
||||
const first = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => createSession({ title: "page-one" }),
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, 5))
|
||||
const second = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => createSession({ title: "page-two" }),
|
||||
})
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const page = await app().request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "1" })}`,
|
||||
{ headers },
|
||||
)
|
||||
expect(page.status).toBe(200)
|
||||
expect(page.headers.get("x-next-cursor")).toBeTruthy()
|
||||
|
||||
const body = (await page.json()) as Session.GlobalInfo[]
|
||||
expect(body.map((session) => session.id)).toEqual([second.id])
|
||||
expect(body[0].project?.id).toBe(second.projectID)
|
||||
|
||||
const next = await app().request(
|
||||
`${ExperimentalPaths.session}?${new URLSearchParams({
|
||||
directory: tmp.path,
|
||||
limit: "10",
|
||||
cursor: body[0].time.updated.toString(),
|
||||
})}`,
|
||||
{ headers },
|
||||
)
|
||||
expect(next.status).toBe(200)
|
||||
expect(((await next.json()) as Session.GlobalInfo[]).map((session) => session.id)).toContain(first.id)
|
||||
})
|
||||
|
||||
testWorktreeMutations("serves worktree mutations through the default server app", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const created = await app().request(ExperimentalPaths.worktree, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: "api-test" }),
|
||||
})
|
||||
|
||||
expect(created.status).toBe(200)
|
||||
const info = (await created.json()) as Worktree.Info
|
||||
expect(info).toMatchObject({ name: "api-test", branch: "opencode/api-test" })
|
||||
await waitReady(info.directory)
|
||||
|
||||
const listed = await app().request(ExperimentalPaths.worktree, { headers })
|
||||
expect(listed.status).toBe(200)
|
||||
expect(await listed.json()).toContain(info.directory)
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
const reset = await app().request(ExperimentalPaths.worktreeReset, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
expect(reset.status).toBe(200)
|
||||
expect(await reset.json()).toBe(true)
|
||||
}
|
||||
|
||||
const removed = await app().request(ExperimentalPaths.worktree, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
body: JSON.stringify({ directory: info.directory }),
|
||||
})
|
||||
|
||||
expect(removed.status).toBe(200)
|
||||
expect(await removed.json()).toBe(true)
|
||||
|
||||
const afterRemove = await app().request(ExperimentalPaths.worktree, { headers })
|
||||
expect(afterRemove.status).toBe(200)
|
||||
expect(await afterRemove.json()).toEqual([])
|
||||
})
|
||||
const afterRemove = yield* request(ExperimentalPaths.worktree, tmp.directory)
|
||||
expect(afterRemove.status).toBe(200)
|
||||
expect(yield* json(afterRemove)).toEqual([])
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -3,7 +3,6 @@ import { Effect } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import * as Database from "@/storage/db"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
@@ -11,80 +10,68 @@ import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { MessageID, PartID } from "../../src/session/schema"
|
||||
import { PartTable } from "@/session/session.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
|
||||
const withTmp = <A, E, R>(
|
||||
options: Parameters<typeof tmpdir>[0],
|
||||
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir(options)),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(fn))
|
||||
|
||||
async function seedCorruptStepFinishPart(directory: string) {
|
||||
return WithInstance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
const seedCorruptStepFinishPart = Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
// Schema.Finite still rejects NaN at encode: exact mirror of the corrupt row
|
||||
// that broke the user's session in the OMO/Windows bug.
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
// Schema.Finite still rejects NaN at encode — exact mirror of the
|
||||
// corrupt row that broke the user's session in the OMO/Windows bug.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never, // drizzle's .set() can't narrow the discriminated union
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
return info.id
|
||||
}).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
})
|
||||
}
|
||||
tokens: { input: 0, output: NaN, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never, // drizzle's .set() can't narrow the discriminated union
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
return info.id
|
||||
})
|
||||
|
||||
describe("schema-rejection wire shape", () => {
|
||||
it.live(
|
||||
it.instance(
|
||||
"Payload schema rejection returns NamedError-shaped JSON, not empty",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
}),
|
||||
)
|
||||
@@ -99,36 +86,38 @@ describe("schema-rejection wire shape", () => {
|
||||
expect(parsed.data.message).toEqual(expect.any(String))
|
||||
expect(parsed.data.message.length).toBeGreaterThan(0)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"Query schema rejection returns NamedError-shaped JSON",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
// /find/file?limit=999999 violates the limit constraint check.
|
||||
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(tmp.path)}`
|
||||
const url = `/find/file?query=foo&limit=999999&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
expect(res.status).toBe(400)
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed).toMatchObject({ name: "BadRequest", data: { kind: "Query" } })
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"rejected request body never echoes back unbounded — message is capped",
|
||||
// Defense against DoS-amplification + secret-echo: Effect's Issue formatter
|
||||
// dumps the rejected `actual` verbatim. A multi-MB invalid array would
|
||||
// become a multi-MB 400 response and log line. Cap kicks in around 1KB.
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const huge = "X".repeat(50_000)
|
||||
const res = yield* Effect.promise(async () =>
|
||||
Server.Default().app.request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: huge }),
|
||||
}),
|
||||
)
|
||||
@@ -139,15 +128,16 @@ describe("schema-rejection wire shape", () => {
|
||||
const parsed = JSON.parse(body)
|
||||
expect(parsed.data.message).not.toContain(huge)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"response-encode failure: corrupted stored row returns NamedError-shaped JSON with field path",
|
||||
withTmp({ config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = yield* Effect.promise(() => seedCorruptStepFinishPart(tmp.path))
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedCorruptStepFinishPart
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
const body = yield* Effect.promise(async () => res.text())
|
||||
expect(res.status).toBe(400)
|
||||
@@ -157,6 +147,6 @@ describe("schema-rejection wire shape", () => {
|
||||
// Field path in data.message — what made this PR worth shipping.
|
||||
expect(parsed.data.message).toMatch(/output/)
|
||||
}),
|
||||
),
|
||||
{ config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -2,11 +2,14 @@ import { afterEach, describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect, Layer } from "effect"
|
||||
import type * as Scope from "effect/Scope"
|
||||
import { HttpRouter } from "effect/unstable/http"
|
||||
import { ChildProcessSpawner } from "effect/unstable/process"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { createOpencodeClient } from "@opencode-ai/sdk/v2"
|
||||
import { validateSession } from "../../src/cli/cmd/tui/validate-session"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||
@@ -18,8 +21,17 @@ import { errorMessage } from "../../src/util/error"
|
||||
import { TestLLMServer } from "../lib/llm-server"
|
||||
import path from "path"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { disposeAllInstances, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const noopBootstrap = Layer.succeed(InstanceBootstrap.Service, InstanceBootstrap.Service.of({ run: Effect.void }))
|
||||
const it = testEffect(
|
||||
Layer.mergeAll(
|
||||
AppFileSystem.defaultLayer,
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
InstanceStore.defaultLayer.pipe(Layer.provide(noopBootstrap)),
|
||||
),
|
||||
)
|
||||
|
||||
const original = {
|
||||
OPENCODE_SERVER_PASSWORD: Flag.OPENCODE_SERVER_PASSWORD,
|
||||
@@ -32,6 +44,8 @@ type SdkResult = { response: Response; data?: unknown; error?: unknown }
|
||||
type Captured = { status: number; data?: unknown; error?: unknown }
|
||||
type ProjectFixture = { sdk: Sdk; directory: string }
|
||||
type LlmProjectFixture = ProjectFixture & { llm: TestLLMServer["Service"] }
|
||||
type TestServices = AppFileSystem.Service | ChildProcessSpawner.ChildProcessSpawner | InstanceStore.Service
|
||||
type TestScope = Scope.Scope | TestServices
|
||||
|
||||
function app(serverPath: ServerPath, input?: { password?: string; username?: string }) {
|
||||
Flag.OPENCODE_SERVER_PASSWORD = input?.password
|
||||
@@ -149,12 +163,27 @@ function expectStatus(request: () => Promise<{ response: Response }>, status: nu
|
||||
)
|
||||
}
|
||||
|
||||
function firstEvent(open: () => Promise<{ stream: AsyncIterator<unknown> }>) {
|
||||
return Effect.acquireRelease(call(open), (events) =>
|
||||
call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
|
||||
function firstEvent(open: (signal: AbortSignal) => Promise<{ stream: AsyncIterator<unknown> }>) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.sync(() => new AbortController()),
|
||||
(controller) => Effect.sync(() => controller.abort()),
|
||||
).pipe(
|
||||
Effect.flatMap((events) => call(() => events.stream.next())),
|
||||
Effect.map((result) => result.value),
|
||||
Effect.flatMap((controller) =>
|
||||
Effect.acquireRelease(
|
||||
call(() => open(controller.signal)),
|
||||
(events) => call(async () => void (await events.stream.return?.(undefined))).pipe(Effect.ignore),
|
||||
).pipe(
|
||||
Effect.flatMap((events) =>
|
||||
call(() => events.stream.next()).pipe(
|
||||
Effect.timeoutOrElse({
|
||||
duration: "1 second",
|
||||
orElse: () => Effect.fail(new Error("timed out waiting for SDK event")),
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.map((result) => result.value),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -188,11 +217,32 @@ function resetState() {
|
||||
})
|
||||
}
|
||||
|
||||
function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, Scope.Scope>) {
|
||||
function httpapi<A, E>(name: string, effect: Effect.Effect<A, E, TestScope>) {
|
||||
it.live(name, effect)
|
||||
}
|
||||
|
||||
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, Scope.Scope>) {
|
||||
function httpapiInstance<A, E>(
|
||||
name: string,
|
||||
options: {
|
||||
serverPath: ServerPath
|
||||
git?: boolean
|
||||
config?: Partial<Config.Info>
|
||||
setup?: (dir: string) => Effect.Effect<void, E, TestServices>
|
||||
},
|
||||
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||
) {
|
||||
it.instance(
|
||||
name,
|
||||
Effect.gen(function* () {
|
||||
const instance = yield* TestInstance
|
||||
yield* options.setup?.(instance.directory) ?? Effect.void
|
||||
return yield* run({ sdk: client(options.serverPath, instance.directory), directory: instance.directory })
|
||||
}),
|
||||
{ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } },
|
||||
)
|
||||
}
|
||||
|
||||
function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath) => Effect.Effect<A, E, TestScope>) {
|
||||
it.live(
|
||||
name,
|
||||
Effect.gen(function* () {
|
||||
@@ -204,35 +254,43 @@ function serverPathParity<A, E>(name: string, scenario: (serverPath: ServerPath)
|
||||
)
|
||||
}
|
||||
|
||||
function withProject<A, E, R>(
|
||||
function withProject<A, E, E2 = never>(
|
||||
serverPath: ServerPath,
|
||||
options: { git?: boolean; config?: Partial<Config.Info>; setup?: (dir: string) => Effect.Effect<void> },
|
||||
run: (input: ProjectFixture) => Effect.Effect<A, E, R>,
|
||||
options: {
|
||||
git?: boolean
|
||||
config?: Partial<Config.Info>
|
||||
setup?: (dir: string) => Effect.Effect<void, E2, TestServices>
|
||||
},
|
||||
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
call(() => tmpdir({ git: options.git ?? true, config: { formatter: false, lsp: false, ...options.config } })),
|
||||
(tmp) => call(() => tmp[Symbol.asyncDispose]()).pipe(Effect.ignore),
|
||||
).pipe(
|
||||
Effect.tap((tmp) => options.setup?.(tmp.path) ?? Effect.void),
|
||||
Effect.flatMap((tmp) => run({ sdk: client(serverPath, tmp.path), directory: tmp.path })),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
const directory = yield* tmpdirScoped({
|
||||
git: options.git ?? true,
|
||||
config: { formatter: false, lsp: false, ...options.config },
|
||||
})
|
||||
yield* options.setup?.(directory) ?? Effect.void
|
||||
return yield* run({ sdk: client(serverPath, directory), directory })
|
||||
})
|
||||
}
|
||||
|
||||
function withStandardProject<A, E, R>(serverPath: ServerPath, run: (input: ProjectFixture) => Effect.Effect<A, E, R>) {
|
||||
function withStandardProject<A, E>(
|
||||
serverPath: ServerPath,
|
||||
run: (input: ProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||
) {
|
||||
return withProject(serverPath, { setup: writeStandardFiles }, run)
|
||||
}
|
||||
|
||||
function withFakeLlm<A, E, R>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>) {
|
||||
function withFakeLlm<A, E>(serverPath: ServerPath, run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>) {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
return yield* withProject(serverPath, { config: providerConfig(llm.url) }, (input) => run({ ...input, llm }))
|
||||
}).pipe(Effect.provide(TestLLMServer.layer))
|
||||
}
|
||||
|
||||
function withFakeLlmProject<A, E, R>(
|
||||
function withFakeLlmProject<A, E>(
|
||||
serverPath: ServerPath,
|
||||
options: { setup?: (dir: string) => Effect.Effect<void> },
|
||||
run: (input: LlmProjectFixture) => Effect.Effect<A, E, R>,
|
||||
options: { setup?: (dir: string) => Effect.Effect<void, E, TestServices> },
|
||||
run: (input: LlmProjectFixture) => Effect.Effect<A, E, TestScope>,
|
||||
) {
|
||||
return Effect.gen(function* () {
|
||||
const llm = yield* TestLLMServer
|
||||
@@ -248,15 +306,17 @@ function withFakeLlmProject<A, E, R>(
|
||||
}
|
||||
|
||||
function writeStandardFiles(dir: string) {
|
||||
return Effect.all([
|
||||
call(() => Bun.write(path.join(dir, "hello.txt"), "hello")),
|
||||
call(() => Bun.write(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n")),
|
||||
]).pipe(Effect.asVoid)
|
||||
return AppFileSystem.Service.use((fs) =>
|
||||
Effect.all([
|
||||
fs.writeWithDirs(path.join(dir, "hello.txt"), "hello"),
|
||||
fs.writeWithDirs(path.join(dir, "needle.ts"), "export const needle = 'sdk-parity'\n"),
|
||||
]).pipe(Effect.asVoid),
|
||||
)
|
||||
}
|
||||
|
||||
function writeProjectSkill(dir: string) {
|
||||
return call(() =>
|
||||
Bun.write(
|
||||
return AppFileSystem.Service.use((fs) =>
|
||||
fs.writeWithDirs(
|
||||
path.join(dir, ".opencode", "skills", "project-rest-skill", "SKILL.md"),
|
||||
`---
|
||||
name: project-rest-skill
|
||||
@@ -266,40 +326,36 @@ description: A project skill visible to REST API prompts.
|
||||
# Project REST Skill
|
||||
`,
|
||||
),
|
||||
).pipe(Effect.asVoid)
|
||||
)
|
||||
}
|
||||
|
||||
function seedMessage(directory: string, sessionID: string) {
|
||||
const id = SessionID.make(sessionID)
|
||||
return call(
|
||||
async () =>
|
||||
await WithInstance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
SessionNs.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const message = yield* svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
tools: {},
|
||||
} satisfies MessageV2.User)
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: id,
|
||||
messageID: message.id,
|
||||
type: "text",
|
||||
text: "seeded message",
|
||||
})
|
||||
return { message, part }
|
||||
}),
|
||||
).pipe(Effect.provide(SessionNs.defaultLayer)),
|
||||
),
|
||||
}),
|
||||
return InstanceStore.Service.use((store) =>
|
||||
store.provide(
|
||||
{ directory },
|
||||
SessionNs.Service.use((svc) =>
|
||||
Effect.gen(function* () {
|
||||
const message = yield* svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
sessionID: id,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: "test",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
tools: {},
|
||||
} satisfies MessageV2.User)
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID: id,
|
||||
messageID: message.id,
|
||||
type: "text",
|
||||
text: "seeded message",
|
||||
})
|
||||
return { message, part }
|
||||
}),
|
||||
).pipe(Effect.provide(SessionNs.defaultLayer)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -320,7 +376,7 @@ describe("HttpApi SDK", () => {
|
||||
|
||||
expect(health.response.status).toBe(200)
|
||||
expect(health.data).toMatchObject({ healthy: true })
|
||||
expect(yield* firstEvent(() => sdk.global.event({ signal: AbortSignal.timeout(1_000) }))).toMatchObject({
|
||||
expect(yield* firstEvent((signal) => sdk.global.event({ signal }))).toMatchObject({
|
||||
payload: { type: "server.connected" },
|
||||
})
|
||||
expect(log.response.status).toBe(200)
|
||||
@@ -329,9 +385,10 @@ describe("HttpApi SDK", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
httpapi(
|
||||
httpapiInstance(
|
||||
"uses the generated SDK for safe instance routes",
|
||||
withProject("raw", { git: false, setup: writeStandardFiles }, ({ sdk }) =>
|
||||
{ serverPath: "raw", git: false, setup: writeStandardFiles },
|
||||
({ sdk }) =>
|
||||
Effect.gen(function* () {
|
||||
const file = yield* call(() => sdk.file.read({ path: "hello.txt" }))
|
||||
const session = yield* call(() => sdk.session.create({ title: "sdk" }))
|
||||
@@ -351,7 +408,6 @@ describe("HttpApi SDK", () => {
|
||||
expectStatus(() => sdk.find.files({ query: "hello", limit: 10 }), 200),
|
||||
])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global and control behavior", (serverPath) =>
|
||||
@@ -370,14 +426,14 @@ describe("HttpApi SDK", () => {
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK global event stream", (serverPath) =>
|
||||
firstEvent(() => client(serverPath).global.event({ signal: AbortSignal.timeout(1_000) })).pipe(
|
||||
firstEvent((signal) => client(serverPath).global.event({ signal })).pipe(
|
||||
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK instance event stream", (serverPath) =>
|
||||
withStandardProject(serverPath, ({ sdk }) =>
|
||||
firstEvent(() => sdk.event.subscribe(undefined, { signal: AbortSignal.timeout(1_000) })).pipe(
|
||||
firstEvent((signal) => sdk.event.subscribe(undefined, { signal })).pipe(
|
||||
Effect.map((event) => ({ type: record(record(event).payload).type })),
|
||||
),
|
||||
),
|
||||
@@ -431,9 +487,10 @@ describe("HttpApi SDK", () => {
|
||||
),
|
||||
)
|
||||
|
||||
httpapi(
|
||||
httpapiInstance(
|
||||
"uses generated SDK basic auth behavior",
|
||||
withStandardProject("raw", ({ directory }) =>
|
||||
{ serverPath: "raw", setup: writeStandardFiles },
|
||||
({ directory }) =>
|
||||
Effect.gen(function* () {
|
||||
const missing = yield* capture(() =>
|
||||
client("raw", directory, { password: "secret" }).file.read({ path: "hello.txt" }),
|
||||
@@ -456,7 +513,6 @@ describe("HttpApi SDK", () => {
|
||||
content: record(good.data).content,
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
serverPathParity("matches generated SDK instance read routes", (serverPath) =>
|
||||
|
||||
@@ -8,8 +8,8 @@ import type { WorkspaceAdapter } from "../../src/control-plane/types"
|
||||
import { Workspace } from "../../src/control-plane/workspace"
|
||||
import { PermissionID } from "../../src/permission/schema"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { InstanceBootstrap } from "../../src/project/bootstrap"
|
||||
import { InstanceBootstrap as InstanceBootstrapService } from "../../src/project/bootstrap-service"
|
||||
import { InstanceStore } from "../../src/project/instance-store"
|
||||
import { Project } from "../../src/project/project"
|
||||
import { Server } from "../../src/server/server"
|
||||
@@ -25,8 +25,8 @@ import * as DateTime from "effect/DateTime"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -35,58 +35,45 @@ const workspaceLayer = Workspace.defaultLayer.pipe(
|
||||
Layer.provide(InstanceStore.defaultLayer),
|
||||
Layer.provide(InstanceBootstrap.defaultLayer),
|
||||
)
|
||||
const instanceStoreLayer = InstanceStore.defaultLayer.pipe(
|
||||
Layer.provide(
|
||||
Layer.succeed(InstanceBootstrapService.Service, InstanceBootstrapService.Service.of({ run: Effect.void })),
|
||||
),
|
||||
)
|
||||
const it = testEffect(Layer.mergeAll(instanceStoreLayer, Project.defaultLayer, Session.defaultLayer, workspaceLayer))
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
function pathFor(path: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
|
||||
}
|
||||
|
||||
function createSession(directory: string, input?: Session.CreateInput) {
|
||||
return Effect.promise(
|
||||
async () =>
|
||||
await WithInstance.provide({
|
||||
directory,
|
||||
fn: () => runSession(Session.Service.use((svc) => svc.create(input))),
|
||||
}),
|
||||
)
|
||||
function createSession(input?: Session.CreateInput) {
|
||||
return Session.Service.use((svc) => svc.create(input))
|
||||
}
|
||||
|
||||
function createTextMessage(directory: string, sessionID: SessionIDType, text: string) {
|
||||
return Effect.promise(
|
||||
async () =>
|
||||
await WithInstance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
runSession(
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Session.Service
|
||||
const info = yield* svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: info.id,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return { info, part }
|
||||
}),
|
||||
),
|
||||
}),
|
||||
)
|
||||
function createTextMessage(sessionID: SessionIDType, text: string) {
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* Session.Service
|
||||
const info = yield* svc.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const part = yield* svc.updatePart({
|
||||
id: PartID.ascending(),
|
||||
sessionID,
|
||||
messageID: info.id,
|
||||
type: "text",
|
||||
text,
|
||||
})
|
||||
return { info, part }
|
||||
})
|
||||
}
|
||||
|
||||
const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
@@ -101,18 +88,88 @@ const localAdapter = (directory: string): WorkspaceAdapter => ({
|
||||
})
|
||||
|
||||
const createLocalWorkspace = (input: { projectID: Project.Info["id"]; type: string; directory: string }) =>
|
||||
Effect.gen(function* () {
|
||||
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
|
||||
return yield* Workspace.Service.use((svc) =>
|
||||
svc.create({
|
||||
type: input.type,
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: input.projectID,
|
||||
}),
|
||||
).pipe(Effect.provide(workspaceLayer))
|
||||
Effect.acquireRelease(
|
||||
Effect.gen(function* () {
|
||||
registerAdapter(input.projectID, input.type, localAdapter(input.directory))
|
||||
return yield* Workspace.Service.use((svc) =>
|
||||
svc.create({
|
||||
type: input.type,
|
||||
branch: null,
|
||||
extra: null,
|
||||
projectID: input.projectID,
|
||||
}),
|
||||
)
|
||||
}),
|
||||
(info) => Workspace.Service.use((svc) => svc.remove(info.id)).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const insertLegacyAssistantMessage = (sessionID: SessionIDType) =>
|
||||
Effect.sync(() => {
|
||||
const message = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: {
|
||||
id: Modelv2.ID.make("model"),
|
||||
providerID: Modelv2.ProviderID.make("provider"),
|
||||
variant: Modelv2.VariantID.make("default"),
|
||||
},
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
content: [],
|
||||
})
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: message.id,
|
||||
session_id: sessionID,
|
||||
type: message.type,
|
||||
time_created: 1,
|
||||
data: {
|
||||
time: { created: 1 },
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
content: message.content,
|
||||
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
])
|
||||
.run(),
|
||||
)
|
||||
})
|
||||
|
||||
const setLegacySummaryDiff = (sessionID: SessionIDType) =>
|
||||
Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
summary_additions: 1,
|
||||
summary_deletions: 0,
|
||||
summary_files: 1,
|
||||
summary_diffs: [{ additions: 1, deletions: 0 }],
|
||||
})
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
|
||||
const getWorkspaceID = (sessionID: SessionIDType) =>
|
||||
Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get(),
|
||||
),
|
||||
)
|
||||
|
||||
const clearSessionPath = (sessionID: SessionIDType) =>
|
||||
Effect.sync(() =>
|
||||
Database.use((db) => db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, sessionID)).run()),
|
||||
)
|
||||
|
||||
function request(path: string, init?: RequestInit) {
|
||||
return Effect.promise(async () => app().request(path, init))
|
||||
}
|
||||
@@ -132,16 +189,6 @@ function requestJson<T>(path: string, init?: RequestInit) {
|
||||
return request(path, init).pipe(Effect.flatMap(json<T>))
|
||||
}
|
||||
|
||||
function withTmp<A, E, R>(
|
||||
options: Parameters<typeof tmpdir>[0],
|
||||
fn: (tmp: Awaited<ReturnType<typeof tmpdir>>) => Effect.Effect<A, E, R>,
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir(options)),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(Effect.flatMap(fn))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
await disposeAllInstances()
|
||||
@@ -149,11 +196,12 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("session HttpApi", () => {
|
||||
it.live(
|
||||
it.instance(
|
||||
"returns declared not found errors for read routes",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const missingSession = SessionID.descending()
|
||||
const missingSessionBody = {
|
||||
name: "NotFoundError",
|
||||
@@ -175,7 +223,7 @@ describe("session HttpApi", () => {
|
||||
expect(remove.status).toBe(404)
|
||||
expect(yield* responseJson(remove)).toEqual(missingSessionBody)
|
||||
|
||||
const session = yield* createSession(tmp.path, { title: "missing message" })
|
||||
const session = yield* createSession({ title: "missing message" })
|
||||
const missingMessage = MessageID.ascending()
|
||||
const message = yield* request(
|
||||
pathFor(SessionPaths.message, { sessionID: session.id, messageID: missingMessage }),
|
||||
@@ -187,18 +235,19 @@ describe("session HttpApi", () => {
|
||||
data: { message: `Message not found: ${missingMessage}` },
|
||||
})
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"serves read routes",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const parent = yield* createSession(tmp.path, { title: "parent" })
|
||||
const child = yield* createSession(tmp.path, { title: "child", parentID: parent.id })
|
||||
const message = yield* createTextMessage(tmp.path, parent.id, "hello")
|
||||
yield* createTextMessage(tmp.path, parent.id, "world")
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const parent = yield* createSession({ title: "parent" })
|
||||
const child = yield* createSession({ title: "child", parentID: parent.id })
|
||||
const message = yield* createTextMessage(parent.id, "hello")
|
||||
yield* createTextMessage(parent.id, "world")
|
||||
|
||||
const listed = yield* requestJson<Session.Info[]>(`${SessionPaths.list}?roots=true`, { headers })
|
||||
expect(listed.map((item) => item.id)).toContain(parent.id)
|
||||
@@ -250,88 +299,40 @@ describe("session HttpApi", () => {
|
||||
),
|
||||
).toMatchObject({ info: { id: message.info.id } })
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const message = new SessionMessage.Assistant({
|
||||
id: SessionMessage.ID.create(),
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: {
|
||||
id: Modelv2.ID.make("model"),
|
||||
providerID: Modelv2.ProviderID.make("provider"),
|
||||
variant: Modelv2.VariantID.make("default"),
|
||||
},
|
||||
time: { created: DateTime.makeUnsafe(1) },
|
||||
content: [],
|
||||
})
|
||||
Database.use((db) =>
|
||||
db
|
||||
.insert(SessionMessageTable)
|
||||
.values([
|
||||
{
|
||||
id: message.id,
|
||||
session_id: parent.id,
|
||||
type: message.type,
|
||||
time_created: 1,
|
||||
data: {
|
||||
time: { created: 1 },
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
content: message.content,
|
||||
} as NonNullable<(typeof SessionMessageTable.$inferInsert)["data"]>,
|
||||
},
|
||||
])
|
||||
.run(),
|
||||
)
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* insertLegacyAssistantMessage(parent.id)
|
||||
|
||||
expect(
|
||||
(yield* requestJson<{ items: SessionMessage.Message[] }>(`/api/session/${parent.id}/message`, { headers }))
|
||||
.items,
|
||||
).toMatchObject([{ type: "assistant" }])
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"serves sessions with migrated summary diffs missing file details",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* createSession(tmp.path, { title: "legacy diff" })
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(SessionTable)
|
||||
.set({
|
||||
summary_additions: 1,
|
||||
summary_deletions: 0,
|
||||
summary_files: 1,
|
||||
summary_diffs: [{ additions: 1, deletions: 0 }],
|
||||
})
|
||||
.where(eq(SessionTable.id, session.id))
|
||||
.run(),
|
||||
),
|
||||
)
|
||||
const test = yield* TestInstance
|
||||
const session = yield* createSession({ title: "legacy diff" })
|
||||
yield* setLegacySummaryDiff(session.id)
|
||||
|
||||
const response = yield* request(pathFor(SessionPaths.get, { sessionID: session.id }), {
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect((yield* json<Session.Info>(response)).summary?.diffs).toEqual([{ additions: 1, deletions: 0 }])
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"serves lifecycle mutation routes",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
||||
|
||||
const createdEmpty = yield* requestJson<Session.Info>(SessionPaths.create, {
|
||||
method: "POST",
|
||||
@@ -373,56 +374,48 @@ describe("session HttpApi", () => {
|
||||
}),
|
||||
).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false, share: "disabled" } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"persists selected workspace id when creating a session",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const project = yield* Project.use.fromDirectory(tmp.path).pipe(Effect.provide(Project.defaultLayer))
|
||||
const project = yield* Project.use.fromDirectory(test.directory)
|
||||
const workspace = yield* createLocalWorkspace({
|
||||
projectID: project.project.id,
|
||||
type: "session-create-workspace",
|
||||
directory: path.join(tmp.path, ".workspace-local"),
|
||||
directory: path.join(test.directory, ".workspace-local"),
|
||||
})
|
||||
|
||||
const created = yield* requestJson<Session.Info>(`${SessionPaths.create}?workspace=${workspace.id}`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
headers: { "x-opencode-directory": test.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ title: "workspace session" }),
|
||||
})
|
||||
const messages = yield* request(
|
||||
`${pathFor(SessionPaths.messages, { sessionID: created.id })}?workspace=${workspace.id}`,
|
||||
{
|
||||
headers: { "x-opencode-directory": tmp.path },
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
},
|
||||
)
|
||||
|
||||
expect(created).toMatchObject({ id: created.id, workspaceID: workspace.id })
|
||||
expect(messages.status).toBe(200)
|
||||
expect(
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db
|
||||
.select({ workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, created.id))
|
||||
.get(),
|
||||
),
|
||||
),
|
||||
).toEqual({ workspaceID: workspace.id })
|
||||
expect(yield* getWorkspaceID(created.id)).toEqual({ workspaceID: workspace.id })
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false, share: "disabled" } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"validates archived timestamp values",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const session = yield* createSession(tmp.path, { title: "archived" })
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
||||
const session = yield* createSession({ title: "archived" })
|
||||
const body = JSON.stringify({ time: { archived: -1 } })
|
||||
|
||||
const response = yield* request(pathFor(SessionPaths.update, { sessionID: session.id }), {
|
||||
@@ -433,30 +426,35 @@ describe("session HttpApi", () => {
|
||||
expect(response.status).toBe(200)
|
||||
expect((yield* json<Session.Info>(response)).time.archived).toBe(-1)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"uses project-scoped path and directory precedence",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const currentDir = path.join(tmp.path, "packages", "opencode", "src")
|
||||
const test = yield* TestInstance
|
||||
const currentDir = path.join(test.directory, "packages", "opencode", "src")
|
||||
yield* Effect.promise(() => mkdir(currentDir, { recursive: true }))
|
||||
|
||||
const pathSession = yield* createSession(currentDir)
|
||||
const pathlessSession = yield* createSession(currentDir)
|
||||
yield* Effect.sync(() =>
|
||||
Database.use((db) =>
|
||||
db.update(SessionTable).set({ path: null }).where(eq(SessionTable.id, pathlessSession.id)).run(),
|
||||
),
|
||||
const store = yield* InstanceStore.Service
|
||||
const { pathSession, pathlessSession } = yield* store.provide(
|
||||
{ directory: currentDir },
|
||||
Effect.gen(function* () {
|
||||
return {
|
||||
pathSession: yield* createSession(),
|
||||
pathlessSession: yield* createSession(),
|
||||
}
|
||||
}).pipe(Effect.provideService(TestInstance, { directory: currentDir }), Effect.provide(Session.defaultLayer)),
|
||||
)
|
||||
yield* clearSessionPath(pathlessSession.id)
|
||||
|
||||
const query = new URLSearchParams({
|
||||
scope: "project",
|
||||
path: "packages/opencode/src",
|
||||
directory: currentDir,
|
||||
})
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const sessions = (yield* json<Session.Info[]>(
|
||||
yield* request(`${SessionPaths.list}?${query}`, { headers }),
|
||||
)).map((item) => item.id)
|
||||
@@ -464,17 +462,18 @@ describe("session HttpApi", () => {
|
||||
expect(sessions).toContain(pathSession.id)
|
||||
expect(sessions).not.toContain(pathlessSession.id)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"serves paginated message link headers",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const session = yield* createSession(tmp.path, { title: "messages" })
|
||||
yield* createTextMessage(tmp.path, session.id, "first")
|
||||
yield* createTextMessage(tmp.path, session.id, "second")
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory }
|
||||
const session = yield* createSession({ title: "messages" })
|
||||
yield* createTextMessage(session.id, "first")
|
||||
yield* createTextMessage(session.id, "second")
|
||||
const route = `${pathFor(SessionPaths.messages, { sessionID: session.id })}?limit=1`
|
||||
|
||||
const response = yield* request(route, { headers })
|
||||
@@ -483,17 +482,18 @@ describe("session HttpApi", () => {
|
||||
expect(response.headers.get("link")).toContain("limit=1")
|
||||
expect(response.headers.get("access-control-expose-headers")?.toLowerCase()).toContain("x-next-cursor")
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"serves message mutation routes",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const session = yield* createSession(tmp.path, { title: "messages" })
|
||||
const first = yield* createTextMessage(tmp.path, session.id, "first")
|
||||
const second = yield* createTextMessage(tmp.path, session.id, "second")
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
||||
const session = yield* createSession({ title: "messages" })
|
||||
const first = yield* createTextMessage(session.id, "first")
|
||||
const second = yield* createTextMessage(session.id, "second")
|
||||
|
||||
const updated = yield* requestJson<MessageV2.Part>(
|
||||
pathFor(SessionPaths.updatePart, {
|
||||
@@ -527,15 +527,16 @@ describe("session HttpApi", () => {
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
it.live(
|
||||
it.instance(
|
||||
"serves remaining non-LLM session mutation routes",
|
||||
withTmp({ git: true, config: { formatter: false, lsp: false } }, (tmp) =>
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const session = yield* createSession(tmp.path, { title: "remaining" })
|
||||
const test = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": test.directory, "content-type": "application/json" }
|
||||
const session = yield* createSession({ title: "remaining" })
|
||||
|
||||
expect(
|
||||
yield* requestJson<Session.Info>(pathFor(SessionPaths.revert, { sessionID: session.id }), {
|
||||
@@ -566,6 +567,6 @@ describe("session HttpApi", () => {
|
||||
),
|
||||
).toBe(true)
|
||||
}),
|
||||
),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,29 +1,25 @@
|
||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock, spyOn } from "bun:test"
|
||||
import { Context, Effect } from "effect"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SyncPaths } from "../../src/server/routes/instance/httpapi/groups/sync"
|
||||
import { ExperimentalHttpApiServer } from "../../src/server/routes/instance/httpapi/server"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const originalWorkspaces = Flag.OPENCODE_EXPERIMENTAL_WORKSPACES
|
||||
const context = Context.empty() as Context.Context<unknown>
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
function app() {
|
||||
return Server.Default().app
|
||||
}
|
||||
|
||||
function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = originalWorkspaces
|
||||
@@ -32,111 +28,138 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("sync HttpApi", () => {
|
||||
test("serves sync routes", async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const info = spyOn(Log.create({ service: "server.sync" }), "info")
|
||||
it.instance(
|
||||
"serves sync routes",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_WORKSPACES = true
|
||||
const tmp = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
|
||||
const info = spyOn(Log.create({ service: "server.sync" }), "info")
|
||||
const session = yield* Session.Service.use((svc) => svc.create({ title: "sync" }))
|
||||
|
||||
const session = await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => runSession(Session.Service.use((svc) => svc.create({ title: "sync" }))),
|
||||
})
|
||||
const started = yield* Effect.promise(() =>
|
||||
Promise.resolve(app().request(SyncPaths.start, { method: "POST", headers })),
|
||||
)
|
||||
expect(started.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => started.json())).toBe(true)
|
||||
|
||||
const started = await app().request(SyncPaths.start, { method: "POST", headers })
|
||||
expect(started.status).toBe(200)
|
||||
expect(await started.json()).toBe(true)
|
||||
const history = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (yield* Effect.promise(() => history.json())) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const history = await app().request(SyncPaths.history, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({}),
|
||||
})
|
||||
expect(history.status).toBe(200)
|
||||
const rows = (await history.json()) as Array<{
|
||||
id: string
|
||||
aggregate_id: string
|
||||
seq: number
|
||||
type: string
|
||||
data: Record<string, unknown>
|
||||
}>
|
||||
expect(rows.map((row) => row.aggregate_id)).toContain(session.id)
|
||||
|
||||
const replayed = await app().request(SyncPaths.replay, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.path,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
const replayed = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(SyncPaths.replay, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
directory: tmp.directory,
|
||||
events: rows
|
||||
.filter((row) => row.aggregate_id === session.id)
|
||||
.map((row) => ({
|
||||
id: row.id,
|
||||
aggregateID: row.aggregate_id,
|
||||
seq: row.seq,
|
||||
type: row.type,
|
||||
data: row.data,
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(yield* Effect.promise(() => replayed.json())).toEqual({ sessionID: session.id })
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
|
||||
}),
|
||||
})
|
||||
expect(replayed.status).toBe(200)
|
||||
expect(await replayed.json()).toEqual({ sessionID: session.id })
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay requested")).toBe(true)
|
||||
expect(info.mock.calls.some(([message]) => message === "sync replay complete")).toBe(true)
|
||||
})
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
test("validates seq values", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
|
||||
const cases = [
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: -1 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: 1.5 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.path,
|
||||
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.path,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
]
|
||||
it.instance(
|
||||
"validates seq values",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const headers = { "x-opencode-directory": tmp.directory, "content-type": "application/json" }
|
||||
const cases = [
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: -1 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.history,
|
||||
body: { aggregate: 1.5 },
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: -1, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: SyncPaths.replay,
|
||||
body: {
|
||||
directory: tmp.directory,
|
||||
events: [{ id: "event", aggregateID: "session", seq: 1.5, type: "session.created", data: {} }],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
for (const item of cases) {
|
||||
const response = await app().request(item.path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
})
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
test.todo("returns structured validation errors", async () => {
|
||||
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
|
||||
const response = await ExperimentalHttpApiServer.webHandler().handler(
|
||||
new Request(`http://localhost${SyncPaths.history}`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.path, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
for (const item of cases) {
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app().request(item.path, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(item.body),
|
||||
}),
|
||||
),
|
||||
)
|
||||
expect(response.status).toBe(400)
|
||||
}
|
||||
}),
|
||||
context,
|
||||
)
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.headers.get("content-type") ?? "").toContain("application/json")
|
||||
const body = (await response.json()) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
|
||||
})
|
||||
it.instance.skip(
|
||||
"returns structured validation errors",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const response = yield* Effect.promise(() =>
|
||||
ExperimentalHttpApiServer.webHandler().handler(
|
||||
new Request(`http://localhost${SyncPaths.history}`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": tmp.directory, "content-type": "application/json" },
|
||||
body: JSON.stringify({ aggregate: -1 }),
|
||||
}),
|
||||
context,
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(response.headers.get("content-type") ?? "").toContain("application/json")
|
||||
const body = (yield* Effect.promise(() => response.json())) as Record<string, unknown>
|
||||
expect(body.success).toBe(false)
|
||||
expect(Array.isArray(body.error) || Array.isArray(body.errors)).toBe(true)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -5,11 +5,10 @@
|
||||
// negative. The pre-fix `safe()` clamp only guarded against non-finite. The
|
||||
// strict `NonNegativeInt` schema then made every load of the message list
|
||||
// fail to encode, killing Desktop boot for every user with such a row.
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { SessionPaths } from "../../src/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
@@ -17,81 +16,66 @@ import { MessageID, PartID } from "../../src/session/schema"
|
||||
import * as Database from "@/storage/db"
|
||||
import { PartTable } from "@/session/session.sql"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
})
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
function seedNegativeTokenSession(directory: string) {
|
||||
return Effect.promise(async () =>
|
||||
WithInstance.provide({
|
||||
directory,
|
||||
fn: () =>
|
||||
Effect.runPromise(
|
||||
Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
function seedNegativeTokenSession() {
|
||||
return Effect.gen(function* () {
|
||||
const session = yield* Session.Service
|
||||
const info = yield* session.create({})
|
||||
const message = yield* session.updateMessage({
|
||||
id: MessageID.ascending(),
|
||||
role: "user",
|
||||
sessionID: info.id,
|
||||
agent: "build",
|
||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||
time: { created: Date.now() },
|
||||
})
|
||||
const partID = PartID.ascending()
|
||||
yield* session.updatePart({
|
||||
id: partID,
|
||||
sessionID: info.id,
|
||||
messageID: message.id,
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
})
|
||||
|
||||
// Bypass the schema with a direct SQL update to install the
|
||||
// negative `output` value we want to test loading.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
// Bypass the schema with a direct SQL update to install the
|
||||
// negative `output` value we want to test loading.
|
||||
Database.use((db) =>
|
||||
db
|
||||
.update(PartTable)
|
||||
.set({
|
||||
data: {
|
||||
type: "step-finish",
|
||||
reason: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: -42, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
} as never,
|
||||
})
|
||||
.where(eq(PartTable.id, partID))
|
||||
.run(),
|
||||
)
|
||||
|
||||
return info.id
|
||||
}).pipe(Effect.provide(Session.defaultLayer)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
return info.id
|
||||
})
|
||||
}
|
||||
|
||||
describe("messages endpoint tolerates legacy negative token counts", () => {
|
||||
it.live(
|
||||
it.instance(
|
||||
"returns 200 even when a step-finish part has tokens.output < 0",
|
||||
Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ config: { formatter: false, lsp: false } })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
).pipe(
|
||||
Effect.flatMap((tmp) =>
|
||||
Effect.gen(function* () {
|
||||
const sessionID = yield* seedNegativeTokenSession(tmp.path)
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(tmp.path)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
||||
}),
|
||||
),
|
||||
),
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => resetDatabase()))
|
||||
const test = yield* TestInstance
|
||||
const sessionID = yield* seedNegativeTokenSession()
|
||||
const url = `${SessionPaths.messages.replace(":sessionID", sessionID)}?limit=80&directory=${encodeURIComponent(test.directory)}`
|
||||
const res = yield* Effect.promise(async () => Server.Default().app.request(url))
|
||||
expect(res.status, "messages endpoint 400'd on legacy negative tokens").not.toBe(400)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,28 +1,14 @@
|
||||
import { afterEach, describe, expect, mock, test } from "bun:test"
|
||||
import { afterEach, describe, expect, mock } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import type { SessionID } from "../../src/session/schema"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
remove(id: SessionID) {
|
||||
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
},
|
||||
}
|
||||
const it = testEffect(SessionNs.defaultLayer)
|
||||
|
||||
afterEach(async () => {
|
||||
mock.restore()
|
||||
@@ -30,21 +16,28 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe("session action routes", () => {
|
||||
test("abort route returns success", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await svc.create({})
|
||||
const app = Server.Default().app
|
||||
it.instance(
|
||||
"abort route returns success",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* Effect.acquireRelease(
|
||||
SessionNs.Service.use((svc) => svc.create({})),
|
||||
(created) => SessionNs.Service.use((svc) => svc.remove(created.id)).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
||||
const res = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
Server.Default().app.request(`/session/${session.id}/abort`, {
|
||||
method: "POST",
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect(await res.json()).toBe(true)
|
||||
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(yield* Effect.promise(() => res.json())).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -10,19 +10,20 @@
|
||||
* asserts that GET /session/<id>/diff returns 200 with the row intact.
|
||||
*/
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Server } from "@/server/server"
|
||||
import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session"
|
||||
import { Session } from "@/session/session"
|
||||
import { Storage } from "@/storage/storage"
|
||||
import { WithInstance } from "@/project/with-instance"
|
||||
import { resetDatabase } from "../fixture/db"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { it } from "../lib/effect"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Session.defaultLayer, Storage.defaultLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
await resetDatabase()
|
||||
@@ -32,50 +33,46 @@ function pathFor(template: string, params: Record<string, string>) {
|
||||
return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template)
|
||||
}
|
||||
|
||||
const withSession = (input?: Parameters<Session.Interface["create"]>[0]) =>
|
||||
Effect.acquireRelease(
|
||||
Session.Service.use((session) => session.create(input)),
|
||||
(created) => Session.Service.use((session) => session.remove(created.id)).pipe(Effect.ignore),
|
||||
)
|
||||
|
||||
describe("session diff with missing patch (#26574)", () => {
|
||||
it.live("GET /session/<id>/diff returns 200 when summary_diffs row has no patch", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true, config: { formatter: false, lsp: false } })),
|
||||
(t) => Effect.promise(() => t[Symbol.asyncDispose]()),
|
||||
)
|
||||
it.instance(
|
||||
"GET /session/<id>/diff returns 200 when summary_diffs row has no patch",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const session = yield* withSession({ title: "missing-patch" })
|
||||
|
||||
yield* Effect.promise(() =>
|
||||
WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
const session = await Effect.runPromise(
|
||||
Effect.provide(
|
||||
Session.Service.use((s) => s.create({ title: "missing-patch" })),
|
||||
Session.defaultLayer,
|
||||
),
|
||||
)
|
||||
// Mimic legacy/imported on-disk shape: a diff entry with no
|
||||
// `patch` text. Pre-fix the typed response encoder rejects
|
||||
// this and returns 400.
|
||||
yield* Storage.Service.use((storage) =>
|
||||
storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
|
||||
)
|
||||
|
||||
// Mimic legacy/imported on-disk shape: a diff entry with no
|
||||
// `patch` text. Pre-fix the typed response encoder rejects
|
||||
// this and returns 400.
|
||||
await Effect.runPromise(
|
||||
Effect.provide(
|
||||
Storage.Service.use((s) =>
|
||||
s.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]),
|
||||
),
|
||||
Storage.defaultLayer,
|
||||
),
|
||||
)
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), {
|
||||
headers: { "x-opencode-directory": test.directory },
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const headers = { "x-opencode-directory": tmp.path }
|
||||
const response = await Server.Default().app.request(pathFor(SessionPaths.diff, { sessionID: session.id }), {
|
||||
headers,
|
||||
})
|
||||
expect(response.status).toBe(200)
|
||||
const body = (await response.json()) as Array<{ file: string; patch?: string; additions: number }>
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0]?.file).toBe("legacy.txt")
|
||||
expect(body[0]?.additions).toBe(1)
|
||||
expect(body[0]?.patch).toBeUndefined()
|
||||
},
|
||||
}),
|
||||
)
|
||||
}),
|
||||
expect(response.status).toBe(200)
|
||||
const body = (yield* Effect.promise(() => response.json())) as Array<{
|
||||
file: string
|
||||
patch?: string
|
||||
additions: number
|
||||
}>
|
||||
expect(body).toHaveLength(1)
|
||||
expect(body[0]?.file).toBe("legacy.txt")
|
||||
expect(body[0]?.additions).toBe(1)
|
||||
expect(body[0]?.patch).toBeUndefined()
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,101 +1,93 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { Session as SessionNs } from "@/session/session"
|
||||
import type { SessionID } from "../../src/session/schema"
|
||||
import { Session } from "@/session/session"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { Server } from "../../src/server/server"
|
||||
import { disposeAllInstances, tmpdir } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionNs.Service>) {
|
||||
return Effect.runPromise(fx.pipe(Effect.provide(SessionNs.defaultLayer)))
|
||||
}
|
||||
|
||||
const svc = {
|
||||
...SessionNs,
|
||||
create(input?: SessionNs.CreateInput) {
|
||||
return run(SessionNs.Service.use((svc) => svc.create(input)))
|
||||
},
|
||||
remove(id: SessionID) {
|
||||
return run(SessionNs.Service.use((svc) => svc.remove(id)))
|
||||
},
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await disposeAllInstances()
|
||||
})
|
||||
const it = testEffect(Session.defaultLayer)
|
||||
|
||||
describe("tui.selectSession endpoint", () => {
|
||||
test("should return 200 when called with valid session", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// #given
|
||||
const session = await svc.create({})
|
||||
it.instance(
|
||||
"should return 200 when called with valid session",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const session = yield* Session.Service.use((svc) => svc.create({}))
|
||||
|
||||
// #when
|
||||
const app = Server.Default().app
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: session.id }),
|
||||
})
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": tmp.directory,
|
||||
},
|
||||
body: JSON.stringify({ sessionID: session.id }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(response.status).toBe(200)
|
||||
const body = await response.json()
|
||||
const body = yield* Effect.promise(() => response.json())
|
||||
expect(body).toBe(true)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
await svc.remove(session.id)
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test("should return 404 when session does not exist", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// #given
|
||||
it.instance(
|
||||
"should return 404 when session does not exist",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const nonExistentSessionID = "ses_nonexistent123"
|
||||
|
||||
// #when
|
||||
const app = Server.Default().app
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: nonExistentSessionID }),
|
||||
})
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": tmp.directory,
|
||||
},
|
||||
body: JSON.stringify({ sessionID: nonExistentSessionID }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(response.status).toBe(404)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
test("should return 400 when session ID format is invalid", async () => {
|
||||
await using tmp = await tmpdir({ git: true })
|
||||
await WithInstance.provide({
|
||||
directory: tmp.path,
|
||||
fn: async () => {
|
||||
// #given
|
||||
it.instance(
|
||||
"should return 400 when session ID format is invalid",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* TestInstance
|
||||
const invalidSessionID = "invalid_session_id"
|
||||
|
||||
// #when
|
||||
const app = Server.Default().app
|
||||
const response = await app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionID: invalidSessionID }),
|
||||
})
|
||||
const response = yield* Effect.promise(() =>
|
||||
Promise.resolve(
|
||||
app.request("/tui/select-session", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-opencode-directory": tmp.directory,
|
||||
},
|
||||
body: JSON.stringify({ sessionID: invalidSessionID }),
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(response.status).toBe(400)
|
||||
},
|
||||
})
|
||||
})
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -926,12 +926,12 @@ describe("session.compaction.process", () => {
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"does not persist tail_start_id for serialized recent turns",
|
||||
"persists tail_start_id for retained recent turns",
|
||||
Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const session = yield* ssn.create({})
|
||||
yield* createUserMessage(session.id, "first")
|
||||
yield* createUserMessage(session.id, "second")
|
||||
const keep = yield* createUserMessage(session.id, "second")
|
||||
yield* createUserMessage(session.id, "third")
|
||||
yield* createSummaryCompaction(session.id)
|
||||
|
||||
@@ -947,18 +947,18 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 10_000 }) })),
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"does not persist tail_start_id when shrinking serialized tail",
|
||||
"shrinks retained tail to fit preserve token budget",
|
||||
Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const session = yield* ssn.create({})
|
||||
yield* createUserMessage(session.id, "first")
|
||||
yield* createUserMessage(session.id, "x".repeat(2_000))
|
||||
yield* createUserMessage(session.id, "tiny")
|
||||
const keep = yield* createUserMessage(session.id, "tiny")
|
||||
yield* createSummaryCompaction(session.id)
|
||||
|
||||
const msgs = yield* ssn.messages({ sessionID: session.id })
|
||||
@@ -973,7 +973,7 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 100 }) })),
|
||||
)
|
||||
|
||||
@@ -1005,7 +1005,7 @@ describe("session.compaction.process", () => {
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"serializes retained tail media as text in the summary input",
|
||||
"falls back to full summary when retained tail media exceeds preserve token budget",
|
||||
() => {
|
||||
const stub = llm()
|
||||
let captured = ""
|
||||
@@ -1078,16 +1078,15 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
expect(captured).toContain("zzzz")
|
||||
expect(captured).toContain("keep tail")
|
||||
expect(captured).not.toContain("keep tail")
|
||||
|
||||
const filtered = MessageV2.filterCompacted(MessageV2.stream(session.id))
|
||||
expect(filtered.map((msg) => msg.info.id)).toEqual([parent!, expect.any(String)])
|
||||
expect(filtered.map((msg) => msg.info.id).slice(0, 3)).toEqual([parent!, expect.any(String), keep.id])
|
||||
expect(filtered[1]?.info.role).toBe("assistant")
|
||||
expect(filtered[1]?.info.role === "assistant" ? filtered[1].info.summary : false).toBe(true)
|
||||
expect(filtered.map((msg) => msg.info.id)).not.toContain(large.id)
|
||||
expect(filtered.map((msg) => msg.info.id)).not.toContain(keep.id)
|
||||
}).pipe(withCompaction({ llm: stub.layer, config: cfg({ tail_turns: 1, preserve_recent_tokens: 100 }) }))
|
||||
},
|
||||
{ git: true },
|
||||
@@ -1354,13 +1353,13 @@ describe("session.compaction.process", () => {
|
||||
)
|
||||
|
||||
itCompaction.instance(
|
||||
"summarizes the head while serializing recent tail into summary input",
|
||||
"summarizes only the head while keeping recent tail out of summary input",
|
||||
() => {
|
||||
const stub = llm()
|
||||
let captured: LLM.StreamInput["messages"] = []
|
||||
let captured = ""
|
||||
stub.push(
|
||||
reply("summary", (input) => {
|
||||
captured = input.messages
|
||||
captured = JSON.stringify(input.messages)
|
||||
}),
|
||||
)
|
||||
return Effect.gen(function* () {
|
||||
@@ -1381,15 +1380,10 @@ describe("session.compaction.process", () => {
|
||||
auto: false,
|
||||
})
|
||||
|
||||
const head = JSON.stringify(captured.slice(0, -1))
|
||||
const prompt = JSON.stringify(captured.at(-1))
|
||||
expect(head).toContain("older context")
|
||||
expect(head).not.toContain("keep this turn")
|
||||
expect(head).not.toContain("and this one too")
|
||||
expect(prompt).toContain("keep this turn")
|
||||
expect(prompt).toContain("and this one too")
|
||||
expect(prompt).toContain("recent-conversation-tail")
|
||||
expect(prompt).not.toContain("What did we do so far?")
|
||||
expect(captured).toContain("older context")
|
||||
expect(captured).not.toContain("keep this turn")
|
||||
expect(captured).not.toContain("and this one too")
|
||||
expect(captured).not.toContain("What did we do so far?")
|
||||
}).pipe(withCompaction({ llm: stub.layer }))
|
||||
},
|
||||
{ git: true },
|
||||
@@ -1437,7 +1431,7 @@ describe("session.compaction.process", () => {
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
itCompaction.instance("does not replay recent pre-compaction turns across repeated compactions", () => {
|
||||
itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => {
|
||||
const stub = llm()
|
||||
stub.push(reply("summary one"))
|
||||
stub.push(reply("summary two"))
|
||||
@@ -1468,8 +1462,8 @@ describe("session.compaction.process", () => {
|
||||
|
||||
expect(ids).not.toContain(u1.id)
|
||||
expect(ids).not.toContain(u2.id)
|
||||
expect(ids).not.toContain(u3.id)
|
||||
expect(ids).not.toContain(u4.id)
|
||||
expect(ids).toContain(u3.id)
|
||||
expect(ids).toContain(u4.id)
|
||||
expect(filtered.some((msg) => msg.info.role === "assistant" && msg.info.summary)).toBe(true)
|
||||
expect(
|
||||
filtered.some((msg) => msg.info.role === "user" && msg.parts.some((part) => part.type === "compaction")),
|
||||
@@ -1478,7 +1472,7 @@ describe("session.compaction.process", () => {
|
||||
})
|
||||
|
||||
itCompaction.instance(
|
||||
"ignores previous summaries when sizing the serialized tail",
|
||||
"ignores previous summaries when sizing the retained tail",
|
||||
Effect.gen(function* () {
|
||||
const ssn = yield* SessionNs.Service
|
||||
const test = yield* TestInstance
|
||||
@@ -1517,7 +1511,7 @@ describe("session.compaction.process", () => {
|
||||
|
||||
const part = yield* readCompactionPart(session.id)
|
||||
expect(part?.type).toBe("compaction")
|
||||
expect(part?.tail_start_id).toBeUndefined()
|
||||
expect(part?.tail_start_id).toBe(keep.id)
|
||||
}).pipe(withCompaction({ config: cfg({ tail_turns: 2, preserve_recent_tokens: 500 }) })),
|
||||
)
|
||||
})
|
||||
@@ -1764,6 +1758,101 @@ describe("SessionNs.getUsage", () => {
|
||||
expect(result.cost).toBe(3 + 1.5)
|
||||
})
|
||||
|
||||
test("uses matching context cost tier before over-200k fallback", () => {
|
||||
const model = createModel({
|
||||
context: 1_000_000,
|
||||
output: 32_000,
|
||||
cost: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cache: { read: 0.1, write: 0.5 },
|
||||
tiers: [
|
||||
{
|
||||
input: 3,
|
||||
output: 4,
|
||||
cache: { read: 0.3, write: 1.5 },
|
||||
tier: { type: "context", size: 200_000 },
|
||||
},
|
||||
{
|
||||
input: 5,
|
||||
output: 6,
|
||||
cache: { read: 0.5, write: 2.5 },
|
||||
tier: { type: "context", size: 500_000 },
|
||||
},
|
||||
],
|
||||
experimentalOver200K: {
|
||||
input: 100,
|
||||
output: 100,
|
||||
cache: { read: 100, write: 100 },
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 650_000,
|
||||
outputTokens: 100_000,
|
||||
totalTokens: 750_000,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: 100_000,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.tokens.input).toBe(550_000)
|
||||
expect(result.cost).toBe(2.75 + 0.6 + 0.05)
|
||||
})
|
||||
|
||||
test("falls back to over-200k pricing when no cost tier matches", () => {
|
||||
const model = createModel({
|
||||
context: 1_000_000,
|
||||
output: 32_000,
|
||||
cost: {
|
||||
input: 1,
|
||||
output: 2,
|
||||
cache: { read: 0.1, write: 0.5 },
|
||||
tiers: [
|
||||
{
|
||||
input: 5,
|
||||
output: 6,
|
||||
cache: { read: 0.5, write: 2.5 },
|
||||
tier: { type: "context", size: 500_000 },
|
||||
},
|
||||
],
|
||||
experimentalOver200K: {
|
||||
input: 3,
|
||||
output: 4,
|
||||
cache: { read: 0.3, write: 1.5 },
|
||||
},
|
||||
},
|
||||
})
|
||||
const result = SessionNs.getUsage({
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 300_000,
|
||||
outputTokens: 100_000,
|
||||
totalTokens: 400_000,
|
||||
inputTokenDetails: {
|
||||
noCacheTokens: undefined,
|
||||
cacheReadTokens: undefined,
|
||||
cacheWriteTokens: undefined,
|
||||
},
|
||||
outputTokenDetails: {
|
||||
textTokens: undefined,
|
||||
reasoningTokens: undefined,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.cost).toBe(0.9 + 0.4)
|
||||
})
|
||||
|
||||
test.each(["@ai-sdk/anthropic", "@ai-sdk/amazon-bedrock", "@ai-sdk/google-vertex/anthropic"])(
|
||||
"computes total from components for %s models",
|
||||
(npm) => {
|
||||
|
||||
@@ -277,6 +277,25 @@ async function loadFixture(providerID: string, modelID: string) {
|
||||
return { provider, model }
|
||||
}
|
||||
|
||||
function configModel(model: ModelsDev.Model) {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
family: model.family,
|
||||
release_date: model.release_date,
|
||||
attachment: model.attachment,
|
||||
reasoning: model.reasoning,
|
||||
temperature: model.temperature,
|
||||
tool_call: model.tool_call,
|
||||
interleaved: model.interleaved,
|
||||
cost: model.cost ? { ...model.cost, tiers: undefined } : undefined,
|
||||
limit: model.limit,
|
||||
modalities: model.modalities,
|
||||
status: model.status,
|
||||
provider: model.provider,
|
||||
}
|
||||
}
|
||||
|
||||
function createEventStream(chunks: unknown[], includeDone = false) {
|
||||
const lines = chunks.map((chunk) => `data: ${typeof chunk === "string" ? chunk : JSON.stringify(chunk)}`)
|
||||
if (includeDone) {
|
||||
@@ -617,7 +636,7 @@ describe("session.llm.stream", () => {
|
||||
npm: "@ai-sdk/openai",
|
||||
api: "https://api.openai.com/v1",
|
||||
models: {
|
||||
[model.id]: model,
|
||||
[model.id]: configModel(model),
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-openai-key",
|
||||
@@ -733,7 +752,7 @@ describe("session.llm.stream", () => {
|
||||
npm: "@ai-sdk/openai",
|
||||
api: "https://api.openai.com/v1",
|
||||
models: {
|
||||
[model.id]: model,
|
||||
[model.id]: configModel(model),
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-openai-key",
|
||||
@@ -970,7 +989,7 @@ describe("session.llm.stream", () => {
|
||||
npm: "@ai-sdk/anthropic",
|
||||
api: "https://api.anthropic.com/v1",
|
||||
models: {
|
||||
[model.id]: model,
|
||||
[model.id]: configModel(model),
|
||||
},
|
||||
options: {
|
||||
apiKey: "test-anthropic-key",
|
||||
|
||||
@@ -650,7 +650,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("ignores original tail when compaction stores tail_start_id", () =>
|
||||
it.instance("retains original tail when compaction stores tail_start_id", () =>
|
||||
withSession(({ session, sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const u1 = yield* addUser(sessionID, "first")
|
||||
@@ -696,12 +696,12 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.instance("fork keeps legacy tail_start_id without replaying the tail", () =>
|
||||
it.instance("fork remaps compaction tail_start_id for filterCompacted", () =>
|
||||
Effect.gen(function* () {
|
||||
const session = yield* SessionNs.Service
|
||||
const created = yield* session.create({})
|
||||
@@ -748,7 +748,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
})
|
||||
|
||||
const parentFiltered = MessageV2.filterCompacted(MessageV2.stream(created.id))
|
||||
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u3, a3])
|
||||
expect(parentFiltered.map((item) => item.info.id)).toEqual([c1, s1, u2, a2, u3, a3])
|
||||
|
||||
const forked = yield* session.fork({ sessionID: created.id })
|
||||
const childFiltered = MessageV2.filterCompacted(MessageV2.stream(forked.id))
|
||||
@@ -758,14 +758,14 @@ describe("MessageV2.filterCompacted", () => {
|
||||
expect(tailPart?.type).toBe("compaction")
|
||||
if (!tailPart || tailPart.type !== "compaction") throw new Error("Expected forked compaction part")
|
||||
expect(tailPart.tail_start_id).toBeDefined()
|
||||
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(false)
|
||||
expect(childFiltered.some((m) => m.info.id === tailPart.tail_start_id)).toBe(true)
|
||||
|
||||
yield* session.remove(forked.id)
|
||||
yield* session.remove(created.id)
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("does not replay an assistant tail when compaction starts inside a turn", () =>
|
||||
it.instance("retains an assistant tail when compaction starts inside a turn", () =>
|
||||
withSession(({ session, sessionID }) =>
|
||||
Effect.gen(function* () {
|
||||
const u1 = yield* addUser(sessionID, "first")
|
||||
@@ -819,7 +819,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, u3, a4])
|
||||
expect(result.map((item) => item.info.id)).toEqual([c1, s1, a3, u3, a4])
|
||||
}),
|
||||
),
|
||||
)
|
||||
@@ -891,7 +891,7 @@ describe("MessageV2.filterCompacted", () => {
|
||||
|
||||
const result = MessageV2.filterCompacted(MessageV2.stream(sessionID))
|
||||
|
||||
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u4, a4])
|
||||
expect(result.map((item) => item.info.id)).toEqual([c2, s2, u3, a3, u4, a4])
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -59,6 +59,7 @@ import { Ripgrep } from "../../src/file/ripgrep"
|
||||
import { Format } from "../../src/format"
|
||||
import { Reference } from "../../src/reference/reference"
|
||||
import { SyncEvent } from "@/sync"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
void Log.init({ print: false })
|
||||
|
||||
@@ -137,6 +138,7 @@ function makeHttp() {
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer()),
|
||||
Layer.provideMerge(todo),
|
||||
Layer.provideMerge(question),
|
||||
Layer.provideMerge(deps),
|
||||
|
||||
@@ -1,250 +1,219 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Session } from "@/session/session"
|
||||
import { SessionPrompt } from "../../src/session/prompt"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { MessageV2 } from "../../src/session/message-v2"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const projectRoot = path.join(__dirname, "../..")
|
||||
void Log.init({ print: false })
|
||||
|
||||
// Skip tests if no API key is available
|
||||
const hasApiKey = !!process.env.ANTHROPIC_API_KEY
|
||||
|
||||
// Helper to run test within Instance context
|
||||
async function withInstance<T>(fn: () => Promise<T>): Promise<T> {
|
||||
return WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn,
|
||||
})
|
||||
}
|
||||
|
||||
function run<A, E>(fx: Effect.Effect<A, E, SessionPrompt.Service | Session.Service>) {
|
||||
return Effect.runPromise(
|
||||
fx.pipe(Effect.scoped, Effect.provide(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))),
|
||||
)
|
||||
}
|
||||
const it = testEffect(Layer.mergeAll(SessionPrompt.defaultLayer, Session.defaultLayer))
|
||||
const live = hasApiKey ? it.instance : it.instance.skip
|
||||
|
||||
describe("StructuredOutput Integration", () => {
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"produces structured output with simple schema",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Structured Output Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Structured Output Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 2 + 2? Provide a simple answer.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "number", description: "The numerical answer" },
|
||||
explanation: { type: "string", description: "Brief explanation" },
|
||||
},
|
||||
required: ["answer"],
|
||||
},
|
||||
retryCount: 0,
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 2 + 2? Provide a simple answer.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
answer: { type: "number", description: "The numerical answer" },
|
||||
explanation: { type: "string", description: "Brief explanation" },
|
||||
},
|
||||
})
|
||||
required: ["answer"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
expect(typeof result.info.structured).toBe("object")
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
expect(typeof result.info.structured).toBe("object")
|
||||
|
||||
const output = result.info.structured as any
|
||||
expect(output.answer).toBe(4)
|
||||
const output = result.info.structured as any
|
||||
expect(output.answer).toBe(4)
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"produces structured output with nested objects",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Nested Schema Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Nested Schema Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tell me about Anthropic company in a structured format.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Tell me about Anthropic company in a structured format.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
company: {
|
||||
type: "object",
|
||||
properties: {
|
||||
company: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
},
|
||||
required: ["name", "founded"],
|
||||
},
|
||||
products: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
name: { type: "string" },
|
||||
founded: { type: "number" },
|
||||
},
|
||||
required: ["company"],
|
||||
required: ["name", "founded"],
|
||||
},
|
||||
products: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
required: ["company"],
|
||||
},
|
||||
retryCount: 0,
|
||||
},
|
||||
})
|
||||
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
const output = result.info.structured as any
|
||||
// Verify structured output was captured (only on assistant messages)
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeDefined()
|
||||
const output = result.info.structured as any
|
||||
|
||||
expect(output.company).toBeDefined()
|
||||
expect(output.company.name).toBe("Anthropic")
|
||||
expect(typeof output.company.founded).toBe("number")
|
||||
expect(output.company).toBeDefined()
|
||||
expect(output.company.name).toBe("Anthropic")
|
||||
expect(typeof output.company.founded).toBe("number")
|
||||
|
||||
if (output.products) {
|
||||
expect(Array.isArray(output.products)).toBe(true)
|
||||
}
|
||||
if (output.products) {
|
||||
expect(Array.isArray(output.products)).toBe(true)
|
||||
}
|
||||
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no error was set
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"works with text outputFormat (default)",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Text Output Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "Text Output Test" })
|
||||
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Say hello.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "text",
|
||||
},
|
||||
})
|
||||
const result = yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Say hello.",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "text",
|
||||
},
|
||||
})
|
||||
|
||||
// Verify no structured output (text mode) and no error
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeUndefined()
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
// Verify no structured output (text mode) and no error
|
||||
expect(result.info.role).toBe("assistant")
|
||||
if (result.info.role === "assistant") {
|
||||
expect(result.info.structured).toBeUndefined()
|
||||
expect(result.info.error).toBeUndefined()
|
||||
}
|
||||
|
||||
// Verify we got a response with parts
|
||||
expect(result.parts.length).toBeGreaterThan(0)
|
||||
// Verify we got a response with parts
|
||||
expect(result.parts.length).toBeGreaterThan(0)
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
test.skipIf(!hasApiKey)(
|
||||
live(
|
||||
"stores outputFormat on user message",
|
||||
async () => {
|
||||
await withInstance(() =>
|
||||
run(
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const prompt = yield* SessionPrompt.Service
|
||||
const sessions = yield* Session.Service
|
||||
const session = yield* sessions.create({ title: "OutputFormat Storage Test" })
|
||||
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 1 + 1?",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "number" },
|
||||
},
|
||||
required: ["result"],
|
||||
},
|
||||
retryCount: 3,
|
||||
yield* prompt.prompt({
|
||||
sessionID: session.id,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: "What is 1 + 1?",
|
||||
},
|
||||
],
|
||||
format: {
|
||||
type: "json_schema",
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
result: { type: "number" },
|
||||
},
|
||||
})
|
||||
required: ["result"],
|
||||
},
|
||||
retryCount: 3,
|
||||
},
|
||||
})
|
||||
|
||||
// Get all messages from session
|
||||
const messages = yield* sessions.messages({ sessionID: session.id })
|
||||
const userMessage = messages.find((m) => m.info.role === "user")
|
||||
// Get all messages from session
|
||||
const messages = yield* sessions.messages({ sessionID: session.id })
|
||||
const userMessage = messages.find((m) => m.info.role === "user")
|
||||
|
||||
// Verify outputFormat was stored on user message
|
||||
expect(userMessage).toBeDefined()
|
||||
if (userMessage?.info.role === "user") {
|
||||
expect(userMessage.info.format).toBeDefined()
|
||||
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||
if (userMessage.info.format?.type === "json_schema") {
|
||||
expect(userMessage.info.format.retryCount).toBe(3)
|
||||
}
|
||||
}
|
||||
// Verify outputFormat was stored on user message
|
||||
expect(userMessage).toBeDefined()
|
||||
if (userMessage?.info.role === "user") {
|
||||
expect(userMessage.info.format).toBeDefined()
|
||||
expect(userMessage.info.format?.type).toBe("json_schema")
|
||||
if (userMessage.info.format?.type === "json_schema") {
|
||||
expect(userMessage.info.format.retryCount).toBe(3)
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
),
|
||||
)
|
||||
},
|
||||
// Clean up
|
||||
// Note: Not removing session to avoid race with background SessionSummary.summarize
|
||||
}),
|
||||
{ git: true },
|
||||
60000,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, beforeAll, afterAll } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { Discovery } from "../../src/skill/discovery"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
@@ -13,7 +14,7 @@ let downloadCount = 0
|
||||
|
||||
const fixturePath = path.join(import.meta.dir, "../fixture/skills")
|
||||
const cacheDir = path.join(Global.Path.cache, "skills")
|
||||
const it = testEffect(Discovery.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(Discovery.defaultLayer, AppFileSystem.defaultLayer))
|
||||
|
||||
beforeAll(async () => {
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
@@ -49,36 +50,37 @@ afterAll(async () => {
|
||||
})
|
||||
|
||||
describe("Discovery.pull", () => {
|
||||
const pull = Effect.fn("DiscoveryTest.pull")(function* (url: string) {
|
||||
return yield* Discovery.Service.use((s) => s.pull(url))
|
||||
})
|
||||
|
||||
it.live("downloads skills from cloudflare url", () =>
|
||||
Effect.gen(function* () {
|
||||
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
expect(dir).toStartWith(cacheDir)
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
|
||||
expect(yield* fsys.existsSafe(md)).toBe(true)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("url without trailing slash works", () =>
|
||||
Effect.gen(function* () {
|
||||
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL.replace(/\/$/, ""))
|
||||
expect(dirs.length).toBeGreaterThan(0)
|
||||
for (const dir of dirs) {
|
||||
const md = path.join(dir, "SKILL.md")
|
||||
expect(yield* Effect.promise(() => Filesystem.exists(md))).toBe(true)
|
||||
expect(yield* fsys.existsSafe(md)).toBe(true)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("returns empty array for invalid url", () =>
|
||||
Effect.gen(function* () {
|
||||
const dirs = yield* pull(`http://localhost:${server.port}/invalid-url/`)
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(`http://localhost:${server.port}/invalid-url/`)
|
||||
expect(dirs).toEqual([])
|
||||
}),
|
||||
)
|
||||
@@ -86,20 +88,23 @@ describe("Discovery.pull", () => {
|
||||
it.live("returns empty array for non-json response", () =>
|
||||
Effect.gen(function* () {
|
||||
// any url not explicitly handled in server returns 404 text "Not Found"
|
||||
const dirs = yield* pull(`http://localhost:${server.port}/some-other-path/`)
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(`http://localhost:${server.port}/some-other-path/`)
|
||||
expect(dirs).toEqual([])
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("downloads reference files alongside SKILL.md", () =>
|
||||
Effect.gen(function* () {
|
||||
const dirs = yield* pull(CLOUDFLARE_SKILLS_URL)
|
||||
const fsys = yield* AppFileSystem.Service
|
||||
const discovery = yield* Discovery.Service
|
||||
const dirs = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
// find a skill dir that should have reference files (e.g. agents-sdk)
|
||||
const agentsSdk = dirs.find((d) => d.endsWith(path.sep + "agents-sdk"))
|
||||
expect(agentsSdk).toBeDefined()
|
||||
if (agentsSdk) {
|
||||
const refs = path.join(agentsSdk, "references")
|
||||
expect(yield* Effect.promise(() => Filesystem.exists(path.join(agentsSdk, "SKILL.md")))).toBe(true)
|
||||
expect(yield* fsys.existsSafe(path.join(agentsSdk, "SKILL.md"))).toBe(true)
|
||||
// agents-sdk has reference files per the index
|
||||
const refDir = yield* Effect.promise(() =>
|
||||
Array.fromAsync(new Bun.Glob("**/*.md").scan({ cwd: refs, onlyFiles: true })),
|
||||
@@ -114,15 +119,16 @@ describe("Discovery.pull", () => {
|
||||
// clear dir and downloadCount
|
||||
yield* Effect.promise(() => rm(cacheDir, { recursive: true, force: true }))
|
||||
downloadCount = 0
|
||||
const discovery = yield* Discovery.Service
|
||||
|
||||
// first pull to populate cache
|
||||
const first = yield* pull(CLOUDFLARE_SKILLS_URL)
|
||||
const first = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(first.length).toBeGreaterThan(0)
|
||||
const firstCount = downloadCount
|
||||
expect(firstCount).toBeGreaterThan(0)
|
||||
|
||||
// second pull should return same results from cache
|
||||
const second = yield* pull(CLOUDFLARE_SKILLS_URL)
|
||||
const second = yield* discovery.pull(CLOUDFLARE_SKILLS_URL)
|
||||
expect(second.length).toBe(first.length)
|
||||
expect(second.sort()).toEqual(first.sort())
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { afterEach, expect } from "bun:test"
|
||||
import { $ } from "bun"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import fs from "fs/promises"
|
||||
import path from "path"
|
||||
import { Effect, Fiber } from "effect"
|
||||
import { Effect, Fiber, Layer } from "effect"
|
||||
import { Snapshot } from "../../src/snapshot"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { disposeAllInstances, provideInstance, TestInstance, tmpdirScoped } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const it = testEffect(Snapshot.defaultLayer)
|
||||
const it = testEffect(Layer.mergeAll(Snapshot.defaultLayer, AppFileSystem.defaultLayer))
|
||||
|
||||
// Git always outputs /-separated paths internally. Snapshot.patch() joins them
|
||||
// with path.join (which produces \ on Windows) then normalizes back to /.
|
||||
@@ -27,17 +27,13 @@ const exec = (cwd: string, command: string[]) =>
|
||||
if (code !== 0) throw new Error(`${command.join(" ")} failed: ${await new Response(proc.stderr).text()}`)
|
||||
})
|
||||
|
||||
const write = (file: string, content: string | Uint8Array) => Effect.promise(() => Filesystem.write(file, content))
|
||||
const readText = (file: string) => Effect.promise(() => fs.readFile(file, "utf-8"))
|
||||
const exists = (file: string) =>
|
||||
Effect.promise(() =>
|
||||
fs
|
||||
.access(file)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
)
|
||||
const mkdirp = (dir: string) => Effect.promise(() => fs.mkdir(dir, { recursive: true }))
|
||||
const rm = (file: string) => Effect.promise(() => fs.rm(file, { recursive: true, force: true }))
|
||||
const write = (file: string, content: string | Uint8Array) =>
|
||||
AppFileSystem.Service.use((fs) => fs.writeWithDirs(file, content))
|
||||
const readText = (file: string) => AppFileSystem.Service.use((fs) => fs.readFileString(file))
|
||||
const exists = (file: string) => AppFileSystem.Service.use((fs) => fs.existsSafe(file))
|
||||
const mkdirp = (dir: string) => AppFileSystem.Service.use((fs) => fs.ensureDir(dir))
|
||||
const rm = (file: string) =>
|
||||
AppFileSystem.Service.use((fs) => fs.remove(file, { recursive: true, force: true }).pipe(Effect.ignore))
|
||||
|
||||
const initialize = Effect.fn("SnapshotTest.initialize")(function* (dir: string) {
|
||||
const unique = Math.random().toString(36).slice(2)
|
||||
|
||||
+114413
-62293
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,7 @@ import { pathToFileURL } from "url"
|
||||
import { Effect, Layer, Result, Schema } from "effect"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { Flag } from "@opencode-ai/core/flag/flag"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { disposeAllInstances, TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import { TestConfig } from "../fixture/config"
|
||||
@@ -29,46 +29,48 @@ import { InstanceState } from "@/effect/instance-state"
|
||||
import { Reference } from "@/reference/reference"
|
||||
import { ProviderID, ModelID } from "@/provider/schema"
|
||||
import { ToolJsonSchema } from "@/tool/json-schema"
|
||||
import { MessageID, SessionID } from "@/session/schema"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
const originalExperimentalScout = Flag.OPENCODE_EXPERIMENTAL_SCOUT
|
||||
const configLayer = TestConfig.layer({
|
||||
directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])),
|
||||
})
|
||||
|
||||
const registryLayer = ToolRegistry.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(node),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
)
|
||||
const registryLayer = (flags: Partial<RuntimeFlags.Info> = {}) =>
|
||||
ToolRegistry.layer.pipe(
|
||||
Layer.provide(configLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(Question.defaultLayer),
|
||||
Layer.provide(Todo.defaultLayer),
|
||||
Layer.provide(Skill.defaultLayer),
|
||||
Layer.provide(Agent.defaultLayer),
|
||||
Layer.provide(Session.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Git.defaultLayer),
|
||||
Layer.provide(Reference.defaultLayer),
|
||||
Layer.provide(LSP.defaultLayer),
|
||||
Layer.provide(Instruction.defaultLayer),
|
||||
Layer.provide(AppFileSystem.defaultLayer),
|
||||
Layer.provide(Bus.layer),
|
||||
Layer.provide(FetchHttpClient.layer),
|
||||
Layer.provide(Format.defaultLayer),
|
||||
Layer.provide(node),
|
||||
Layer.provide(Ripgrep.defaultLayer),
|
||||
Layer.provide(Truncate.defaultLayer),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(registryLayer, node, Agent.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||
const scout = testEffect(Layer.mergeAll(registryLayer({ experimentalScout: true }), node, Agent.defaultLayer))
|
||||
|
||||
afterEach(async () => {
|
||||
Flag.OPENCODE_EXPERIMENTAL_SCOUT = originalExperimentalScout
|
||||
await disposeAllInstances()
|
||||
})
|
||||
|
||||
describe("tool.registry", () => {
|
||||
it.instance("hides repo research tools unless experimental", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_SCOUT = false
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
@@ -77,9 +79,8 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("shows repo research tools when experimental scout is enabled", () =>
|
||||
scout.instance("shows repo research tools when experimental scout is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
Flag.OPENCODE_EXPERIMENTAL_SCOUT = true
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
|
||||
@@ -193,6 +194,51 @@ describe("tool.registry", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("preserves attachments from structured custom tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const customTools = path.join(test.directory, ".opencode", "tools")
|
||||
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "image.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'image tool',",
|
||||
" args: {},",
|
||||
" execute: async () => ({",
|
||||
" output: 'here is an image',",
|
||||
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
|
||||
" }),",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
|
||||
if (!loaded) throw new Error("custom image tool was not loaded")
|
||||
const agents = yield* Agent.Service
|
||||
const result = yield* loaded.execute({}, {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
messageID: MessageID.make("msg_test"),
|
||||
agent: (yield* agents.defaultInfo()).name,
|
||||
abort: new AbortController().signal,
|
||||
messages: [],
|
||||
metadata: () => Effect.void,
|
||||
ask: () => Effect.void,
|
||||
} satisfies Tool.Context)
|
||||
|
||||
expect(result.output).toBe("here is an image")
|
||||
expect(result.attachments).toEqual([
|
||||
{ type: "file", mime: "image/png", filename: "picture.png", url: "data:image/png;base64,AAAA" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import path from "path"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { FetchHttpClient } from "effect/unstable/http"
|
||||
import { Agent } from "../../src/agent/agent"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Instance } from "../../src/project/instance"
|
||||
import { WithInstance } from "../../src/project/with-instance"
|
||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { Tool } from "@/tool/tool"
|
||||
import { testEffect } from "../lib/effect"
|
||||
|
||||
const projectRoot = path.join(import.meta.dir, "../..")
|
||||
const it = testEffect(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer))
|
||||
|
||||
const ctx = {
|
||||
sessionID: SessionID.make("ses_test"),
|
||||
@@ -22,30 +21,31 @@ const ctx = {
|
||||
ask: () => Effect.void,
|
||||
}
|
||||
|
||||
async function withFetch(fetch: (req: Request) => Response | Promise<Response>, fn: (url: URL) => Promise<void>) {
|
||||
using server = Bun.serve({ port: 0, fetch })
|
||||
await fn(server.url)
|
||||
}
|
||||
|
||||
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
|
||||
return WebFetchTool.pipe(
|
||||
Effect.flatMap((info) => info.init()),
|
||||
Effect.flatMap((tool) => tool.execute(args, ctx)),
|
||||
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
|
||||
Effect.runPromise,
|
||||
const withFetch = <A, E, R>(
|
||||
fetch: (req: Request) => Response | Promise<Response>,
|
||||
fn: (url: URL) => Effect.Effect<A, E, R>,
|
||||
) =>
|
||||
Effect.acquireUseRelease(
|
||||
Effect.sync(() => Bun.serve({ port: 0, fetch })),
|
||||
(server) => fn(server.url),
|
||||
(server) => Effect.sync(() => server.stop(true)),
|
||||
)
|
||||
}
|
||||
|
||||
const exec = Effect.fn("WebFetchToolTest.exec")(function* (args: Tool.InferParameters<typeof WebFetchTool>) {
|
||||
const info = yield* WebFetchTool
|
||||
const tool = yield* info.init()
|
||||
return yield* tool.execute(args, ctx)
|
||||
})
|
||||
|
||||
describe("tool.webfetch", () => {
|
||||
test("returns image responses as file attachments", async () => {
|
||||
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
await withFetch(
|
||||
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
|
||||
async (url) => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
|
||||
it.instance("returns image responses as file attachments", () =>
|
||||
Effect.gen(function* () {
|
||||
const bytes = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10])
|
||||
yield* withFetch(
|
||||
() => new Response(bytes, { status: 200, headers: { "content-type": "IMAGE/PNG; charset=binary" } }),
|
||||
(url) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
|
||||
expect(result.output).toBe("Image fetched successfully")
|
||||
expect(result.attachments).toBeDefined()
|
||||
expect(result.attachments?.length).toBe(1)
|
||||
@@ -55,50 +55,40 @@ describe("tool.webfetch", () => {
|
||||
expect(result.attachments?.[0]).not.toHaveProperty("id")
|
||||
expect(result.attachments?.[0]).not.toHaveProperty("sessionID")
|
||||
expect(result.attachments?.[0]).not.toHaveProperty("messageID")
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
test("keeps svg as text output", async () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>'
|
||||
await withFetch(
|
||||
it.instance("keeps svg as text output", () =>
|
||||
withFetch(
|
||||
() =>
|
||||
new Response(svg, {
|
||||
new Response('<svg xmlns="http://www.w3.org/2000/svg"><text>hello</text></svg>', {
|
||||
status: 200,
|
||||
headers: { "content-type": "image/svg+xml; charset=UTF-8" },
|
||||
}),
|
||||
async (url) => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
|
||||
expect(result.output).toContain("<svg")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
(url) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* exec({ url: new URL("/image.svg", url).toString(), format: "html" })
|
||||
expect(result.output).toContain("<svg")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
test("keeps text responses as text output", async () => {
|
||||
await withFetch(
|
||||
it.instance("keeps text responses as text output", () =>
|
||||
withFetch(
|
||||
() =>
|
||||
new Response("hello from webfetch", {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/plain; charset=utf-8" },
|
||||
}),
|
||||
async (url) => {
|
||||
await WithInstance.provide({
|
||||
directory: projectRoot,
|
||||
fn: async () => {
|
||||
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
|
||||
expect(result.output).toBe("hello from webfetch")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
},
|
||||
})
|
||||
},
|
||||
)
|
||||
})
|
||||
(url) =>
|
||||
Effect.gen(function* () {
|
||||
const result = yield* exec({ url: new URL("/file.txt", url).toString(), format: "text" })
|
||||
expect(result.output).toBe("hello from webfetch")
|
||||
expect(result.attachments).toBeUndefined()
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user