mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-15 17:08:21 -04:00
Compare commits
31 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 73a4f5a654 | |||
| 8b9b9ad31e | |||
| 3729fd5706 | |||
| 74b14a2d4e | |||
| cdb951ec2f | |||
| fc01cad2b8 | |||
| c1ddc0ea2d | |||
| 319b7655b7 | |||
| 824c12c01a | |||
| 17b2900884 | |||
| 003010bdb6 | |||
| 82a4292934 | |||
| eea4253d67 | |||
| 1eacc3c339 | |||
| 1a509d62a0 | |||
| 4c4eef46f1 | |||
| d62ec7776e | |||
| cb1e5d9e41 | |||
| ca5f086759 | |||
| 57c40eb7c2 | |||
| 63035f977f | |||
| 514d2a36bc | |||
| 0b6fd5f612 | |||
| 029e7135b7 | |||
| c43591f8a2 | |||
| a2c22714cb | |||
| 312f10f797 | |||
| d1f05b0f3a | |||
| ccb0b320e1 | |||
| 5ee7edaf9e | |||
| 27190635ea |
@@ -26,6 +26,7 @@ kommander
|
|||||||
r44vc0rp
|
r44vc0rp
|
||||||
rekram1-node
|
rekram1-node
|
||||||
-robinmordasiewicz
|
-robinmordasiewicz
|
||||||
|
simonklee
|
||||||
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
-spider-yamet clawdbot/llm psychosis, spam pinging the team
|
||||||
thdxr
|
thdxr
|
||||||
-toastythebot
|
-toastythebot
|
||||||
|
|||||||
@@ -319,7 +319,7 @@
|
|||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
@@ -331,7 +331,7 @@
|
|||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
"@ai-sdk/openai-compatible": "2.0.37",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/provider-utils": "4.0.23",
|
"@ai-sdk/provider-utils": "4.0.23",
|
||||||
@@ -341,7 +341,6 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/node-server": "1.19.11",
|
"@hono/node-server": "1.19.11",
|
||||||
"@hono/node-ws": "1.3.0",
|
"@hono/node-ws": "1.3.0",
|
||||||
@@ -357,7 +356,7 @@
|
|||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.4.2",
|
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||||
"@opentui/core": "0.1.97",
|
"@opentui/core": "0.1.97",
|
||||||
"@opentui/solid": "0.1.97",
|
"@opentui/solid": "0.1.97",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
@@ -663,7 +662,7 @@
|
|||||||
"@types/node": "22.13.9",
|
"@types/node": "22.13.9",
|
||||||
"@types/semver": "7.7.1",
|
"@types/semver": "7.7.1",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
"@typescript/native-preview": "7.0.0-dev.20251207.1",
|
||||||
"ai": "6.0.149",
|
"ai": "6.0.158",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"diff": "8.0.2",
|
"diff": "8.0.2",
|
||||||
"dompurify": "3.3.1",
|
"dompurify": "3.3.1",
|
||||||
@@ -708,7 +707,7 @@
|
|||||||
|
|
||||||
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
"@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.16.1", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.93", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.69", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-hcXDU8QDwpAzLVTuY932TQVlIij9+iaVTxc5mPGY6yb//JMAAC5hMVhg93IrxlrxWLvMgjezNgoZGwquR+SGnw=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
"@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-rwLi/Rsuj2pYniQXIrvClHvXDzgM4UQHHnvHTWEF14efnlKclG/1ghpNC+adsRujAbCTr6gRsSbDE2vEqriV7g=="],
|
||||||
|
|
||||||
@@ -1162,8 +1161,6 @@
|
|||||||
|
|
||||||
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
"@gar/promise-retry": ["@gar/promise-retry@1.0.3", "", {}, "sha512-GmzA9ckNokPypTg10pgpeHNQe7ph+iIKKmhKu3Ob9ANkswreCx7R3cKmY781K8QK3AqVL3xVh9A42JvIAbkkSA=="],
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider": ["@gitlab/gitlab-ai-provider@3.6.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=2.0.0", "@ai-sdk/provider-utils": ">=3.0.0" } }, "sha512-8LmcIQ86xkMtC7L4P1/QYVEC+yKMTRerfPeniaaQGalnzXKtX6iMHLjLPOL9Rxp55lOXi6ed0WrFuJzZx+fNRg=="],
|
|
||||||
|
|
||||||
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
"@gitlab/opencode-gitlab-auth": ["@gitlab/opencode-gitlab-auth@1.3.3", "", { "dependencies": { "@fastify/rate-limit": "^10.2.0", "@opencode-ai/plugin": "*", "fastify": "^5.2.0", "open": "^10.0.0" } }, "sha512-FT+KsCmAJjtqWr1YAq0MywGgL9kaLQ4apmsoowAXrPqHtoYf2i/nY10/A+L06kNj22EATeEDRpbB1NWXMto/SA=="],
|
||||||
|
|
||||||
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
|
||||||
@@ -1540,7 +1537,7 @@
|
|||||||
|
|
||||||
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
"@opencode-ai/web": ["@opencode-ai/web@workspace:packages/web"],
|
||||||
|
|
||||||
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.4.2", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-uRQZ4da77gru1I7/lNGJhKbqEIY7o/sPsLlbCM97VY9muGDjM/TaJzuwqIviqKTtXLzF0WDj5qBAi6FhxjvlSg=="],
|
"@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.5.1", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-r1fJL1Cb3gQDa2MpWH/sfx1BsEW0uzlRriJM6eihaKqbtKDmZoBisF32VcVaQYassighX7NGCkF68EsrZA43uQ=="],
|
||||||
|
|
||||||
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
"@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
|
||||||
|
|
||||||
@@ -2386,7 +2383,7 @@
|
|||||||
|
|
||||||
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
"agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="],
|
||||||
|
|
||||||
"ai": ["ai@6.0.149", "", { "dependencies": { "@ai-sdk/gateway": "3.0.91", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-3asRb/m3ZGH7H4+VTuTgj8eQYJZ9IJUmV0ljLslY92mQp6Zj+NVn4SmFj0TBr2Y/wFBWC3xgn++47tSGOXxdbw=="],
|
"ai": ["ai@6.0.158", "", { "dependencies": { "@ai-sdk/gateway": "3.0.95", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-gLTp1UXFtMqKUi3XHs33K7UFglbvojkxF/aq337TxnLGOhHIW9+GyP2jwW4hYX87f1es+wId3VQoPRRu9zEStQ=="],
|
||||||
|
|
||||||
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
"ai-gateway-provider": ["ai-gateway-provider@3.1.2", "", { "optionalDependencies": { "@ai-sdk/amazon-bedrock": "^4.0.62", "@ai-sdk/anthropic": "^3.0.46", "@ai-sdk/azure": "^3.0.31", "@ai-sdk/cerebras": "^2.0.34", "@ai-sdk/cohere": "^3.0.21", "@ai-sdk/deepgram": "^2.0.20", "@ai-sdk/deepseek": "^2.0.20", "@ai-sdk/elevenlabs": "^2.0.20", "@ai-sdk/fireworks": "^2.0.34", "@ai-sdk/google": "^3.0.30", "@ai-sdk/google-vertex": "^4.0.61", "@ai-sdk/groq": "^3.0.24", "@ai-sdk/mistral": "^3.0.20", "@ai-sdk/openai": "^3.0.30", "@ai-sdk/perplexity": "^3.0.19", "@ai-sdk/xai": "^3.0.57", "@openrouter/ai-sdk-provider": "^2.2.3" }, "peerDependencies": { "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/provider": "^3.0.0", "@ai-sdk/provider-utils": "^4.0.0", "ai": "^6.0.0" } }, "sha512-krGNnJSoO/gJ7Hbe5nQDlsBpDUGIBGtMQTRUaW7s1MylsfvLduba0TLWzQaGtOmNRkP0pGhtGlwsnS6FNQMlyw=="],
|
||||||
|
|
||||||
@@ -5006,7 +5003,11 @@
|
|||||||
|
|
||||||
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
"@actions/http-client/undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.12", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.13.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FE3bZdEl62ojmy8x4FHqxq2+BuOHlcxiH5vaZ6aqHJr3AIZzwF5jfx8dEiU/X0a8RboyNDjmXjlbr8AdEyLgiA=="],
|
||||||
|
|
||||||
|
"@ai-sdk/amazon-bedrock/@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="],
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
@@ -5282,10 +5283,6 @@
|
|||||||
|
|
||||||
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
"@fastify/proxy-addr/ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider/openai": ["openai@6.33.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw=="],
|
|
||||||
|
|
||||||
"@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
|
||||||
|
|
||||||
"@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=="],
|
"@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=="],
|
||||||
|
|
||||||
"@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/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=="],
|
||||||
@@ -5554,7 +5551,9 @@
|
|||||||
|
|
||||||
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
"accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.91", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-J39Dh6Gyg6HjG3A7OFKnJMp3QyZ3Eex+XDiX8aFBdRwwZm3jGWaMhkCxQPH7yiQ9kRiErZwHXX/Oexx4SyGGGA=="],
|
"ai/@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.95", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZmUNNbZl3V42xwQzPaNUi+s8eqR2lnrxf0bvB6YbLXpLjHYv0k2Y78t12cNOfY0bxGeuVVTLyk856uLuQIuXEQ=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.83", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21", "@smithy/eventstream-codec": "^4.0.1", "@smithy/util-utf8": "^4.0.0", "aws4fetch": "^1.0.20" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-DoRpvIWGU/r83UeJAM9L93Lca8Kf/yP5fIhfEOltMPGP/PXrGe0BZaz0maLSRn8djJ6+HzWIsgu5ZI6bZqXEXg=="],
|
||||||
|
|
||||||
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
|
"ai-gateway-provider/@openrouter/ai-sdk-provider": ["@openrouter/ai-sdk-provider@2.3.3", "", { "peerDependencies": { "ai": "^6.0.0", "zod": "^3.25.0 || ^4.0.0" } }, "sha512-4fVteGkVedc7fGoA9+qJs4tpYwALezMq14m2Sjub3KmyRlksCbK+WJf67NPdGem8+NZrV2tAN42A1NU3+SiV3w=="],
|
||||||
|
|
||||||
@@ -5770,6 +5769,8 @@
|
|||||||
|
|
||||||
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
|
"opencode/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.67", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-FFX4P5Fd6lcQJc2OLngZQkbbJHa0IDDZi087Edb8qRZx6h90krtM61ArbMUL8us/7ZUwojCXnyJ/wQ2Eflx2jQ=="],
|
||||||
|
|
||||||
|
"opencode/@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=="],
|
||||||
|
|
||||||
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
"opencontrol/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.6.1", "", { "dependencies": { "content-type": "^1.0.5", "cors": "^2.8.5", "eventsource": "^3.0.2", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^4.1.0", "raw-body": "^3.0.0", "zod": "^3.23.8", "zod-to-json-schema": "^3.24.1" } }, "sha512-oxzMzYCkZHMntzuyerehK3fV6A2Kwh5BD6CGEJSVDU2QNEhfLOptf2X7esQgaHZXHZY0oHmMsOtIDLP71UJXgA=="],
|
||||||
|
|
||||||
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
"opencontrol/@tsconfig/bun": ["@tsconfig/bun@1.0.7", "", {}, "sha512-udGrGJBNQdXGVulehc1aWT73wkR9wdaGBtB6yL70RJsqwW/yJhIg6ZbRlPOfIUiFNrnBuYLBi9CSmMKfDC7dvA=="],
|
||||||
@@ -5952,8 +5953,6 @@
|
|||||||
|
|
||||||
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
"@actions/github/@octokit/plugin-rest-endpoint-methods/@octokit/types": ["@octokit/types@12.6.0", "", { "dependencies": { "@octokit/openapi-types": "^20.0.0" } }, "sha512-1rhSOfRa6H9w4YwK0yrf5faDaDTb+yLyBUKOCV4xtCDB5VmIPqd/v9yr9o6SAzOAlRxMiRiCic6JVM1/kunVkw=="],
|
||||||
|
|
||||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
|
||||||
|
|
||||||
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
"@ai-sdk/azure/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
@@ -6450,6 +6449,8 @@
|
|||||||
|
|
||||||
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
"accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="],
|
||||||
|
|
||||||
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
"ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
"ansi-align/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
@@ -6822,6 +6823,8 @@
|
|||||||
|
|
||||||
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
"@solidjs/start/shiki/@shikijs/engine-javascript/oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="],
|
||||||
|
|
||||||
|
"ai-gateway-provider/@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||||
|
|
||||||
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
"ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
"app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-LkmY8hoc1OkJWnTxberdbo2wKlMTmq1NlyOndHSoR1Y=",
|
"x86_64-linux": "sha256-fNRQYkucjXr1D61HJRScJpDa6+oBdyhgTBxCu+PE2kQ=",
|
||||||
"aarch64-linux": "sha256-TOojgsW0rpYiSuDCV/ZmAuewmkYitB6GBmxus3pXpNo=",
|
"aarch64-linux": "sha256-V8J6kn2nSdXrplyqi6aIqNlHcVjSxvye+yC/YFO7PF4=",
|
||||||
"aarch64-darwin": "sha256-Vf+XYCm3YQQ5HBUK7UDXvEKEDeFUMwlGXQsmzrK+a1E=",
|
"aarch64-darwin": "sha256-6cLmUJVUycGALCmslXuloVGBSlFOSHRjsWjx7KOW8rg=",
|
||||||
"x86_64-darwin": "sha256-68OlpJhmf5tpapxYGhcYjhx9716X+BFoIHTGosA3Yg4="
|
"x86_64-darwin": "sha256-kcOSO3NFIJh79ylLotG41ovWLQfH5kh1WYFghUu+4HE="
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
"drizzle-kit": "1.0.0-beta.19-d95b7a4",
|
||||||
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
"drizzle-orm": "1.0.0-beta.19-d95b7a4",
|
||||||
"effect": "4.0.0-beta.46",
|
"effect": "4.0.0-beta.46",
|
||||||
"ai": "6.0.149",
|
"ai": "6.0.158",
|
||||||
"cross-spawn": "7.0.6",
|
"cross-spawn": "7.0.6",
|
||||||
"hono": "4.10.7",
|
"hono": "4.10.7",
|
||||||
"hono-openapi": "1.1.2",
|
"hono-openapi": "1.1.2",
|
||||||
|
|||||||
@@ -316,7 +316,8 @@
|
|||||||
|
|
||||||
/* Download Hero Section */
|
/* Download Hero Section */
|
||||||
[data-component="download-hero"] {
|
[data-component="download-hero"] {
|
||||||
display: grid;
|
/* display: grid; */
|
||||||
|
display: none;
|
||||||
grid-template-columns: 260px 1fr;
|
grid-template-columns: 260px 1fr;
|
||||||
gap: 4rem;
|
gap: 4rem;
|
||||||
padding-bottom: 2rem;
|
padding-bottom: 2rem;
|
||||||
|
|||||||
@@ -39,6 +39,11 @@
|
|||||||
"bun": "./src/pty/pty.bun.ts",
|
"bun": "./src/pty/pty.bun.ts",
|
||||||
"node": "./src/pty/pty.node.ts",
|
"node": "./src/pty/pty.node.ts",
|
||||||
"default": "./src/pty/pty.bun.ts"
|
"default": "./src/pty/pty.bun.ts"
|
||||||
|
},
|
||||||
|
"#hono": {
|
||||||
|
"bun": "./src/server/adapter.bun.ts",
|
||||||
|
"node": "./src/server/adapter.node.ts",
|
||||||
|
"default": "./src/server/adapter.bun.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -78,7 +83,7 @@
|
|||||||
"@actions/core": "1.11.1",
|
"@actions/core": "1.11.1",
|
||||||
"@actions/github": "6.0.1",
|
"@actions/github": "6.0.1",
|
||||||
"@agentclientprotocol/sdk": "0.16.1",
|
"@agentclientprotocol/sdk": "0.16.1",
|
||||||
"@ai-sdk/amazon-bedrock": "4.0.83",
|
"@ai-sdk/amazon-bedrock": "4.0.93",
|
||||||
"@ai-sdk/anthropic": "3.0.67",
|
"@ai-sdk/anthropic": "3.0.67",
|
||||||
"@ai-sdk/azure": "3.0.49",
|
"@ai-sdk/azure": "3.0.49",
|
||||||
"@ai-sdk/cerebras": "2.0.41",
|
"@ai-sdk/cerebras": "2.0.41",
|
||||||
@@ -90,7 +95,7 @@
|
|||||||
"@ai-sdk/groq": "3.0.31",
|
"@ai-sdk/groq": "3.0.31",
|
||||||
"@ai-sdk/mistral": "3.0.27",
|
"@ai-sdk/mistral": "3.0.27",
|
||||||
"@ai-sdk/openai": "3.0.48",
|
"@ai-sdk/openai": "3.0.48",
|
||||||
"@ai-sdk/openai-compatible": "2.0.37",
|
"@ai-sdk/openai-compatible": "2.0.41",
|
||||||
"@ai-sdk/perplexity": "3.0.26",
|
"@ai-sdk/perplexity": "3.0.26",
|
||||||
"@ai-sdk/provider": "3.0.8",
|
"@ai-sdk/provider": "3.0.8",
|
||||||
"@ai-sdk/provider-utils": "4.0.23",
|
"@ai-sdk/provider-utils": "4.0.23",
|
||||||
@@ -100,7 +105,6 @@
|
|||||||
"@aws-sdk/credential-providers": "3.993.0",
|
"@aws-sdk/credential-providers": "3.993.0",
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@effect/platform-node": "catalog:",
|
"@effect/platform-node": "catalog:",
|
||||||
"@gitlab/gitlab-ai-provider": "3.6.0",
|
|
||||||
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
"@gitlab/opencode-gitlab-auth": "1.3.3",
|
||||||
"@hono/node-server": "1.19.11",
|
"@hono/node-server": "1.19.11",
|
||||||
"@hono/node-ws": "1.3.0",
|
"@hono/node-ws": "1.3.0",
|
||||||
@@ -116,7 +120,7 @@
|
|||||||
"@opencode-ai/script": "workspace:*",
|
"@opencode-ai/script": "workspace:*",
|
||||||
"@opencode-ai/sdk": "workspace:*",
|
"@opencode-ai/sdk": "workspace:*",
|
||||||
"@opencode-ai/util": "workspace:*",
|
"@opencode-ai/util": "workspace:*",
|
||||||
"@openrouter/ai-sdk-provider": "2.4.2",
|
"@openrouter/ai-sdk-provider": "2.5.1",
|
||||||
"@opentui/core": "0.1.97",
|
"@opentui/core": "0.1.97",
|
||||||
"@opentui/solid": "0.1.97",
|
"@opentui/solid": "0.1.97",
|
||||||
"@parcel/watcher": "2.5.1",
|
"@parcel/watcher": "2.5.1",
|
||||||
|
|||||||
@@ -217,6 +217,7 @@ Fully migrated (single namespace, InstanceState where needed, flattened facade):
|
|||||||
- [x] `SessionSummary` — `session/summary.ts`
|
- [x] `SessionSummary` — `session/summary.ts`
|
||||||
- [x] `SessionRevert` — `session/revert.ts`
|
- [x] `SessionRevert` — `session/revert.ts`
|
||||||
- [x] `Instruction` — `session/instruction.ts`
|
- [x] `Instruction` — `session/instruction.ts`
|
||||||
|
- [x] `SystemPrompt` — `session/system.ts`
|
||||||
- [x] `Provider` — `provider/provider.ts`
|
- [x] `Provider` — `provider/provider.ts`
|
||||||
- [x] `Storage` — `storage/storage.ts`
|
- [x] `Storage` — `storage/storage.ts`
|
||||||
- [x] `ShareNext` — `share/share-next.ts`
|
- [x] `ShareNext` — `share/share-next.ts`
|
||||||
@@ -229,24 +230,24 @@ Still open:
|
|||||||
|
|
||||||
## Tool interface → Effect
|
## Tool interface → Effect
|
||||||
|
|
||||||
Once individual tools are effectified, change `Tool.Info` (`tool/tool.ts`) so `init` and `execute` return `Effect` instead of `Promise`. This lets tool implementations compose natively with the Effect pipeline rather than being wrapped in `Effect.promise()` at the call site. Requires:
|
`Tool.Def.execute` and `Tool.Info.init` already return `Effect` on this branch. Tool definitions should now stay Effect-native all the way through initialization instead of using Promise-returning init callbacks. Tools can still use lazy init callbacks when they need instance-bound state at init time, but those callbacks should return `Effect`, not `Promise`. Remaining work is:
|
||||||
|
|
||||||
1. Migrate each tool to return Effects
|
1. Migrate each tool body to return Effects
|
||||||
2. Update `Tool.define()` factory to work with Effects
|
2. Keep `Tool.define()` inputs Effect-native
|
||||||
3. Update `SessionPrompt` to `yield*` tool results instead of `await`ing
|
3. Update remaining callers to `yield*` tool initialization instead of `await`ing
|
||||||
|
|
||||||
### Tool migration details
|
### Tool migration details
|
||||||
|
|
||||||
Until the tool interface itself returns `Effect`, use this transitional pattern for migrated tools:
|
With `Tool.Info.init()` now effectful, use this transitional pattern for migrated tools that still need Promise-based boundaries internally:
|
||||||
|
|
||||||
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
|
- `Tool.defineEffect(...)` should `yield*` the services the tool depends on and close over them in the returned tool definition.
|
||||||
- Keep the bridge at the Promise boundary only. Prefer a single `Effect.runPromise(...)` in the temporary `async execute(...)` implementation, and move the inner logic into `Effect.fn(...)` helpers instead of scattering `runPromise` islands through the tool body.
|
- Keep the bridge at the Promise boundary only inside the tool body when required by external APIs. Do not return Promise-based init callbacks from `Tool.define()`.
|
||||||
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
|
- If a tool starts requiring new services, wire them into `ToolRegistry.defaultLayer` so production callers resolve the same dependencies as tests.
|
||||||
|
|
||||||
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
|
Tool tests should use the existing Effect helpers in `packages/opencode/test/lib/effect.ts`:
|
||||||
|
|
||||||
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
|
- Use `testEffect(...)` / `it.live(...)` instead of creating fake local wrappers around effectful tools.
|
||||||
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* Effect.promise(() => info.init())`.
|
- Yield the real tool export, then initialize it: `const info = yield* ReadTool`, `const tool = yield* info.init()`.
|
||||||
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
|
- Run tests inside a real instance with `provideTmpdirInstance(...)` or `provideInstance(tmpdirScoped(...))` so instance-scoped services resolve exactly as they do in production.
|
||||||
|
|
||||||
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later.
|
This keeps migrated tool tests aligned with the production service graph today, and makes the eventual `Tool.Info` → `Effect` cleanup mostly mechanical later.
|
||||||
@@ -340,3 +341,47 @@ For each service, the migration is roughly:
|
|||||||
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
- `ShareNext` — migrated 2026-04-11. Swapped remaining async callers to `AppRuntime.runPromise(ShareNext.Service.use(...))`, removed the `makeRuntime(...)` facade, and kept instance bootstrap on the shared app runtime.
|
||||||
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
- `SessionTodo` — migrated 2026-04-10. Already matched the target service shape in `session/todo.ts`: single namespace, traced Effect methods, and no `makeRuntime(...)` facade remained; checklist updated to reflect the completed migration.
|
||||||
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
- `Storage` — migrated 2026-04-10. One production caller (`Session.diff`) and all storage.test.ts tests converted to effectful style. Facades and `makeRuntime` removed.
|
||||||
|
- `SessionRunState` — migrated 2026-04-11. Single caller in `server/routes/session.ts` converted; facade removed.
|
||||||
|
- `Account` — migrated 2026-04-11. Callers in `server/routes/experimental.ts` and `cli/cmd/account.ts` converted; facade removed.
|
||||||
|
- `Instruction` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||||
|
- `FileTime` — migrated 2026-04-11. Test-only callers converted; facade removed.
|
||||||
|
- `FileWatcher` — migrated 2026-04-11. Callers in `project/bootstrap.ts` and test converted; facade removed.
|
||||||
|
- `Question` — migrated 2026-04-11. Callers in `server/routes/question.ts` and test converted; facade removed.
|
||||||
|
- `Truncate` — migrated 2026-04-11. Caller in `tool/tool.ts` and test converted; facade removed.
|
||||||
|
|
||||||
|
## Route handler effectification
|
||||||
|
|
||||||
|
Route handlers should wrap their entire body in a single `AppRuntime.runPromise(Effect.gen(...))` call, yielding services from context rather than calling facades one-by-one. This eliminates multiple `runPromise` round-trips and lets handlers compose naturally.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Before — one facade call per service
|
||||||
|
;async (c) => {
|
||||||
|
await SessionRunState.assertNotBusy(id)
|
||||||
|
await Session.removeMessage({ sessionID: id, messageID })
|
||||||
|
return c.json(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After — one Effect.gen, yield services from context
|
||||||
|
;async (c) => {
|
||||||
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const state = yield* SessionRunState.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
yield* state.assertNotBusy(id)
|
||||||
|
yield* session.removeMessage({ sessionID: id, messageID })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
When migrating, always use `{ concurrency: "unbounded" }` with `Effect.all` — route handlers should run independent service calls in parallel, not sequentially.
|
||||||
|
|
||||||
|
Route files to convert (each handler that calls facades should be wrapped):
|
||||||
|
|
||||||
|
- [ ] `server/routes/session.ts` — heaviest; uses Session, SessionPrompt, SessionRevert, SessionCompaction, SessionShare, SessionSummary, SessionRunState, Agent, Permission, Bus
|
||||||
|
- [ ] `server/routes/global.ts` — uses Config, Project, Provider, Vcs, Snapshot, Agent
|
||||||
|
- [ ] `server/routes/provider.ts` — uses Provider, Auth, Config
|
||||||
|
- [ ] `server/routes/question.ts` — uses Question
|
||||||
|
- [ ] `server/routes/pty.ts` — uses Pty
|
||||||
|
- [ ] `server/routes/experimental.ts` — uses Account, ToolRegistry, Agent, MCP, Config
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
HttpClientResponse,
|
HttpClientResponse,
|
||||||
} from "effect/unstable/http"
|
} from "effect/unstable/http"
|
||||||
|
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
import { AccountRepo, type AccountRow } from "./repo"
|
import { AccountRepo, type AccountRow } from "./repo"
|
||||||
import { normalizeServerUrl } from "./url"
|
import { normalizeServerUrl } from "./url"
|
||||||
@@ -454,18 +453,4 @@ export namespace Account {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AccountRepo.layer), Layer.provide(FetchHttpClient.layer))
|
||||||
|
|
||||||
export const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function active(): Promise<Info | undefined> {
|
|
||||||
return Option.getOrUndefined(await runPromise((service) => service.active()))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function orgsByAccount(): Promise<readonly AccountOrgs[]> {
|
|
||||||
return runPromise((service) => service.orgsByAccount())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function switchOrg(accountID: AccountID, orgID: OrgID) {
|
|
||||||
return runPromise((service) => service.use(accountID, Option.some(orgID)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -398,13 +398,11 @@ export namespace Agent {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = Layer.suspend(() =>
|
export const defaultLayer = layer.pipe(
|
||||||
layer.pipe(
|
|
||||||
Layer.provide(Provider.defaultLayer),
|
Layer.provide(Provider.defaultLayer),
|
||||||
Layer.provide(Auth.defaultLayer),
|
Layer.provide(Auth.defaultLayer),
|
||||||
Layer.provide(Config.defaultLayer),
|
Layer.provide(Config.defaultLayer),
|
||||||
Layer.provide(Skill.defaultLayer),
|
Layer.provide(Skill.defaultLayer),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Duration, Effect, Match, Option } from "effect"
|
|||||||
import { UI } from "../ui"
|
import { UI } from "../ui"
|
||||||
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
import { AccountID, Account, OrgID, PollExpired, type PollResult } from "@/account"
|
||||||
import { type AccountError } from "@/account/schema"
|
import { type AccountError } from "@/account/schema"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import * as Prompt from "../effect/prompt"
|
import * as Prompt from "../effect/prompt"
|
||||||
import open from "open"
|
import open from "open"
|
||||||
|
|
||||||
@@ -182,7 +183,7 @@ export const LoginCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => loginEffect(args.url))
|
await AppRuntime.runPromise(loginEffect(args.url))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -196,7 +197,7 @@ export const LogoutCommand = cmd({
|
|||||||
}),
|
}),
|
||||||
async handler(args) {
|
async handler(args) {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => logoutEffect(args.email))
|
await AppRuntime.runPromise(logoutEffect(args.email))
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -205,7 +206,7 @@ export const SwitchCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => switchEffect())
|
await AppRuntime.runPromise(switchEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ export const OrgsCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => orgsEffect())
|
await AppRuntime.runPromise(orgsEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -223,7 +224,7 @@ export const OpenCommand = cmd({
|
|||||||
describe: false,
|
describe: false,
|
||||||
async handler() {
|
async handler() {
|
||||||
UI.empty()
|
UI.empty()
|
||||||
await Account.runPromise((_svc) => openEffect())
|
await AppRuntime.runPromise(openEffect())
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
|
|||||||
import { TuiConfig } from "@/config/tui"
|
import { TuiConfig } from "@/config/tui"
|
||||||
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
|
||||||
import { FormatError, FormatUnknownError } from "@/cli/error"
|
import { FormatError, FormatUnknownError } from "@/cli/error"
|
||||||
|
import { Keybind } from "@/util/keybind"
|
||||||
|
|
||||||
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
async function getTerminalBackgroundColor(): Promise<"dark" | "light"> {
|
||||||
// can't set raw mode if not a TTY
|
// can't set raw mode if not a TTY
|
||||||
@@ -310,7 +311,7 @@ function App(props: { onSnapshot?: () => Promise<string[]> }) {
|
|||||||
// - Ctrl+C copies and dismisses selection
|
// - Ctrl+C copies and dismisses selection
|
||||||
// - Esc dismisses selection
|
// - Esc dismisses selection
|
||||||
// - Most other key input dismisses selection and is passed through
|
// - Most other key input dismisses selection and is passed through
|
||||||
if (evt.ctrl && evt.name === "c") {
|
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||||
if (!Selection.copy(renderer, toast)) {
|
if (!Selection.copy(renderer, toast)) {
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function DialogMcp() {
|
|||||||
|
|
||||||
const keybinds = createMemo(() => [
|
const keybinds = createMemo(() => [
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("space")[0],
|
keybind: Keybind.parseOne("space"),
|
||||||
title: "toggle",
|
title: "toggle",
|
||||||
onTrigger: async (option: DialogSelectOption<string>) => {
|
onTrigger: async (option: DialogSelectOption<string>) => {
|
||||||
// Prevent toggling while an operation is already in progress
|
// Prevent toggling while an operation is already in progress
|
||||||
|
|||||||
@@ -162,7 +162,7 @@ export function DialogSessionList() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
keybind: Keybind.parse("ctrl+w")[0],
|
keybind: Keybind.parseOne("ctrl+w"),
|
||||||
title: "new workspace",
|
title: "new workspace",
|
||||||
side: "right",
|
side: "right",
|
||||||
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
disabled: !Flag.OPENCODE_EXPERIMENTAL_WORKSPACES,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { createSignal } from "solid-js"
|
|||||||
import { Installation } from "@/installation"
|
import { Installation } from "@/installation"
|
||||||
import { win32FlushInputBuffer } from "../win32"
|
import { win32FlushInputBuffer } from "../win32"
|
||||||
import { getScrollAcceleration } from "../util/scroll"
|
import { getScrollAcceleration } from "../util/scroll"
|
||||||
|
import { Keybind } from "@/util/keybind"
|
||||||
|
|
||||||
export function ErrorComponent(props: {
|
export function ErrorComponent(props: {
|
||||||
error: Error
|
error: Error
|
||||||
@@ -25,7 +26,7 @@ export function ErrorComponent(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (evt.ctrl && evt.name === "c") {
|
if (Keybind.matchParsedKey("ctrl+c", evt)) {
|
||||||
handleExit()
|
handleExit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -589,6 +589,13 @@ export function Prompt(props: PromptProps) {
|
|||||||
])
|
])
|
||||||
|
|
||||||
async function submit() {
|
async function submit() {
|
||||||
|
// IME: double-defer may fire before onContentChange flushes the last
|
||||||
|
// composed character (e.g. Korean hangul) to the store, so read
|
||||||
|
// plainText directly and sync before any downstream reads.
|
||||||
|
if (input && !input.isDestroyed && input.plainText !== store.prompt.input) {
|
||||||
|
setStore("prompt", "input", input.plainText)
|
||||||
|
syncExtmarksWithPromptParts()
|
||||||
|
}
|
||||||
if (props.disabled) return
|
if (props.disabled) return
|
||||||
if (autocomplete?.visible) return
|
if (autocomplete?.visible) return
|
||||||
if (!store.prompt.input) return
|
if (!store.prompt.input) return
|
||||||
@@ -994,7 +1001,11 @@ export function Prompt(props: PromptProps) {
|
|||||||
input.cursorOffset = input.plainText.length
|
input.cursorOffset = input.plainText.length
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onSubmit={submit}
|
onSubmit={() => {
|
||||||
|
// IME: double-defer so the last composed character (e.g. Korean
|
||||||
|
// hangul) is flushed to plainText before we read it for submission.
|
||||||
|
setTimeout(() => setTimeout(() => submit(), 0), 0)
|
||||||
|
}}
|
||||||
onPaste={async (event: PasteEvent) => {
|
onPaste={async (event: PasteEvent) => {
|
||||||
if (props.disabled) {
|
if (props.disabled) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
|
|||||||
@@ -137,12 +137,18 @@ export const TuiThreadCommand = cmd({
|
|||||||
),
|
),
|
||||||
})
|
})
|
||||||
worker.onerror = (e) => {
|
worker.onerror = (e) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error("thread error", {
|
||||||
|
message: e.message,
|
||||||
|
filename: e.filename,
|
||||||
|
lineno: e.lineno,
|
||||||
|
colno: e.colno,
|
||||||
|
error: e.error,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const client = Rpc.client<typeof rpc>(worker)
|
const client = Rpc.client<typeof rpc>(worker)
|
||||||
const error = (e: unknown) => {
|
const error = (e: unknown) => {
|
||||||
Log.Default.error(e)
|
Log.Default.error("process error", { error: errorMessage(e) })
|
||||||
}
|
}
|
||||||
const reload = () => {
|
const reload = () => {
|
||||||
client.call("reload", undefined).catch((err) => {
|
client.call("reload", undefined).catch((err) => {
|
||||||
|
|||||||
@@ -195,8 +195,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
|
|||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
setStore("input", "keyboard")
|
setStore("input", "keyboard")
|
||||||
|
|
||||||
if (evt.name === "up" || (evt.ctrl && evt.name === "p")) move(-1)
|
if (evt.name === "up" || Keybind.matchParsedKey("ctrl+p", evt)) move(-1)
|
||||||
if (evt.name === "down" || (evt.ctrl && evt.name === "n")) move(1)
|
if (evt.name === "down" || Keybind.matchParsedKey("ctrl+n", evt)) move(1)
|
||||||
if (evt.name === "pageup") move(-10)
|
if (evt.name === "pageup") move(-10)
|
||||||
if (evt.name === "pagedown") move(10)
|
if (evt.name === "pagedown") move(10)
|
||||||
if (evt.name === "home") moveTo(0)
|
if (evt.name === "home") moveTo(0)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { createStore } from "solid-js/store"
|
|||||||
import { useToast } from "./toast"
|
import { useToast } from "./toast"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Selection } from "@tui/util/selection"
|
import { Selection } from "@tui/util/selection"
|
||||||
|
import { Keybind } from "@/util/keybind"
|
||||||
|
|
||||||
export function Dialog(
|
export function Dialog(
|
||||||
props: ParentProps<{
|
props: ParentProps<{
|
||||||
@@ -72,12 +73,13 @@ function init() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const renderer = useRenderer()
|
const renderer = useRenderer()
|
||||||
|
|
||||||
useKeyboard((evt) => {
|
useKeyboard((evt) => {
|
||||||
if (store.stack.length === 0) return
|
if (store.stack.length === 0) return
|
||||||
if (evt.defaultPrevented) return
|
if (evt.defaultPrevented) return
|
||||||
if ((evt.name === "escape" || (evt.ctrl && evt.name === "c")) && renderer.getSelection()?.getSelectedText()) return
|
const isCtrlC = Keybind.matchParsedKey("ctrl+c", evt)
|
||||||
if (evt.name === "escape" || (evt.ctrl && evt.name === "c")) {
|
|
||||||
|
if ((evt.name === "escape" || isCtrlC) && renderer.getSelection()?.getSelectedText()) return
|
||||||
|
if (evt.name === "escape" || isCtrlC) {
|
||||||
if (renderer.getSelection()) {
|
if (renderer.getSelection()) {
|
||||||
renderer.clearSelection()
|
renderer.clearSelection()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { pathToFileURL } from "url"
|
|||||||
import os from "os"
|
import os from "os"
|
||||||
import { Process } from "../util/process"
|
import { Process } from "../util/process"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { ModelsDev } from "../provider/models"
|
|
||||||
import { mergeDeep, pipe, unique } from "remeda"
|
import { mergeDeep, pipe, unique } from "remeda"
|
||||||
import { Global } from "../global"
|
import { Global } from "../global"
|
||||||
import fsNode from "fs/promises"
|
import fsNode from "fs/promises"
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Layer, ManagedRuntime } from "effect"
|
import { Layer, ManagedRuntime } from "effect"
|
||||||
import { memoMap } from "./run-service"
|
import { memoMap } from "./run-service"
|
||||||
|
|
||||||
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { Format } from "@/format"
|
import { Format } from "@/format"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
|
||||||
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer)
|
export const BootstrapLayer = Layer.mergeAll(Format.defaultLayer, ShareNext.defaultLayer, FileWatcher.defaultLayer)
|
||||||
|
|
||||||
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
export const BootstrapRuntime = ManagedRuntime.make(BootstrapLayer, { memoMap })
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
import { DateTime, Effect, Layer, Option, Semaphore, Context } from "effect"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import type { SessionID } from "@/session/schema"
|
import type { SessionID } from "@/session/schema"
|
||||||
@@ -112,22 +111,4 @@ export namespace FileTime {
|
|||||||
).pipe(Layer.orDie)
|
).pipe(Layer.orDie)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function read(sessionID: SessionID, file: string) {
|
|
||||||
return runPromise((s) => s.read(sessionID, file))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function get(sessionID: SessionID, file: string) {
|
|
||||||
return runPromise((s) => s.get(sessionID, file))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function assert(sessionID: SessionID, filepath: string) {
|
|
||||||
return runPromise((s) => s.assert(sessionID, filepath))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function withLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
|
|
||||||
return runPromise((s) => s.withLock(filepath, () => Effect.promise(fn)))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import z from "zod"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { Git } from "@/git"
|
import { Git } from "@/git"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
@@ -161,10 +160,4 @@ export namespace FileWatcher {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer), Layer.provide(Git.defaultLayer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function init() {
|
|
||||||
return runPromise((svc) => svc.init())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { iife } from "@/util/iife"
|
|||||||
import { Log } from "../../util/log"
|
import { Log } from "../../util/log"
|
||||||
import { setTimeout as sleep } from "node:timers/promises"
|
import { setTimeout as sleep } from "node:timers/promises"
|
||||||
import { CopilotModels } from "./models"
|
import { CopilotModels } from "./models"
|
||||||
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
|
|
||||||
const log = Log.create({ service: "plugin.copilot" })
|
const log = Log.create({ service: "plugin.copilot" })
|
||||||
|
|
||||||
@@ -27,11 +28,27 @@ function base(enterpriseUrl?: string) {
|
|||||||
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
return enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : "https://api.githubcopilot.com"
|
||||||
}
|
}
|
||||||
|
|
||||||
function fix(model: Model): Model {
|
// Check if a message is a synthetic user msg used to attach an image from a tool call
|
||||||
|
function imgMsg(msg: any): boolean {
|
||||||
|
if (msg?.role !== "user") return false
|
||||||
|
|
||||||
|
// Handle the 3 api formats
|
||||||
|
|
||||||
|
const content = msg.content
|
||||||
|
if (typeof content === "string") return content === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT
|
||||||
|
if (!Array.isArray(content)) return false
|
||||||
|
return content.some(
|
||||||
|
(part: any) =>
|
||||||
|
(part?.type === "text" || part?.type === "input_text") && part.text === MessageV2.SYNTHETIC_ATTACHMENT_PROMPT,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fix(model: Model, url: string): Model {
|
||||||
return {
|
return {
|
||||||
...model,
|
...model,
|
||||||
api: {
|
api: {
|
||||||
...model.api,
|
...model.api,
|
||||||
|
url,
|
||||||
npm: "@ai-sdk/github-copilot",
|
npm: "@ai-sdk/github-copilot",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -44,19 +61,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
id: "github-copilot",
|
id: "github-copilot",
|
||||||
async models(provider, ctx) {
|
async models(provider, ctx) {
|
||||||
if (ctx.auth?.type !== "oauth") {
|
if (ctx.auth?.type !== "oauth") {
|
||||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model, base())]))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const auth = ctx.auth
|
||||||
|
|
||||||
return CopilotModels.get(
|
return CopilotModels.get(
|
||||||
base(ctx.auth.enterpriseUrl),
|
base(auth.enterpriseUrl),
|
||||||
{
|
{
|
||||||
Authorization: `Bearer ${ctx.auth.refresh}`,
|
Authorization: `Bearer ${auth.refresh}`,
|
||||||
"User-Agent": `opencode/${Installation.VERSION}`,
|
"User-Agent": `opencode/${Installation.VERSION}`,
|
||||||
},
|
},
|
||||||
provider.models,
|
provider.models,
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
log.error("failed to fetch copilot models", { error })
|
log.error("failed to fetch copilot models", { error })
|
||||||
return Object.fromEntries(Object.entries(provider.models).map(([id, model]) => [id, fix(model)]))
|
return Object.fromEntries(
|
||||||
|
Object.entries(provider.models).map(([id, model]) => [id, fix(model, base(auth.enterpriseUrl))]),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -66,10 +87,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
const info = await getAuth()
|
const info = await getAuth()
|
||||||
if (!info || info.type !== "oauth") return {}
|
if (!info || info.type !== "oauth") return {}
|
||||||
|
|
||||||
const baseURL = base(info.enterpriseUrl)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseURL,
|
|
||||||
apiKey: "",
|
apiKey: "",
|
||||||
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
async fetch(request: RequestInfo | URL, init?: RequestInit) {
|
||||||
const info = await getAuth()
|
const info = await getAuth()
|
||||||
@@ -88,7 +106,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
(msg: any) =>
|
(msg: any) =>
|
||||||
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
Array.isArray(msg.content) && msg.content.some((part: any) => part.type === "image_url"),
|
||||||
),
|
),
|
||||||
isAgent: last?.role !== "user",
|
isAgent: last?.role !== "user" || imgMsg(last),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,7 +118,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
(item: any) =>
|
(item: any) =>
|
||||||
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
Array.isArray(item?.content) && item.content.some((part: any) => part.type === "input_image"),
|
||||||
),
|
),
|
||||||
isAgent: last?.role !== "user",
|
isAgent: last?.role !== "user" || imgMsg(last),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +140,7 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise<Hooks> {
|
|||||||
part.content.some((nested: any) => nested?.type === "image")),
|
part.content.some((nested: any) => nested?.type === "image")),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
isAgent: !(last?.role === "user" && hasNonToolCalls),
|
isAgent: !(last?.role === "user" && hasNonToolCalls) || imgMsg(last),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|||||||
@@ -52,13 +52,15 @@ export namespace CopilotModels {
|
|||||||
(remote.capabilities.supports.vision ?? false) ||
|
(remote.capabilities.supports.vision ?? false) ||
|
||||||
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
(remote.capabilities.limits.vision?.supported_media_types ?? []).some((item) => item.startsWith("image/"))
|
||||||
|
|
||||||
|
const isMsgApi = remote.supported_endpoints?.includes("/v1/messages")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: key,
|
id: key,
|
||||||
providerID: "github-copilot",
|
providerID: "github-copilot",
|
||||||
api: {
|
api: {
|
||||||
id: remote.id,
|
id: remote.id,
|
||||||
url,
|
url: isMsgApi ? `${url}/v1` : url,
|
||||||
npm: "@ai-sdk/github-copilot",
|
npm: isMsgApi ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot",
|
||||||
},
|
},
|
||||||
// API response wins
|
// API response wins
|
||||||
status: "active",
|
status: "active",
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ export namespace Plugin {
|
|||||||
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
Authorization: `Basic ${Buffer.from(`${Flag.OPENCODE_SERVER_USERNAME ?? "opencode"}:${Flag.OPENCODE_SERVER_PASSWORD}`).toString("base64")}`,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
fetch: async (...args) => Server.Default().app.fetch(...args),
|
fetch: async (...args) => (await Server.Default()).app.fetch(...args),
|
||||||
})
|
})
|
||||||
const cfg = yield* config.get()
|
const cfg = yield* config.get()
|
||||||
const input: PluginInput = {
|
const input: PluginInput = {
|
||||||
@@ -210,13 +210,15 @@ export namespace Plugin {
|
|||||||
return message
|
return message
|
||||||
},
|
},
|
||||||
}).pipe(
|
}).pipe(
|
||||||
Effect.catch((message) =>
|
Effect.catch(() => {
|
||||||
bus.publish(Session.Event.Error, {
|
// TODO: make proper events for this
|
||||||
error: new NamedError.Unknown({
|
// bus.publish(Session.Event.Error, {
|
||||||
message: `Failed to load plugin ${load.spec}: ${message}`,
|
// error: new NamedError.Unknown({
|
||||||
}).toObject(),
|
// message: `Failed to load plugin ${load.spec}: ${message}`,
|
||||||
|
// }).toObject(),
|
||||||
|
// })
|
||||||
|
return Effect.void
|
||||||
}),
|
}),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Plugin } from "../plugin"
|
|||||||
import { Format } from "../format"
|
import { Format } from "../format"
|
||||||
import { LSP } from "../lsp"
|
import { LSP } from "../lsp"
|
||||||
import { File } from "../file"
|
import { File } from "../file"
|
||||||
import { FileWatcher } from "../file/watcher"
|
|
||||||
import { Snapshot } from "../snapshot"
|
import { Snapshot } from "../snapshot"
|
||||||
import { Project } from "./project"
|
import { Project } from "./project"
|
||||||
import { Vcs } from "./vcs"
|
import { Vcs } from "./vcs"
|
||||||
@@ -11,6 +10,7 @@ import { Command } from "../command"
|
|||||||
import { Instance } from "./instance"
|
import { Instance } from "./instance"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
import { BootstrapRuntime } from "@/effect/bootstrap-runtime"
|
||||||
|
import { FileWatcher } from "@/file/watcher"
|
||||||
import { ShareNext } from "@/share/share-next"
|
import { ShareNext } from "@/share/share-next"
|
||||||
|
|
||||||
export async function InstanceBootstrap() {
|
export async function InstanceBootstrap() {
|
||||||
@@ -20,7 +20,7 @@ export async function InstanceBootstrap() {
|
|||||||
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
void BootstrapRuntime.runPromise(Format.Service.use((svc) => svc.init()))
|
||||||
await LSP.init()
|
await LSP.init()
|
||||||
File.init()
|
File.init()
|
||||||
FileWatcher.init()
|
void BootstrapRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
||||||
Vcs.init()
|
Vcs.init()
|
||||||
Snapshot.init()
|
Snapshot.init()
|
||||||
|
|
||||||
|
|||||||
+63059
-58317
File diff suppressed because it is too large
Load Diff
@@ -774,7 +774,10 @@ export namespace ProviderTransform {
|
|||||||
result["chat_template_args"] = { enable_thinking: true }
|
result["chat_template_args"] = { enable_thinking: true }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (["zai", "zhipuai"].includes(input.model.providerID) && input.model.api.npm === "@ai-sdk/openai-compatible") {
|
if (
|
||||||
|
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
|
||||||
|
input.model.api.npm === "@ai-sdk/openai-compatible"
|
||||||
|
) {
|
||||||
result["thinking"] = {
|
result["thinking"] = {
|
||||||
type: "enabled",
|
type: "enabled",
|
||||||
clear_thinking: false,
|
clear_thinking: false,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Deferred, Effect, Layer, Schema, Context } from "effect"
|
|||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { BusEvent } from "@/bus/bus-event"
|
import { BusEvent } from "@/bus/bus-event"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { SessionID, MessageID } from "@/session/schema"
|
import { SessionID, MessageID } from "@/session/schema"
|
||||||
import { Log } from "@/util/log"
|
import { Log } from "@/util/log"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
@@ -199,26 +198,4 @@ export namespace Question {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(Bus.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function ask(input: {
|
|
||||||
sessionID: SessionID
|
|
||||||
questions: Info[]
|
|
||||||
tool?: { messageID: MessageID; callID: string }
|
|
||||||
}): Promise<Answer[]> {
|
|
||||||
return runPromise((s) => s.ask(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reply(input: { requestID: QuestionID; answers: Answer[] }) {
|
|
||||||
return runPromise((s) => s.reply(input))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function reject(requestID: QuestionID) {
|
|
||||||
return runPromise((s) => s.reject(requestID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function list() {
|
|
||||||
return runPromise((s) => s.list())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Hono } from "hono"
|
||||||
|
import { createBunWebSocket } from "hono/bun"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createBunWebSocket()
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const args = {
|
||||||
|
fetch: app.fetch,
|
||||||
|
hostname: opts.hostname,
|
||||||
|
idleTimeout: 0,
|
||||||
|
websocket: ws.websocket,
|
||||||
|
} as const
|
||||||
|
const start = (port: number) => {
|
||||||
|
try {
|
||||||
|
return Bun.serve({ ...args, port })
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const server = opts.port === 0 ? (start(4096) ?? start(0)) : start(opts.port)
|
||||||
|
if (!server) {
|
||||||
|
throw new Error(`Failed to start server on port ${opts.port}`)
|
||||||
|
}
|
||||||
|
if (!server.port) {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
port: server.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
return Promise.resolve(server.stop(close))
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
||||||
|
import { createNodeWebSocket } from "@hono/node-ws"
|
||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { Adapter } from "./adapter"
|
||||||
|
|
||||||
|
export const adapter: Adapter = {
|
||||||
|
create(app: Hono) {
|
||||||
|
const ws = createNodeWebSocket({ app })
|
||||||
|
return {
|
||||||
|
upgradeWebSocket: ws.upgradeWebSocket,
|
||||||
|
async listen(opts) {
|
||||||
|
const start = (port: number) =>
|
||||||
|
new Promise<ServerType>((resolve, reject) => {
|
||||||
|
const server = createAdaptorServer({ fetch: app.fetch })
|
||||||
|
ws.injectWebSocket(server)
|
||||||
|
const fail = (err: Error) => {
|
||||||
|
cleanup()
|
||||||
|
reject(err)
|
||||||
|
}
|
||||||
|
const ready = () => {
|
||||||
|
cleanup()
|
||||||
|
resolve(server)
|
||||||
|
}
|
||||||
|
const cleanup = () => {
|
||||||
|
server.off("error", fail)
|
||||||
|
server.off("listening", ready)
|
||||||
|
}
|
||||||
|
server.once("error", fail)
|
||||||
|
server.once("listening", ready)
|
||||||
|
server.listen(port, opts.hostname)
|
||||||
|
})
|
||||||
|
|
||||||
|
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
||||||
|
const addr = server.address()
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let closing: Promise<void> | undefined
|
||||||
|
return {
|
||||||
|
port: addr.port,
|
||||||
|
stop(close?: boolean) {
|
||||||
|
closing ??= new Promise((resolve, reject) => {
|
||||||
|
server.close((err) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
if (close) {
|
||||||
|
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
||||||
|
server.closeAllConnections()
|
||||||
|
}
|
||||||
|
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
||||||
|
server.closeIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return closing
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { Hono } from "hono"
|
||||||
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
|
|
||||||
|
export type Opts = {
|
||||||
|
port: number
|
||||||
|
hostname: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Listener = {
|
||||||
|
port: number
|
||||||
|
stop: (close?: boolean) => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Runtime {
|
||||||
|
upgradeWebSocket: UpgradeWebSocket
|
||||||
|
listen(opts: Opts): Promise<Listener>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Adapter {
|
||||||
|
create(app: Hono): Runtime
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { Auth } from "@/auth"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import { ProviderID } from "@/provider/schema"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { describeRoute, resolver, validator, openAPIRouteHandler } from "hono-openapi"
|
||||||
|
import z from "zod"
|
||||||
|
import { errors } from "../error"
|
||||||
|
import { GlobalRoutes } from "../instance/global"
|
||||||
|
|
||||||
|
export function ControlPlaneRoutes(): Hono {
|
||||||
|
const app = new Hono()
|
||||||
|
return app
|
||||||
|
.route("/global", GlobalRoutes())
|
||||||
|
.put(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Set auth credentials",
|
||||||
|
description: "Set authentication credentials",
|
||||||
|
operationId: "auth.set",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully set authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
validator("json", Auth.Info.zod),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
const info = c.req.valid("json")
|
||||||
|
await Auth.set(providerID, info)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.delete(
|
||||||
|
"/auth/:providerID",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Remove auth credentials",
|
||||||
|
description: "Remove authentication credentials",
|
||||||
|
operationId: "auth.remove",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Successfully removed authentication credentials",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"param",
|
||||||
|
z.object({
|
||||||
|
providerID: ProviderID.zod,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const providerID = c.req.valid("param").providerID
|
||||||
|
await Auth.remove(providerID)
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.get(
|
||||||
|
"/doc",
|
||||||
|
openAPIRouteHandler(app, {
|
||||||
|
documentation: {
|
||||||
|
info: {
|
||||||
|
title: "opencode",
|
||||||
|
version: "0.0.3",
|
||||||
|
description: "opencode api",
|
||||||
|
},
|
||||||
|
openapi: "3.1.1",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.use(
|
||||||
|
validator(
|
||||||
|
"query",
|
||||||
|
z.object({
|
||||||
|
directory: z.string().optional(),
|
||||||
|
workspace: z.string().optional(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.post(
|
||||||
|
"/log",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Write log",
|
||||||
|
description: "Write a log entry to the server logs with specified level and metadata.",
|
||||||
|
operationId: "app.log",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Log entry written successfully",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.boolean()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
service: z.string().meta({ description: "Service name for the log entry" }),
|
||||||
|
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
||||||
|
message: z.string().meta({ description: "Log message" }),
|
||||||
|
extra: z
|
||||||
|
.record(z.string(), z.any())
|
||||||
|
.optional()
|
||||||
|
.meta({ description: "Additional metadata for the log entry" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const { service, level, message, extra } = c.req.valid("json")
|
||||||
|
const logger = Log.create({ service })
|
||||||
|
|
||||||
|
switch (level) {
|
||||||
|
case "debug":
|
||||||
|
logger.debug(message, extra)
|
||||||
|
break
|
||||||
|
case "info":
|
||||||
|
logger.info(message, extra)
|
||||||
|
break
|
||||||
|
case "error":
|
||||||
|
logger.error(message, extra)
|
||||||
|
break
|
||||||
|
case "warn":
|
||||||
|
logger.warn(message, extra)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(true)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
+32
-9
@@ -11,9 +11,11 @@ import { Session } from "../../session"
|
|||||||
import { Config } from "../../config/config"
|
import { Config } from "../../config/config"
|
||||||
import { ConsoleState } from "../../config/console-state"
|
import { ConsoleState } from "../../config/console-state"
|
||||||
import { Account, AccountID, OrgID } from "../../account"
|
import { Account, AccountID, OrgID } from "../../account"
|
||||||
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { zodToJsonSchema } from "zod-to-json-schema"
|
import { zodToJsonSchema } from "zod-to-json-schema"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { Effect, Option } from "effect"
|
||||||
import { WorkspaceRoutes } from "./workspace"
|
import { WorkspaceRoutes } from "./workspace"
|
||||||
import { Agent } from "@/agent/agent"
|
import { Agent } from "@/agent/agent"
|
||||||
|
|
||||||
@@ -55,11 +57,20 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [consoleState, groups] = await Promise.all([Config.getConsoleState(), Account.orgsByAccount()])
|
const result = await AppRuntime.runPromise(
|
||||||
return c.json({
|
Effect.gen(function* () {
|
||||||
...consoleState,
|
const config = yield* Config.Service
|
||||||
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
const account = yield* Account.Service
|
||||||
|
const [state, groups] = yield* Effect.all([config.getConsoleState(), account.orgsByAccount()], {
|
||||||
|
concurrency: "unbounded",
|
||||||
})
|
})
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
switchableOrgCount: groups.reduce((count, group) => count + group.orgs.length, 0),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return c.json(result)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.get(
|
.get(
|
||||||
@@ -80,18 +91,25 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const [groups, active] = await Promise.all([Account.orgsByAccount(), Account.active()])
|
const orgs = await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
const orgs = groups.flatMap((group) =>
|
const account = yield* Account.Service
|
||||||
|
const [groups, active] = yield* Effect.all([account.orgsByAccount(), account.active()], {
|
||||||
|
concurrency: "unbounded",
|
||||||
|
})
|
||||||
|
const info = Option.getOrUndefined(active)
|
||||||
|
return groups.flatMap((group) =>
|
||||||
group.orgs.map((org) => ({
|
group.orgs.map((org) => ({
|
||||||
accountID: group.account.id,
|
accountID: group.account.id,
|
||||||
accountEmail: group.account.email,
|
accountEmail: group.account.email,
|
||||||
accountUrl: group.account.url,
|
accountUrl: group.account.url,
|
||||||
orgID: org.id,
|
orgID: org.id,
|
||||||
orgName: org.name,
|
orgName: org.name,
|
||||||
active: !!active && active.id === group.account.id && active.active_org_id === org.id,
|
active: !!info && info.id === group.account.id && info.active_org_id === org.id,
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json({ orgs })
|
return c.json({ orgs })
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -115,7 +133,12 @@ export const ExperimentalRoutes = lazy(() =>
|
|||||||
validator("json", ConsoleSwitchBody),
|
validator("json", ConsoleSwitchBody),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const body = c.req.valid("json")
|
const body = c.req.valid("json")
|
||||||
await Account.switchOrg(AccountID.make(body.accountID), OrgID.make(body.orgID))
|
await AppRuntime.runPromise(
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const account = yield* Account.Service
|
||||||
|
yield* account.use(AccountID.make(body.accountID), Option.some(OrgID.make(body.orgID)))
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
+24
-80
@@ -1,53 +1,33 @@
|
|||||||
import { describeRoute, resolver, validator } from "hono-openapi"
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { proxy } from "hono/proxy"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
import type { UpgradeWebSocket } from "hono/ws"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { createHash } from "node:crypto"
|
import { Format } from "../../format"
|
||||||
import * as fs from "node:fs/promises"
|
import { TuiRoutes } from "./tui"
|
||||||
import { Log } from "../util/log"
|
import { Instance } from "../../project/instance"
|
||||||
import { Format } from "../format"
|
import { Vcs } from "../../project/vcs"
|
||||||
import { TuiRoutes } from "./routes/tui"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Instance } from "../project/instance"
|
import { Skill } from "../../skill"
|
||||||
import { Vcs } from "../project/vcs"
|
import { Global } from "../../global"
|
||||||
import { Agent } from "../agent/agent"
|
import { LSP } from "../../lsp"
|
||||||
import { Skill } from "../skill"
|
import { Command } from "../../command"
|
||||||
import { Global } from "../global"
|
import { QuestionRoutes } from "./question"
|
||||||
import { LSP } from "../lsp"
|
import { PermissionRoutes } from "./permission"
|
||||||
import { Command } from "../command"
|
import { ProjectRoutes } from "./project"
|
||||||
import { Flag } from "../flag/flag"
|
import { SessionRoutes } from "./session"
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { PtyRoutes } from "./pty"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { McpRoutes } from "./mcp"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { FileRoutes } from "./file"
|
||||||
import { ProjectRoutes } from "./routes/project"
|
import { ConfigRoutes } from "./config"
|
||||||
import { SessionRoutes } from "./routes/session"
|
import { ExperimentalRoutes } from "./experimental"
|
||||||
import { PtyRoutes } from "./routes/pty"
|
import { ProviderRoutes } from "./provider"
|
||||||
import { McpRoutes } from "./routes/mcp"
|
import { EventRoutes } from "./event"
|
||||||
import { FileRoutes } from "./routes/file"
|
import { WorkspaceRouterMiddleware } from "./middleware"
|
||||||
import { ConfigRoutes } from "./routes/config"
|
|
||||||
import { ExperimentalRoutes } from "./routes/experimental"
|
|
||||||
import { ProviderRoutes } from "./routes/provider"
|
|
||||||
import { EventRoutes } from "./routes/event"
|
|
||||||
import { errorHandler } from "./middleware"
|
|
||||||
import { getMimeType } from "hono/utils/mime"
|
|
||||||
import { AppRuntime } from "@/effect/app-runtime"
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono =>
|
||||||
|
new Hono()
|
||||||
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
.use(WorkspaceRouterMiddleware(upgrade))
|
||||||
? Promise.resolve(null)
|
|
||||||
: // @ts-expect-error - generated file at build time
|
|
||||||
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
|
||||||
|
|
||||||
const DEFAULT_CSP =
|
|
||||||
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
|
||||||
|
|
||||||
const csp = (hash = "") =>
|
|
||||||
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
|
||||||
|
|
||||||
export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()) =>
|
|
||||||
app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.route("/project", ProjectRoutes())
|
.route("/project", ProjectRoutes())
|
||||||
.route("/pty", PtyRoutes(upgrade))
|
.route("/pty", PtyRoutes(upgrade))
|
||||||
.route("/config", ConfigRoutes())
|
.route("/config", ConfigRoutes())
|
||||||
@@ -281,39 +261,3 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket, app: Hono = new Hono()
|
|||||||
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
return c.json(await AppRuntime.runPromise(Format.Service.use((svc) => svc.status())))
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.all("/*", async (c) => {
|
|
||||||
const embeddedWebUI = await embeddedUIPromise
|
|
||||||
const path = c.req.path
|
|
||||||
|
|
||||||
if (embeddedWebUI) {
|
|
||||||
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
|
||||||
if (!match) return c.json({ error: "Not Found" }, 404)
|
|
||||||
|
|
||||||
if (await fs.exists(match)) {
|
|
||||||
const mime = getMimeType(match) ?? "text/plain"
|
|
||||||
c.header("Content-Type", mime)
|
|
||||||
if (mime.startsWith("text/html")) {
|
|
||||||
c.header("Content-Security-Policy", DEFAULT_CSP)
|
|
||||||
}
|
|
||||||
return c.body(new Uint8Array(await fs.readFile(match)))
|
|
||||||
} else {
|
|
||||||
return c.json({ error: "Not Found" }, 404)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const response = await proxy(`https://app.opencode.ai${path}`, {
|
|
||||||
...c.req,
|
|
||||||
headers: {
|
|
||||||
...c.req.raw.headers,
|
|
||||||
host: "app.opencode.ai",
|
|
||||||
},
|
|
||||||
})
|
|
||||||
const match = response.headers.get("content-type")?.includes("text/html")
|
|
||||||
? (await response.clone().text()).match(
|
|
||||||
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
|
||||||
)
|
|
||||||
: undefined
|
|
||||||
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
|
||||||
response.headers.set("Content-Security-Policy", csp(hash))
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
})
|
|
||||||
+6
-10
@@ -3,12 +3,10 @@ import type { UpgradeWebSocket } from "hono/ws"
|
|||||||
import { getAdaptor } from "@/control-plane/adaptors"
|
import { getAdaptor } from "@/control-plane/adaptors"
|
||||||
import { WorkspaceID } from "@/control-plane/schema"
|
import { WorkspaceID } from "@/control-plane/schema"
|
||||||
import { Workspace } from "@/control-plane/workspace"
|
import { Workspace } from "@/control-plane/workspace"
|
||||||
import { ServerProxy } from "./proxy"
|
import { ServerProxy } from "../proxy"
|
||||||
import { lazy } from "@/util/lazy"
|
|
||||||
import { Filesystem } from "@/util/filesystem"
|
import { Filesystem } from "@/util/filesystem"
|
||||||
import { Instance } from "@/project/instance"
|
import { Instance } from "@/project/instance"
|
||||||
import { InstanceBootstrap } from "@/project/bootstrap"
|
import { InstanceBootstrap } from "@/project/bootstrap"
|
||||||
import { InstanceRoutes } from "./instance"
|
|
||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
import { WorkspaceContext } from "@/control-plane/workspace-context"
|
||||||
@@ -47,9 +45,7 @@ async function getSessionWorkspace(url: URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): MiddlewareHandler {
|
||||||
const routes = lazy(() => InstanceRoutes(upgrade))
|
return async (c, next) => {
|
||||||
|
|
||||||
return async (c) => {
|
|
||||||
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
const raw = c.req.query("directory") || c.req.header("x-opencode-directory") || process.cwd()
|
||||||
const directory = Filesystem.resolve(
|
const directory = Filesystem.resolve(
|
||||||
(() => {
|
(() => {
|
||||||
@@ -72,7 +68,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
directory,
|
directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -87,7 +83,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
// The lets the `DELETE /session/:id` endpoint through and we've
|
// The lets the `DELETE /session/:id` endpoint through and we've
|
||||||
// made sure that it will run without an instance
|
// made sure that it will run without an instance
|
||||||
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
if (url.pathname.match(/\/session\/[^/]+$/) && c.req.method === "DELETE") {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(`Workspace not found: ${workspaceID}`, {
|
return new Response(`Workspace not found: ${workspaceID}`, {
|
||||||
@@ -109,7 +105,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
directory: target.directory,
|
directory: target.directory,
|
||||||
init: InstanceBootstrap,
|
init: InstanceBootstrap,
|
||||||
async fn() {
|
async fn() {
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
@@ -118,7 +114,7 @@ export function WorkspaceRouterMiddleware(upgrade: UpgradeWebSocket): Middleware
|
|||||||
if (local(c.req.method, url.pathname)) {
|
if (local(c.req.method, url.pathname)) {
|
||||||
// No instance provided because we are serving cached data; there
|
// No instance provided because we are serving cached data; there
|
||||||
// is no instance to work with
|
// is no instance to work with
|
||||||
return routes().fetch(c.req.raw, c.env)
|
return next()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
if (c.req.header("upgrade")?.toLowerCase() === "websocket") {
|
||||||
+9
-4
@@ -3,6 +3,7 @@ import { describeRoute, validator } from "hono-openapi"
|
|||||||
import { resolver } from "hono-openapi"
|
import { resolver } from "hono-openapi"
|
||||||
import { QuestionID } from "@/question/schema"
|
import { QuestionID } from "@/question/schema"
|
||||||
import { Question } from "../../question"
|
import { Question } from "../../question"
|
||||||
|
import { AppRuntime } from "@/effect/app-runtime"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
@@ -27,7 +28,7 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const questions = await Question.list()
|
const questions = await AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||||
return c.json(questions)
|
return c.json(questions)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -59,10 +60,14 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
const json = c.req.valid("json")
|
const json = c.req.valid("json")
|
||||||
await Question.reply({
|
await AppRuntime.runPromise(
|
||||||
|
Question.Service.use((svc) =>
|
||||||
|
svc.reply({
|
||||||
requestID: params.requestID,
|
requestID: params.requestID,
|
||||||
answers: json.answers,
|
answers: json.answers,
|
||||||
})
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -92,7 +97,7 @@ export const QuestionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await Question.reject(params.requestID)
|
await AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(params.requestID)))
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
+17
-2
@@ -13,6 +13,7 @@ import { SessionShare } from "@/share/session"
|
|||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "../../session/todo"
|
import { Todo } from "../../session/todo"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { AppRuntime } from "../../effect/app-runtime"
|
import { AppRuntime } from "../../effect/app-runtime"
|
||||||
import { Agent } from "../../agent/agent"
|
import { Agent } from "../../agent/agent"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
@@ -273,6 +274,7 @@ export const SessionRoutes = lazy(() =>
|
|||||||
"json",
|
"json",
|
||||||
z.object({
|
z.object({
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
permission: Permission.Ruleset.optional(),
|
||||||
time: z
|
time: z
|
||||||
.object({
|
.object({
|
||||||
archived: z.number().optional(),
|
archived: z.number().optional(),
|
||||||
@@ -283,10 +285,17 @@ export const SessionRoutes = lazy(() =>
|
|||||||
async (c) => {
|
async (c) => {
|
||||||
const sessionID = c.req.valid("param").sessionID
|
const sessionID = c.req.valid("param").sessionID
|
||||||
const updates = c.req.valid("json")
|
const updates = c.req.valid("json")
|
||||||
|
const current = await Session.get(sessionID)
|
||||||
|
|
||||||
if (updates.title !== undefined) {
|
if (updates.title !== undefined) {
|
||||||
await Session.setTitle({ sessionID, title: updates.title })
|
await Session.setTitle({ sessionID, title: updates.title })
|
||||||
}
|
}
|
||||||
|
if (updates.permission !== undefined) {
|
||||||
|
await Session.setPermission({
|
||||||
|
sessionID,
|
||||||
|
permission: Permission.merge(current.permission ?? [], updates.permission),
|
||||||
|
})
|
||||||
|
}
|
||||||
if (updates.time?.archived !== undefined) {
|
if (updates.time?.archived !== undefined) {
|
||||||
await Session.setArchived({ sessionID, time: updates.time.archived })
|
await Session.setArchived({ sessionID, time: updates.time.archived })
|
||||||
}
|
}
|
||||||
@@ -716,11 +725,17 @@ export const SessionRoutes = lazy(() =>
|
|||||||
),
|
),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
const params = c.req.valid("param")
|
const params = c.req.valid("param")
|
||||||
await SessionRunState.assertNotBusy(params.sessionID)
|
await AppRuntime.runPromise(
|
||||||
await Session.removeMessage({
|
Effect.gen(function* () {
|
||||||
|
const state = yield* SessionRunState.Service
|
||||||
|
const session = yield* Session.Service
|
||||||
|
yield* state.assertNotBusy(params.sessionID)
|
||||||
|
yield* session.removeMessage({
|
||||||
sessionID: params.sessionID,
|
sessionID: params.sessionID,
|
||||||
messageID: params.messageID,
|
messageID: params.messageID,
|
||||||
})
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
return c.json(true)
|
return c.json(true)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -3,12 +3,17 @@ import { NamedError } from "@opencode-ai/util/error"
|
|||||||
import { NotFoundError } from "../storage/db"
|
import { NotFoundError } from "../storage/db"
|
||||||
import { Session } from "../session"
|
import { Session } from "../session"
|
||||||
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
import type { ContentfulStatusCode } from "hono/utils/http-status"
|
||||||
import type { ErrorHandler } from "hono"
|
import type { ErrorHandler, MiddlewareHandler } from "hono"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import type { Log } from "../util/log"
|
import { Log } from "../util/log"
|
||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { basicAuth } from "hono/basic-auth"
|
||||||
|
import { cors } from "hono/cors"
|
||||||
|
import { compress } from "hono/compress"
|
||||||
|
|
||||||
export function errorHandler(log: Log.Logger): ErrorHandler {
|
const log = Log.create({ service: "server" })
|
||||||
return (err, c) => {
|
|
||||||
|
export const ErrorMiddleware: ErrorHandler = (err, c) => {
|
||||||
log.error("failed", {
|
log.error("failed", {
|
||||||
error: err,
|
error: err,
|
||||||
})
|
})
|
||||||
@@ -29,5 +34,59 @@ export function errorHandler(log: Log.Logger): ErrorHandler {
|
|||||||
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
return c.json(new NamedError.Unknown({ message }).toObject(), {
|
||||||
status: 500,
|
status: 500,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const AuthMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
// Allow CORS preflight requests to succeed without auth.
|
||||||
|
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
||||||
|
if (c.req.method === "OPTIONS") return next()
|
||||||
|
const password = Flag.OPENCODE_SERVER_PASSWORD
|
||||||
|
if (!password) return next()
|
||||||
|
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
||||||
|
|
||||||
|
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
||||||
|
|
||||||
|
return basicAuth({ username, password })(c, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LoggerMiddleware: MiddlewareHandler = async (c, next) => {
|
||||||
|
const skip = c.req.path === "/log"
|
||||||
|
if (!skip) {
|
||||||
|
log.info("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const timer = log.time("request", {
|
||||||
|
method: c.req.method,
|
||||||
|
path: c.req.path,
|
||||||
|
})
|
||||||
|
await next()
|
||||||
|
if (!skip) timer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CorsMiddleware(opts?: { cors?: string[] }): MiddlewareHandler {
|
||||||
|
return cors({
|
||||||
|
maxAge: 86_400,
|
||||||
|
origin(input) {
|
||||||
|
if (!input) return
|
||||||
|
|
||||||
|
if (input.startsWith("http://localhost:")) return input
|
||||||
|
if (input.startsWith("http://127.0.0.1:")) return input
|
||||||
|
if (input === "tauri://localhost" || input === "http://tauri.localhost" || input === "https://tauri.localhost")
|
||||||
|
return input
|
||||||
|
|
||||||
|
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
||||||
|
if (opts?.cors?.includes(input)) return input
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const zipped = compress()
|
||||||
|
export const CompressionMiddleware: MiddlewareHandler = (c, next) => {
|
||||||
|
const path = c.req.path
|
||||||
|
const method = c.req.method
|
||||||
|
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return next()
|
||||||
|
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return next()
|
||||||
|
return zipped(c, next)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,14 @@
|
|||||||
import { Log } from "../util/log"
|
import { generateSpecs } from "hono-openapi"
|
||||||
import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi"
|
|
||||||
import { Hono } from "hono"
|
import { Hono } from "hono"
|
||||||
import { compress } from "hono/compress"
|
import { adapter } from "#hono"
|
||||||
import { createNodeWebSocket } from "@hono/node-ws"
|
|
||||||
import { cors } from "hono/cors"
|
|
||||||
import { basicAuth } from "hono/basic-auth"
|
|
||||||
import type { UpgradeWebSocket } from "hono/ws"
|
|
||||||
import z from "zod"
|
|
||||||
import { Auth } from "../auth"
|
|
||||||
import { Flag } from "../flag/flag"
|
|
||||||
import { ProviderID } from "../provider/schema"
|
|
||||||
import { WorkspaceRouterMiddleware } from "./router"
|
|
||||||
import { errors } from "./error"
|
|
||||||
import { GlobalRoutes } from "./routes/global"
|
|
||||||
import { MDNS } from "./mdns"
|
import { MDNS } from "./mdns"
|
||||||
import { lazy } from "@/util/lazy"
|
import { lazy } from "@/util/lazy"
|
||||||
import { errorHandler } from "./middleware"
|
import { AuthMiddleware, CompressionMiddleware, CorsMiddleware, ErrorMiddleware, LoggerMiddleware } from "./middleware"
|
||||||
import { InstanceRoutes } from "./instance"
|
import { InstanceRoutes } from "./instance"
|
||||||
import { initProjectors } from "./projectors"
|
import { initProjectors } from "./projectors"
|
||||||
import { createAdaptorServer, type ServerType } from "@hono/node-server"
|
import { Log } from "@/util/log"
|
||||||
|
import { ControlPlaneRoutes } from "./control"
|
||||||
|
import { UIRoutes } from "./ui"
|
||||||
|
|
||||||
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
// @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -26,6 +16,8 @@ globalThis.AI_SDK_LOG_WARNINGS = false
|
|||||||
initProjectors()
|
initProjectors()
|
||||||
|
|
||||||
export namespace Server {
|
export namespace Server {
|
||||||
|
const log = Log.create({ service: "server" })
|
||||||
|
|
||||||
export type Listener = {
|
export type Listener = {
|
||||||
hostname: string
|
hostname: string
|
||||||
port: number
|
port: number
|
||||||
@@ -33,231 +25,31 @@ export namespace Server {
|
|||||||
stop: (close?: boolean) => Promise<void>
|
stop: (close?: boolean) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
const log = Log.create({ service: "server" })
|
|
||||||
const zipped = compress()
|
|
||||||
|
|
||||||
const skipCompress = (path: string, method: string) => {
|
|
||||||
if (path === "/event" || path === "/global/event" || path === "/global/sync-event") return true
|
|
||||||
if (method === "POST" && /\/session\/[^/]+\/(message|prompt_async)$/.test(path)) return true
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Default = lazy(() => create({}))
|
export const Default = lazy(() => create({}))
|
||||||
|
|
||||||
export function ControlPlaneRoutes(upgrade: UpgradeWebSocket, app = new Hono(), opts?: { cors?: string[] }): Hono {
|
|
||||||
return app
|
|
||||||
.onError(errorHandler(log))
|
|
||||||
.use((c, next) => {
|
|
||||||
// Allow CORS preflight requests to succeed without auth.
|
|
||||||
// Browser clients sending Authorization headers will preflight with OPTIONS.
|
|
||||||
if (c.req.method === "OPTIONS") return next()
|
|
||||||
const password = Flag.OPENCODE_SERVER_PASSWORD
|
|
||||||
if (!password) return next()
|
|
||||||
const username = Flag.OPENCODE_SERVER_USERNAME ?? "opencode"
|
|
||||||
|
|
||||||
if (c.req.query("auth_token")) c.req.raw.headers.set("authorization", `Basic ${c.req.query("auth_token")}`)
|
|
||||||
|
|
||||||
return basicAuth({ username, password })(c, next)
|
|
||||||
})
|
|
||||||
.use(async (c, next) => {
|
|
||||||
const skip = c.req.path === "/log"
|
|
||||||
if (!skip) {
|
|
||||||
log.info("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const timer = log.time("request", {
|
|
||||||
method: c.req.method,
|
|
||||||
path: c.req.path,
|
|
||||||
})
|
|
||||||
await next()
|
|
||||||
if (!skip) timer.stop()
|
|
||||||
})
|
|
||||||
.use(
|
|
||||||
cors({
|
|
||||||
maxAge: 86_400,
|
|
||||||
origin(input) {
|
|
||||||
if (!input) return
|
|
||||||
|
|
||||||
if (input.startsWith("http://localhost:")) return input
|
|
||||||
if (input.startsWith("http://127.0.0.1:")) return input
|
|
||||||
if (
|
|
||||||
input === "tauri://localhost" ||
|
|
||||||
input === "http://tauri.localhost" ||
|
|
||||||
input === "https://tauri.localhost"
|
|
||||||
)
|
|
||||||
return input
|
|
||||||
|
|
||||||
if (/^https:\/\/([a-z0-9-]+\.)*opencode\.ai$/.test(input)) return input
|
|
||||||
if (opts?.cors?.includes(input)) return input
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use((c, next) => {
|
|
||||||
if (skipCompress(c.req.path, c.req.method)) return next()
|
|
||||||
return zipped(c, next)
|
|
||||||
})
|
|
||||||
.route("/global", GlobalRoutes())
|
|
||||||
.put(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Set auth credentials",
|
|
||||||
description: "Set authentication credentials",
|
|
||||||
operationId: "auth.set",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully set authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
validator("json", Auth.Info.zod),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
const info = c.req.valid("json")
|
|
||||||
await Auth.set(providerID, info)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.delete(
|
|
||||||
"/auth/:providerID",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Remove auth credentials",
|
|
||||||
description: "Remove authentication credentials",
|
|
||||||
operationId: "auth.remove",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Successfully removed authentication credentials",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"param",
|
|
||||||
z.object({
|
|
||||||
providerID: ProviderID.zod,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const providerID = c.req.valid("param").providerID
|
|
||||||
await Auth.remove(providerID)
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.get(
|
|
||||||
"/doc",
|
|
||||||
openAPIRouteHandler(app, {
|
|
||||||
documentation: {
|
|
||||||
info: {
|
|
||||||
title: "opencode",
|
|
||||||
version: "0.0.3",
|
|
||||||
description: "opencode api",
|
|
||||||
},
|
|
||||||
openapi: "3.1.1",
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.use(
|
|
||||||
validator(
|
|
||||||
"query",
|
|
||||||
z.object({
|
|
||||||
directory: z.string().optional(),
|
|
||||||
workspace: z.string().optional(),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.post(
|
|
||||||
"/log",
|
|
||||||
describeRoute({
|
|
||||||
summary: "Write log",
|
|
||||||
description: "Write a log entry to the server logs with specified level and metadata.",
|
|
||||||
operationId: "app.log",
|
|
||||||
responses: {
|
|
||||||
200: {
|
|
||||||
description: "Log entry written successfully",
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: resolver(z.boolean()),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
...errors(400),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
validator(
|
|
||||||
"json",
|
|
||||||
z.object({
|
|
||||||
service: z.string().meta({ description: "Service name for the log entry" }),
|
|
||||||
level: z.enum(["debug", "info", "error", "warn"]).meta({ description: "Log level" }),
|
|
||||||
message: z.string().meta({ description: "Log message" }),
|
|
||||||
extra: z
|
|
||||||
.record(z.string(), z.any())
|
|
||||||
.optional()
|
|
||||||
.meta({ description: "Additional metadata for the log entry" }),
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
async (c) => {
|
|
||||||
const { service, level, message, extra } = c.req.valid("json")
|
|
||||||
const logger = Log.create({ service })
|
|
||||||
|
|
||||||
switch (level) {
|
|
||||||
case "debug":
|
|
||||||
logger.debug(message, extra)
|
|
||||||
break
|
|
||||||
case "info":
|
|
||||||
logger.info(message, extra)
|
|
||||||
break
|
|
||||||
case "error":
|
|
||||||
logger.error(message, extra)
|
|
||||||
break
|
|
||||||
case "warn":
|
|
||||||
logger.warn(message, extra)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.json(true)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.use(WorkspaceRouterMiddleware(upgrade))
|
|
||||||
}
|
|
||||||
|
|
||||||
function create(opts: { cors?: string[] }) {
|
function create(opts: { cors?: string[] }) {
|
||||||
const app = new Hono()
|
const app = new Hono()
|
||||||
const ws = createNodeWebSocket({ app })
|
const runtime = adapter.create(app)
|
||||||
return {
|
return {
|
||||||
app: ControlPlaneRoutes(ws.upgradeWebSocket, app, opts),
|
app: app
|
||||||
ws,
|
.onError(ErrorMiddleware)
|
||||||
|
.use(AuthMiddleware)
|
||||||
|
.use(LoggerMiddleware)
|
||||||
|
.use(CompressionMiddleware)
|
||||||
|
.use(CorsMiddleware(opts))
|
||||||
|
.route("/", ControlPlaneRoutes())
|
||||||
|
.route("/", InstanceRoutes(runtime.upgradeWebSocket))
|
||||||
|
.route("/", UIRoutes()),
|
||||||
|
runtime,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(opts: { cors?: string[] }) {
|
|
||||||
return create(opts).app
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function openapi() {
|
export async function openapi() {
|
||||||
// Build a fresh app with all routes registered directly so
|
// Build a fresh app with all routes registered directly so
|
||||||
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
// hono-openapi can see describeRoute metadata (`.route()` wraps
|
||||||
// handlers when the sub-app has a custom errorHandler, which
|
// handlers when the sub-app has a custom errorHandler, which
|
||||||
// strips the metadata symbol).
|
// strips the metadata symbol).
|
||||||
const { app, ws } = create({})
|
const { app } = create({})
|
||||||
InstanceRoutes(ws.upgradeWebSocket, app)
|
|
||||||
const result = await generateSpecs(app, {
|
const result = await generateSpecs(app, {
|
||||||
documentation: {
|
documentation: {
|
||||||
info: {
|
info: {
|
||||||
@@ -281,46 +73,21 @@ export namespace Server {
|
|||||||
cors?: string[]
|
cors?: string[]
|
||||||
}): Promise<Listener> {
|
}): Promise<Listener> {
|
||||||
const built = create(opts)
|
const built = create(opts)
|
||||||
const start = (port: number) =>
|
const server = await built.runtime.listen(opts)
|
||||||
new Promise<ServerType>((resolve, reject) => {
|
|
||||||
const server = createAdaptorServer({ fetch: built.app.fetch })
|
|
||||||
built.ws.injectWebSocket(server)
|
|
||||||
const fail = (err: Error) => {
|
|
||||||
cleanup()
|
|
||||||
reject(err)
|
|
||||||
}
|
|
||||||
const ready = () => {
|
|
||||||
cleanup()
|
|
||||||
resolve(server)
|
|
||||||
}
|
|
||||||
const cleanup = () => {
|
|
||||||
server.off("error", fail)
|
|
||||||
server.off("listening", ready)
|
|
||||||
}
|
|
||||||
server.once("error", fail)
|
|
||||||
server.once("listening", ready)
|
|
||||||
server.listen(port, opts.hostname)
|
|
||||||
})
|
|
||||||
|
|
||||||
const server = opts.port === 0 ? await start(4096).catch(() => start(0)) : await start(opts.port)
|
|
||||||
const addr = server.address()
|
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
throw new Error(`Failed to resolve server address for port ${opts.port}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = new URL("http://localhost")
|
const next = new URL("http://localhost")
|
||||||
next.hostname = opts.hostname
|
next.hostname = opts.hostname
|
||||||
next.port = String(addr.port)
|
next.port = String(server.port)
|
||||||
url = next
|
url = next
|
||||||
|
|
||||||
const mdns =
|
const mdns =
|
||||||
opts.mdns &&
|
opts.mdns &&
|
||||||
addr.port &&
|
server.port &&
|
||||||
opts.hostname !== "127.0.0.1" &&
|
opts.hostname !== "127.0.0.1" &&
|
||||||
opts.hostname !== "localhost" &&
|
opts.hostname !== "localhost" &&
|
||||||
opts.hostname !== "::1"
|
opts.hostname !== "::1"
|
||||||
if (mdns) {
|
if (mdns) {
|
||||||
MDNS.publish(addr.port, opts.mdnsDomain)
|
MDNS.publish(server.port, opts.mdnsDomain)
|
||||||
} else if (opts.mdns) {
|
} else if (opts.mdns) {
|
||||||
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
log.warn("mDNS enabled but hostname is loopback; skipping mDNS publish")
|
||||||
}
|
}
|
||||||
@@ -328,27 +95,13 @@ export namespace Server {
|
|||||||
let closing: Promise<void> | undefined
|
let closing: Promise<void> | undefined
|
||||||
return {
|
return {
|
||||||
hostname: opts.hostname,
|
hostname: opts.hostname,
|
||||||
port: addr.port,
|
port: server.port,
|
||||||
url: next,
|
url: next,
|
||||||
stop(close?: boolean) {
|
stop(close?: boolean) {
|
||||||
closing ??= new Promise((resolve, reject) => {
|
closing ??= (async () => {
|
||||||
if (mdns) MDNS.unpublish()
|
if (mdns) MDNS.unpublish()
|
||||||
server.close((err) => {
|
await server.stop(close)
|
||||||
if (err) {
|
})()
|
||||||
reject(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
resolve()
|
|
||||||
})
|
|
||||||
if (close) {
|
|
||||||
if ("closeAllConnections" in server && typeof server.closeAllConnections === "function") {
|
|
||||||
server.closeAllConnections()
|
|
||||||
}
|
|
||||||
if ("closeIdleConnections" in server && typeof server.closeIdleConnections === "function") {
|
|
||||||
server.closeIdleConnections()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return closing
|
return closing
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { Flag } from "@/flag/flag"
|
||||||
|
import { Hono } from "hono"
|
||||||
|
import { proxy } from "hono/proxy"
|
||||||
|
import { getMimeType } from "hono/utils/mime"
|
||||||
|
import { createHash } from "node:crypto"
|
||||||
|
import fs from "node:fs/promises"
|
||||||
|
|
||||||
|
const embeddedUIPromise = Flag.OPENCODE_DISABLE_EMBEDDED_WEB_UI
|
||||||
|
? Promise.resolve(null)
|
||||||
|
: // @ts-expect-error - generated file at build time
|
||||||
|
import("opencode-web-ui.gen.ts").then((module) => module.default as Record<string, string>).catch(() => null)
|
||||||
|
|
||||||
|
const DEFAULT_CSP =
|
||||||
|
"default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:"
|
||||||
|
|
||||||
|
const csp = (hash = "") =>
|
||||||
|
`default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:`
|
||||||
|
|
||||||
|
export const UIRoutes = (): Hono =>
|
||||||
|
new Hono().all("/*", async (c) => {
|
||||||
|
const embeddedWebUI = await embeddedUIPromise
|
||||||
|
const path = c.req.path
|
||||||
|
|
||||||
|
if (embeddedWebUI) {
|
||||||
|
const match = embeddedWebUI[path.replace(/^\//, "")] ?? embeddedWebUI["index.html"] ?? null
|
||||||
|
if (!match) return c.json({ error: "Not Found" }, 404)
|
||||||
|
|
||||||
|
if (await fs.exists(match)) {
|
||||||
|
const mime = getMimeType(match) ?? "text/plain"
|
||||||
|
c.header("Content-Type", mime)
|
||||||
|
if (mime.startsWith("text/html")) {
|
||||||
|
c.header("Content-Security-Policy", DEFAULT_CSP)
|
||||||
|
}
|
||||||
|
return c.body(new Uint8Array(await fs.readFile(match)))
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "Not Found" }, 404)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const response = await proxy(`https://app.opencode.ai${path}`, {
|
||||||
|
...c.req,
|
||||||
|
headers: {
|
||||||
|
...c.req.raw.headers,
|
||||||
|
host: "app.opencode.ai",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const match = response.headers.get("content-type")?.includes("text/html")
|
||||||
|
? (await response.clone().text()).match(
|
||||||
|
/<script\b(?![^>]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
const hash = match ? createHash("sha256").update(match[2]).digest("base64") : ""
|
||||||
|
response.headers.set("Content-Security-Policy", csp(hash))
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -708,6 +708,10 @@ export namespace Session {
|
|||||||
runPromise((svc) => svc.setArchived(input)),
|
runPromise((svc) => svc.setArchived(input)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
export const setPermission = fn(z.object({ sessionID: SessionID.zod, permission: Permission.Ruleset }), (input) =>
|
||||||
|
runPromise((svc) => svc.setPermission(input)),
|
||||||
|
)
|
||||||
|
|
||||||
export const setRevert = fn(
|
export const setRevert = fn(
|
||||||
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
z.object({ sessionID: SessionID.zod, revert: Info.shape.revert, summary: Info.shape.summary }),
|
||||||
(input) =>
|
(input) =>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { Effect, Layer, Context } from "effect"
|
|||||||
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Flag } from "@/flag/flag"
|
import { Flag } from "@/flag/flag"
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { withTransientReadRetry } from "@/util/effect-http-client"
|
import { withTransientReadRetry } from "@/util/effect-http-client"
|
||||||
@@ -238,21 +237,7 @@ export namespace Instruction {
|
|||||||
Layer.provide(FetchHttpClient.layer),
|
Layer.provide(FetchHttpClient.layer),
|
||||||
)
|
)
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export function clear(messageID: MessageID) {
|
|
||||||
return runPromise((svc) => svc.clear(messageID))
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function systemPaths() {
|
|
||||||
return runPromise((svc) => svc.systemPaths())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loaded(messages: MessageV2.WithParts[]) {
|
export function loaded(messages: MessageV2.WithParts[]) {
|
||||||
return extract(messages)
|
return extract(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolve(messages: MessageV2.WithParts[], filepath: string, messageID: MessageID) {
|
|
||||||
return runPromise((svc) => svc.resolve(messages, filepath, messageID))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ interface FetchDecompressionError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace MessageV2 {
|
export namespace MessageV2 {
|
||||||
|
export const SYNTHETIC_ATTACHMENT_PROMPT = "Attached image(s) from tool result:"
|
||||||
|
|
||||||
export function isMedia(mime: string) {
|
export function isMedia(mime: string) {
|
||||||
return mime.startsWith("image/") || mime === "application/pdf"
|
return mime.startsWith("image/") || mime === "application/pdf"
|
||||||
}
|
}
|
||||||
@@ -808,7 +810,7 @@ export namespace MessageV2 {
|
|||||||
parts: [
|
parts: [
|
||||||
{
|
{
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: "Attached image(s) from tool result:",
|
text: SYNTHETIC_ATTACHMENT_PROMPT,
|
||||||
},
|
},
|
||||||
...media.map((attachment) => ({
|
...media.map((attachment) => ({
|
||||||
type: "file" as const,
|
type: "file" as const,
|
||||||
|
|||||||
@@ -102,6 +102,8 @@ export namespace SessionPrompt {
|
|||||||
const instruction = yield* Instruction.Service
|
const instruction = yield* Instruction.Service
|
||||||
const state = yield* SessionRunState.Service
|
const state = yield* SessionRunState.Service
|
||||||
const revert = yield* SessionRevert.Service
|
const revert = yield* SessionRevert.Service
|
||||||
|
const sys = yield* SystemPrompt.Service
|
||||||
|
const llm = yield* LLM.Service
|
||||||
|
|
||||||
const run = {
|
const run = {
|
||||||
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
promise: <A, E>(effect: Effect.Effect<A, E>) =>
|
||||||
@@ -180,21 +182,24 @@ export namespace SessionPrompt {
|
|||||||
const msgs = onlySubtasks
|
const msgs = onlySubtasks
|
||||||
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
? [{ role: "user" as const, content: subtasks.map((p) => p.prompt).join("\n") }]
|
||||||
: yield* MessageV2.toModelMessagesEffect(context, mdl)
|
: yield* MessageV2.toModelMessagesEffect(context, mdl)
|
||||||
const text = yield* Effect.promise(async (signal) => {
|
const text = yield* llm
|
||||||
const result = await LLM.stream({
|
.stream({
|
||||||
agent: ag,
|
agent: ag,
|
||||||
user: firstInfo,
|
user: firstInfo,
|
||||||
system: [],
|
system: [],
|
||||||
small: true,
|
small: true,
|
||||||
tools: {},
|
tools: {},
|
||||||
model: mdl,
|
model: mdl,
|
||||||
abort: signal,
|
|
||||||
sessionID: input.session.id,
|
sessionID: input.session.id,
|
||||||
retries: 2,
|
retries: 2,
|
||||||
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs],
|
||||||
})
|
})
|
||||||
return result.text
|
.pipe(
|
||||||
})
|
Stream.filter((e): e is Extract<LLM.Event, { type: "text-delta" }> => e.type === "text-delta"),
|
||||||
|
Stream.map((e) => e.text),
|
||||||
|
Stream.mkString,
|
||||||
|
Effect.orDie,
|
||||||
|
)
|
||||||
const cleaned = text
|
const cleaned = text
|
||||||
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
.replace(/<think>[\s\S]*?<\/think>\s*/g, "")
|
||||||
.split("\n")
|
.split("\n")
|
||||||
@@ -1462,8 +1467,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs })
|
||||||
|
|
||||||
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
const [skills, env, instructions, modelMsgs] = yield* Effect.all([
|
||||||
Effect.promise(() => SystemPrompt.skills(agent)),
|
sys.skills(agent),
|
||||||
Effect.promise(() => SystemPrompt.environment(model)),
|
Effect.sync(() => sys.environment(model)),
|
||||||
instruction.system().pipe(Effect.orDie),
|
instruction.system().pipe(Effect.orDie),
|
||||||
MessageV2.toModelMessagesEffect(msgs, model),
|
MessageV2.toModelMessagesEffect(msgs, model),
|
||||||
])
|
])
|
||||||
@@ -1687,9 +1692,15 @@ NOTE: At any point in time through this workflow you should feel free to ask the
|
|||||||
Layer.provide(Plugin.defaultLayer),
|
Layer.provide(Plugin.defaultLayer),
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
Layer.provide(SessionRevert.defaultLayer),
|
Layer.provide(SessionRevert.defaultLayer),
|
||||||
Layer.provide(Agent.defaultLayer),
|
Layer.provide(
|
||||||
Layer.provide(Bus.layer),
|
Layer.mergeAll(
|
||||||
Layer.provide(CrossSpawnSpawner.defaultLayer),
|
Agent.defaultLayer,
|
||||||
|
SystemPrompt.defaultLayer,
|
||||||
|
LLM.defaultLayer,
|
||||||
|
Bus.layer,
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
const { runPromise } = makeRuntime(Service, defaultLayer)
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { InstanceState } from "@/effect/instance-state"
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
import { Runner } from "@/effect/runner"
|
import { Runner } from "@/effect/runner"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { Effect, Layer, Scope, Context } from "effect"
|
import { Effect, Layer, Scope, Context } from "effect"
|
||||||
import { Session } from "."
|
import { Session } from "."
|
||||||
import { MessageV2 } from "./message-v2"
|
import { MessageV2 } from "./message-v2"
|
||||||
@@ -106,9 +105,4 @@ export namespace SessionRunState {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
export const defaultLayer = layer.pipe(Layer.provide(SessionStatus.defaultLayer))
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function assertNotBusy(sessionID: SessionID) {
|
|
||||||
return runPromise((svc) => svc.assertNotBusy(sessionID))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Ripgrep } from "../file/ripgrep"
|
import { Context, Effect, Layer } from "effect"
|
||||||
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
|
|
||||||
@@ -33,7 +33,20 @@ export namespace SystemPrompt {
|
|||||||
return [PROMPT_DEFAULT]
|
return [PROMPT_DEFAULT]
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function environment(model: Provider.Model) {
|
export interface Interface {
|
||||||
|
readonly environment: (model: Provider.Model) => string[]
|
||||||
|
readonly skills: (agent: Agent.Info) => Effect.Effect<string | undefined>
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Service extends Context.Service<Service, Interface>()("@opencode/SystemPrompt") {}
|
||||||
|
|
||||||
|
export const layer = Layer.effect(
|
||||||
|
Service,
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const skill = yield* Skill.Service
|
||||||
|
|
||||||
|
return Service.of({
|
||||||
|
environment(model) {
|
||||||
const project = Instance.project
|
const project = Instance.project
|
||||||
return [
|
return [
|
||||||
[
|
[
|
||||||
@@ -46,24 +59,14 @@ export namespace SystemPrompt {
|
|||||||
` Platform: ${process.platform}`,
|
` Platform: ${process.platform}`,
|
||||||
` Today's date: ${new Date().toDateString()}`,
|
` Today's date: ${new Date().toDateString()}`,
|
||||||
`</env>`,
|
`</env>`,
|
||||||
`<directories>`,
|
|
||||||
` ${
|
|
||||||
project.vcs === "git" && false
|
|
||||||
? await Ripgrep.tree({
|
|
||||||
cwd: Instance.directory,
|
|
||||||
limit: 50,
|
|
||||||
})
|
|
||||||
: ""
|
|
||||||
}`,
|
|
||||||
`</directories>`,
|
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
]
|
]
|
||||||
}
|
},
|
||||||
|
|
||||||
export async function skills(agent: Agent.Info) {
|
skills: Effect.fn("SystemPrompt.skills")(function* (agent: Agent.Info) {
|
||||||
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
if (Permission.disabled(["skill"], agent.permission).has("skill")) return
|
||||||
|
|
||||||
const list = await Skill.available(agent)
|
const list = yield* skill.available(agent)
|
||||||
|
|
||||||
return [
|
return [
|
||||||
"Skills provide specialized instructions and workflows for specific tasks.",
|
"Skills provide specialized instructions and workflows for specific tasks.",
|
||||||
@@ -72,5 +75,10 @@ export namespace SystemPrompt {
|
|||||||
// version of them here and a less verbose version in tool description, rather than vice versa.
|
// version of them here and a less verbose version in tool description, rather than vice versa.
|
||||||
Skill.fmt(list, { verbose: true }),
|
Skill.fmt(list, { verbose: true }),
|
||||||
].join("\n")
|
].join("\n")
|
||||||
}
|
}),
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
export const defaultLayer = layer.pipe(Layer.provide(Skill.defaultLayer))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,7 +454,8 @@ export const BashTool = Tool.define(
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return async () => {
|
return () =>
|
||||||
|
Effect.sync(() => {
|
||||||
const shell = Shell.acceptable()
|
const shell = Shell.acceptable()
|
||||||
const name = Shell.name(shell)
|
const name = Shell.name(shell)
|
||||||
const chain =
|
const chain =
|
||||||
@@ -500,6 +501,6 @@ export const BashTool = Tool.define(
|
|||||||
)
|
)
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export const MultiEditTool = Tool.define(
|
|||||||
"multiedit",
|
"multiedit",
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const editInfo = yield* EditTool
|
const editInfo = yield* EditTool
|
||||||
const edit = yield* Effect.promise(() => editInfo.init())
|
const edit = yield* editInfo.init()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export const SkillTool = Tool.define(
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const skill = yield* Skill.Service
|
const skill = yield* Skill.Service
|
||||||
const rg = yield* Ripgrep.Service
|
const rg = yield* Ripgrep.Service
|
||||||
|
return () =>
|
||||||
return async () => {
|
Effect.gen(function* () {
|
||||||
const list = await Effect.runPromise(skill.available().pipe(Effect.provide(EffectLogger.layer)))
|
const list = yield* skill.available().pipe(Effect.provide(EffectLogger.layer))
|
||||||
|
|
||||||
const description =
|
const description =
|
||||||
list.length === 0
|
list.length === 0
|
||||||
@@ -95,6 +95,6 @@ export const SkillTool = Tool.define(
|
|||||||
}
|
}
|
||||||
}).pipe(Effect.orDie),
|
}).pipe(Effect.orDie),
|
||||||
}
|
}
|
||||||
}
|
})
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -47,9 +47,13 @@ export namespace Tool {
|
|||||||
|
|
||||||
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
export interface Info<Parameters extends z.ZodType = z.ZodType, M extends Metadata = Metadata> {
|
||||||
id: string
|
id: string
|
||||||
init: () => Promise<DefWithoutID<Parameters, M>>
|
init: () => Effect.Effect<DefWithoutID<Parameters, M>>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Init<Parameters extends z.ZodType, M extends Metadata> =
|
||||||
|
| DefWithoutID<Parameters, M>
|
||||||
|
| (() => Effect.Effect<DefWithoutID<Parameters, M>>)
|
||||||
|
|
||||||
export type InferParameters<T> =
|
export type InferParameters<T> =
|
||||||
T extends Info<infer P, any>
|
T extends Info<infer P, any>
|
||||||
? z.infer<P>
|
? z.infer<P>
|
||||||
@@ -68,10 +72,13 @@ export namespace Tool {
|
|||||||
|
|
||||||
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
function wrap<Parameters extends z.ZodType, Result extends Metadata>(
|
||||||
id: string,
|
id: string,
|
||||||
init: (() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>,
|
init: Init<Parameters, Result>,
|
||||||
|
truncate: Truncate.Interface,
|
||||||
|
agents: Agent.Interface,
|
||||||
) {
|
) {
|
||||||
return async () => {
|
return () =>
|
||||||
const toolInfo = init instanceof Function ? await init() : { ...init }
|
Effect.gen(function* () {
|
||||||
|
const toolInfo = init instanceof Function ? { ...(yield* init()) } : { ...init }
|
||||||
const execute = toolInfo.execute
|
const execute = toolInfo.execute
|
||||||
toolInfo.execute = (args, ctx) =>
|
toolInfo.execute = (args, ctx) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
@@ -91,8 +98,8 @@ export namespace Tool {
|
|||||||
if (result.metadata.truncated !== undefined) {
|
if (result.metadata.truncated !== undefined) {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
const agent = yield* Effect.promise(() => Agent.get(ctx.agent))
|
const agent = yield* agents.get(ctx.agent)
|
||||||
const truncated = yield* Effect.promise(() => Truncate.output(result.output, {}, agent))
|
const truncated = yield* truncate.output(result.output, {}, agent)
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
output: truncated.content,
|
output: truncated.content,
|
||||||
@@ -104,22 +111,27 @@ export namespace Tool {
|
|||||||
}
|
}
|
||||||
}).pipe(Effect.orDie)
|
}).pipe(Effect.orDie)
|
||||||
return toolInfo
|
return toolInfo
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
export function define<Parameters extends z.ZodType, Result extends Metadata, R, ID extends string = string>(
|
||||||
id: ID,
|
id: ID,
|
||||||
init: Effect.Effect<(() => Promise<DefWithoutID<Parameters, Result>>) | DefWithoutID<Parameters, Result>, never, R>,
|
init: Effect.Effect<Init<Parameters, Result>, never, R>,
|
||||||
): Effect.Effect<Info<Parameters, Result>, never, R> & { id: ID } {
|
): Effect.Effect<Info<Parameters, Result>, never, R | Truncate.Service | Agent.Service> & { id: ID } {
|
||||||
return Object.assign(
|
return Object.assign(
|
||||||
Effect.map(init, (next) => ({ id, init: wrap(id, next) })),
|
Effect.gen(function* () {
|
||||||
|
const resolved = yield* init
|
||||||
|
const truncate = yield* Truncate.Service
|
||||||
|
const agents = yield* Agent.Service
|
||||||
|
return { id, init: wrap(id, resolved, truncate, agents) }
|
||||||
|
}),
|
||||||
{ id },
|
{ id },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
export function init<P extends z.ZodType, M extends Metadata>(info: Info<P, M>): Effect.Effect<Def<P, M>> {
|
||||||
return Effect.gen(function* () {
|
return Effect.gen(function* () {
|
||||||
const init = yield* Effect.promise(() => info.init())
|
const init = yield* info.init()
|
||||||
return {
|
return {
|
||||||
...init,
|
...init,
|
||||||
id: info.id,
|
id: info.id,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { NodePath } from "@effect/platform-node"
|
|||||||
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
import { Cause, Duration, Effect, Layer, Schedule, Context } from "effect"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import type { Agent } from "../agent/agent"
|
import type { Agent } from "../agent/agent"
|
||||||
import { makeRuntime } from "@/effect/run-service"
|
|
||||||
import { AppFileSystem } from "@/filesystem"
|
import { AppFileSystem } from "@/filesystem"
|
||||||
import { evaluate } from "@/permission/evaluate"
|
import { evaluate } from "@/permission/evaluate"
|
||||||
import { Identifier } from "../id/id"
|
import { Identifier } from "../id/id"
|
||||||
@@ -135,10 +134,4 @@ export namespace Truncate {
|
|||||||
)
|
)
|
||||||
|
|
||||||
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
export const defaultLayer = layer.pipe(Layer.provide(AppFileSystem.defaultLayer), Layer.provide(NodePath.layer))
|
||||||
|
|
||||||
const { runPromise } = makeRuntime(Service, defaultLayer)
|
|
||||||
|
|
||||||
export async function output(text: string, options: Options = {}, agent?: Agent.Info): Promise<Result> {
|
|
||||||
return runPromise((s) => s.output(text, options, agent))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,15 +6,70 @@ export namespace Keybind {
|
|||||||
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
|
* Keybind info derived from OpenTUI's ParsedKey with our custom `leader` field.
|
||||||
* This ensures type compatibility and catches missing fields at compile time.
|
* This ensures type compatibility and catches missing fields at compile time.
|
||||||
*/
|
*/
|
||||||
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super"> & {
|
export type Info = Pick<ParsedKey, "name" | "ctrl" | "meta" | "shift" | "super" | "baseCode"> & {
|
||||||
leader: boolean // our custom field
|
leader: boolean // our custom field
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getBaseCodeName(baseCode: number | undefined): string | undefined {
|
||||||
|
if (baseCode === undefined || baseCode < 32 || baseCode === 127) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const name = String.fromCodePoint(baseCode)
|
||||||
|
|
||||||
|
if (name.length === 1 && name >= "A" && name <= "Z") {
|
||||||
|
return name.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
return name
|
||||||
|
} catch {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function match(a: Info | undefined, b: Info): boolean {
|
export function match(a: Info | undefined, b: Info): boolean {
|
||||||
if (!a) return false
|
if (!a) return false
|
||||||
const normalizedA = { ...a, super: a.super ?? false }
|
const normalizedA = { ...a, super: a.super ?? false }
|
||||||
const normalizedB = { ...b, super: b.super ?? false }
|
const normalizedB = { ...b, super: b.super ?? false }
|
||||||
return isDeepEqual(normalizedA, normalizedB)
|
if (isDeepEqual(normalizedA, normalizedB)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const modifiersA = {
|
||||||
|
ctrl: normalizedA.ctrl,
|
||||||
|
meta: normalizedA.meta,
|
||||||
|
shift: normalizedA.shift,
|
||||||
|
super: normalizedA.super,
|
||||||
|
leader: normalizedA.leader,
|
||||||
|
}
|
||||||
|
const modifiersB = {
|
||||||
|
ctrl: normalizedB.ctrl,
|
||||||
|
meta: normalizedB.meta,
|
||||||
|
shift: normalizedB.shift,
|
||||||
|
super: normalizedB.super,
|
||||||
|
leader: normalizedB.leader,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isDeepEqual(modifiersA, modifiersB)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
normalizedA.name === normalizedB.name ||
|
||||||
|
getBaseCodeName(normalizedA.baseCode) === normalizedB.name ||
|
||||||
|
getBaseCodeName(normalizedB.baseCode) === normalizedA.name
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOne(key: string): Info {
|
||||||
|
const parsed = parse(key)
|
||||||
|
|
||||||
|
if (parsed.length !== 1) {
|
||||||
|
throw new Error(`Expected exactly one keybind, got ${parsed.length}: ${key}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed[0]!
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -28,10 +83,23 @@ export namespace Keybind {
|
|||||||
meta: key.meta,
|
meta: key.meta,
|
||||||
shift: key.shift,
|
shift: key.shift,
|
||||||
super: key.super ?? false,
|
super: key.super ?? false,
|
||||||
|
baseCode: key.baseCode,
|
||||||
leader,
|
leader,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function matchParsedKey(binding: Info | string | undefined, key: ParsedKey, leader = false): boolean {
|
||||||
|
const bindings = typeof binding === "string" ? parse(binding) : binding ? [binding] : []
|
||||||
|
|
||||||
|
if (!bindings.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = fromParsedKey(key, leader)
|
||||||
|
|
||||||
|
return bindings.some((item) => match(item, parsed))
|
||||||
|
}
|
||||||
|
|
||||||
export function toString(info: Info | undefined): string {
|
export function toString(info: Info | undefined): string {
|
||||||
if (!info) return ""
|
if (!info) return ""
|
||||||
const parts: string[] = []
|
const parts: string[] = []
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export namespace Log {
|
|||||||
WARN: 2,
|
WARN: 2,
|
||||||
ERROR: 3,
|
ERROR: 3,
|
||||||
}
|
}
|
||||||
|
const keep = 10
|
||||||
|
|
||||||
let level: Level = "INFO"
|
let level: Level = "INFO"
|
||||||
|
|
||||||
@@ -78,15 +79,19 @@ export namespace Log {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function cleanup(dir: string) {
|
async function cleanup(dir: string) {
|
||||||
const files = await Glob.scan("????-??-??T??????.log", {
|
const files = (
|
||||||
|
await Glob.scan("????-??-??T??????.log", {
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
absolute: true,
|
absolute: false,
|
||||||
include: "file",
|
include: "file",
|
||||||
})
|
}).catch(() => [])
|
||||||
if (files.length <= 5) return
|
)
|
||||||
|
.filter((file) => path.basename(file) === file)
|
||||||
|
.sort()
|
||||||
|
if (files.length <= keep) return
|
||||||
|
|
||||||
const filesToDelete = files.slice(0, -10)
|
const doomed = files.slice(0, -keep)
|
||||||
await Promise.all(filesToDelete.map((file) => fs.unlink(file).catch(() => {})))
|
await Promise.all(doomed.map((file) => fs.unlink(path.join(dir, file)).catch(() => {})))
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatError(error: Error, depth = 0): string {
|
function formatError(error: Error, depth = 0): string {
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { Identifier } from "@/id/id"
|
||||||
|
import { withStatics } from "@/util/schema"
|
||||||
|
import { DateTime, Effect, Schema } from "effect"
|
||||||
|
|
||||||
|
export namespace Message {
|
||||||
|
export const ID = Schema.String.pipe(Schema.brand("Message.ID")).pipe(
|
||||||
|
withStatics((s) => ({
|
||||||
|
create: () => s.make(Identifier.ascending("message")),
|
||||||
|
prefix: "msg",
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
export class File extends Schema.Class<File>("Message.File")({
|
||||||
|
url: Schema.String,
|
||||||
|
mime: Schema.String,
|
||||||
|
}) {
|
||||||
|
static create(url: string) {
|
||||||
|
return new File({
|
||||||
|
url,
|
||||||
|
mime: "text/plain",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UserContent extends Schema.Class<UserContent>("Message.User.Content")({
|
||||||
|
text: Schema.String,
|
||||||
|
synthetic: Schema.Boolean.pipe(Schema.optional),
|
||||||
|
agent: Schema.String.pipe(Schema.optional),
|
||||||
|
files: Schema.Array(File).pipe(Schema.optional),
|
||||||
|
}) {}
|
||||||
|
|
||||||
|
export class User extends Schema.Class<User>("Message.User")({
|
||||||
|
id: ID,
|
||||||
|
type: Schema.Literal("user"),
|
||||||
|
time: Schema.Struct({
|
||||||
|
created: Schema.DateTimeUtc,
|
||||||
|
}),
|
||||||
|
content: UserContent,
|
||||||
|
}) {
|
||||||
|
static create(content: Schema.Schema.Type<typeof UserContent>) {
|
||||||
|
const msg = new User({
|
||||||
|
id: ID.create(),
|
||||||
|
type: "user",
|
||||||
|
time: {
|
||||||
|
created: Effect.runSync(DateTime.now),
|
||||||
|
},
|
||||||
|
content,
|
||||||
|
})
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
static file(url: string) {
|
||||||
|
return new File({
|
||||||
|
url,
|
||||||
|
mime: "text/plain",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace User {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const msg = Message.User.create({
|
||||||
|
text: "Hello world",
|
||||||
|
files: [Message.File.create("file://example.com/file.txt")],
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log(JSON.stringify(msg, null, 2))
|
||||||
@@ -79,3 +79,55 @@ await using tmp = await tmpdir({
|
|||||||
- Directories are created in the system temp folder with prefix `opencode-test-`
|
- Directories are created in the system temp folder with prefix `opencode-test-`
|
||||||
- Use `await using` for automatic cleanup when the variable goes out of scope
|
- Use `await using` for automatic cleanup when the variable goes out of scope
|
||||||
- Paths are sanitized to strip null bytes (defensive fix for CI environments)
|
- Paths are sanitized to strip null bytes (defensive fix for CI environments)
|
||||||
|
|
||||||
|
## Testing With Effects
|
||||||
|
|
||||||
|
Use `testEffect(...)` from `test/lib/effect.ts` for tests that exercise Effect services or Effect-based workflows.
|
||||||
|
|
||||||
|
### Core Pattern
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, expect } from "bun:test"
|
||||||
|
import { Effect, Layer } from "effect"
|
||||||
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(MyService.defaultLayer))
|
||||||
|
|
||||||
|
describe("my service", () => {
|
||||||
|
it.live("does the thing", () =>
|
||||||
|
provideTmpdirInstance(() =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* MyService.Service
|
||||||
|
const out = yield* svc.run()
|
||||||
|
expect(out).toEqual("ok")
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### `it.effect` vs `it.live`
|
||||||
|
|
||||||
|
- Use `it.effect(...)` when the test should run with `TestClock` and `TestConsole`.
|
||||||
|
- Use `it.live(...)` when the test depends on real time, filesystem mtimes, child processes, git, locks, or other live OS behavior.
|
||||||
|
- Most integration-style tests in this package use `it.live(...)`.
|
||||||
|
|
||||||
|
### Effect Fixtures
|
||||||
|
|
||||||
|
Prefer the Effect-aware helpers from `fixture/fixture.ts` instead of building a manual runtime in each test.
|
||||||
|
|
||||||
|
- `tmpdirScoped(options?)` creates a scoped temp directory and cleans it up when the Effect scope closes.
|
||||||
|
- `provideInstance(dir)(effect)` is the low-level helper. It does not create a directory; it just runs an Effect with `Instance.current` bound to `dir`.
|
||||||
|
- `provideTmpdirInstance((dir) => effect, options?)` is the convenience helper. It creates a temp directory, binds it as the active instance, and disposes the instance on cleanup.
|
||||||
|
- `provideTmpdirServer((input) => effect, options?)` does the same, but also provides the test LLM server.
|
||||||
|
|
||||||
|
Use `provideTmpdirInstance(...)` by default when a test only needs one temp instance. Use `tmpdirScoped()` plus `provideInstance(...)` when a test needs multiple directories, custom setup before binding, or needs to switch instance context within one test.
|
||||||
|
|
||||||
|
### Style
|
||||||
|
|
||||||
|
- Define `const it = testEffect(...)` near the top of the file.
|
||||||
|
- Keep the test body inside `Effect.gen(function* () { ... })`.
|
||||||
|
- Yield services directly with `yield* MyService.Service` or `yield* MyTool`.
|
||||||
|
- Avoid custom `ManagedRuntime`, `attach(...)`, or ad hoc `run(...)` wrappers when `testEffect(...)` already provides the runtime.
|
||||||
|
- When a test needs instance-local state, prefer `provideTmpdirInstance(...)` or `provideInstance(...)` over manual `Instance.provide(...)` inside Promise-style tests.
|
||||||
|
|||||||
@@ -1,445 +1,422 @@
|
|||||||
import { describe, test, expect, afterEach } from "bun:test"
|
import { afterEach, describe, expect } from "bun:test"
|
||||||
import path from "path"
|
|
||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Cause, Deferred, Effect, Exit, Fiber, Layer } from "effect"
|
||||||
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { FileTime } from "../../src/file/time"
|
import { FileTime } from "../../src/file/time"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { SessionID } from "../../src/session/schema"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { provideInstance, provideTmpdirInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function touch(file: string, time: number) {
|
const it = testEffect(Layer.mergeAll(FileTime.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
||||||
const date = new Date(time)
|
|
||||||
await fs.utimes(file, date, date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function gate() {
|
const id = SessionID.make("ses_00000000000000000000000001")
|
||||||
let open!: () => void
|
|
||||||
const wait = new Promise<void>((resolve) => {
|
const put = (file: string, text: string) => Effect.promise(() => fs.writeFile(file, text, "utf-8"))
|
||||||
open = resolve
|
|
||||||
|
const touch = (file: string, time: number) =>
|
||||||
|
Effect.promise(() => {
|
||||||
|
const date = new Date(time)
|
||||||
|
return fs.utimes(file, date, date)
|
||||||
})
|
})
|
||||||
return { open, wait }
|
|
||||||
}
|
const read = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.read(id, file))
|
||||||
|
|
||||||
|
const get = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.get(id, file))
|
||||||
|
|
||||||
|
const check = (id: SessionID, file: string) => FileTime.Service.use((svc) => svc.assert(id, file))
|
||||||
|
|
||||||
|
const lock = <A>(file: string, fn: () => Effect.Effect<A>) => FileTime.Service.use((svc) => svc.withLock(file, fn))
|
||||||
|
|
||||||
|
const fail = Effect.fn("FileTimeTest.fail")(function* <A, E, R>(self: Effect.Effect<A, E, R>) {
|
||||||
|
const exit = yield* self.pipe(Effect.exit)
|
||||||
|
if (Exit.isFailure(exit)) {
|
||||||
|
const err = Cause.squash(exit.cause)
|
||||||
|
return err instanceof Error ? err : new Error(String(err))
|
||||||
|
}
|
||||||
|
throw new Error("expected file time effect to fail")
|
||||||
|
})
|
||||||
|
|
||||||
describe("file/time", () => {
|
describe("file/time", () => {
|
||||||
const sessionID = SessionID.make("ses_00000000000000000000000001")
|
|
||||||
|
|
||||||
describe("read() and get()", () => {
|
describe("read() and get()", () => {
|
||||||
test("stores read timestamp", async () => {
|
it.live("stores read timestamp", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const before = yield* get(id, file)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const before = await FileTime.get(sessionID, filepath)
|
|
||||||
expect(before).toBeUndefined()
|
expect(before).toBeUndefined()
|
||||||
|
|
||||||
await FileTime.read(sessionID, filepath)
|
yield* read(id, file)
|
||||||
|
|
||||||
const after = await FileTime.get(sessionID, filepath)
|
const after = yield* get(id, file)
|
||||||
expect(after).toBeInstanceOf(Date)
|
expect(after).toBeInstanceOf(Date)
|
||||||
expect(after!.getTime()).toBeGreaterThan(0)
|
expect(after!.getTime()).toBeGreaterThan(0)
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("tracks separate timestamps per session", async () => {
|
it.live("tracks separate timestamps per session", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const one = SessionID.make("ses_00000000000000000000000002")
|
||||||
directory: tmp.path,
|
const two = SessionID.make("ses_00000000000000000000000003")
|
||||||
fn: async () => {
|
yield* read(one, file)
|
||||||
await FileTime.read(SessionID.make("ses_00000000000000000000000002"), filepath)
|
yield* read(two, file)
|
||||||
await FileTime.read(SessionID.make("ses_00000000000000000000000003"), filepath)
|
|
||||||
|
|
||||||
const time1 = await FileTime.get(SessionID.make("ses_00000000000000000000000002"), filepath)
|
const first = yield* get(one, file)
|
||||||
const time2 = await FileTime.get(SessionID.make("ses_00000000000000000000000003"), filepath)
|
const second = yield* get(two, file)
|
||||||
|
|
||||||
expect(time1).toBeDefined()
|
expect(first).toBeDefined()
|
||||||
expect(time2).toBeDefined()
|
expect(second).toBeDefined()
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("updates timestamp on subsequent reads", async () => {
|
it.live("updates timestamp on subsequent reads", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
const first = yield* get(id, file)
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
const first = await FileTime.get(sessionID, filepath)
|
|
||||||
|
|
||||||
await FileTime.read(sessionID, filepath)
|
yield* read(id, file)
|
||||||
const second = await FileTime.get(sessionID, filepath)
|
const second = yield* get(id, file)
|
||||||
|
|
||||||
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
expect(second!.getTime()).toBeGreaterThanOrEqual(first!.getTime())
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("isolates reads by directory", async () => {
|
it.live("isolates reads by directory", () =>
|
||||||
await using one = await tmpdir()
|
Effect.gen(function* () {
|
||||||
await using two = await tmpdir()
|
const one = yield* tmpdirScoped()
|
||||||
await using shared = await tmpdir()
|
const two = yield* tmpdirScoped()
|
||||||
const filepath = path.join(shared.path, "file.txt")
|
const shared = yield* tmpdirScoped()
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(shared, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
yield* provideInstance(one)(read(id, file))
|
||||||
directory: one.path,
|
const result = yield* provideInstance(two)(get(id, file))
|
||||||
fn: async () => {
|
expect(result).toBeUndefined()
|
||||||
await FileTime.read(sessionID, filepath)
|
}),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: two.path,
|
|
||||||
fn: async () => {
|
|
||||||
expect(await FileTime.get(sessionID, filepath)).toBeUndefined()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("assert()", () => {
|
describe("assert()", () => {
|
||||||
test("passes when file has not been modified", async () => {
|
it.live("passes when file has not been modified", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
yield* check(id, file)
|
||||||
fn: async () => {
|
}),
|
||||||
await FileTime.read(sessionID, filepath)
|
),
|
||||||
await FileTime.assert(sessionID, filepath)
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when file was not read first", async () => {
|
it.live("throws when file was not read first", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const err = yield* fail(check(id, file))
|
||||||
directory: tmp.path,
|
expect(err.message).toContain("You must read file")
|
||||||
fn: async () => {
|
}),
|
||||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
|
),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws when file was modified after read", async () => {
|
it.live("throws when file was modified after read", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
yield* put(file, "modified content")
|
||||||
fn: async () => {
|
yield* touch(file, 2_000)
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
await fs.writeFile(filepath, "modified content", "utf-8")
|
|
||||||
await touch(filepath, 2_000)
|
|
||||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("includes timestamps in error message", async () => {
|
const err = yield* fail(check(id, file))
|
||||||
await using tmp = await tmpdir()
|
expect(err.message).toContain("modified since it was last read")
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
}),
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
),
|
||||||
await touch(filepath, 1_000)
|
)
|
||||||
|
|
||||||
await Instance.provide({
|
it.live("includes timestamps in error message", () =>
|
||||||
directory: tmp.path,
|
provideTmpdirInstance((dir) =>
|
||||||
fn: async () => {
|
Effect.gen(function* () {
|
||||||
await FileTime.read(sessionID, filepath)
|
const file = path.join(dir, "file.txt")
|
||||||
await fs.writeFile(filepath, "modified", "utf-8")
|
yield* put(file, "content")
|
||||||
await touch(filepath, 2_000)
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
let error: Error | undefined
|
yield* read(id, file)
|
||||||
try {
|
yield* put(file, "modified")
|
||||||
await FileTime.assert(sessionID, filepath)
|
yield* touch(file, 2_000)
|
||||||
} catch (e) {
|
|
||||||
error = e as Error
|
const err = yield* fail(check(id, file))
|
||||||
}
|
expect(err.message).toContain("Last modification:")
|
||||||
expect(error).toBeDefined()
|
expect(err.message).toContain("Last read:")
|
||||||
expect(error!.message).toContain("Last modification:")
|
}),
|
||||||
expect(error!.message).toContain("Last read:")
|
),
|
||||||
},
|
)
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("withLock()", () => {
|
describe("withLock()", () => {
|
||||||
test("executes function within lock", async () => {
|
it.live("executes function within lock", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
let hit = false
|
||||||
|
|
||||||
await Instance.provide({
|
yield* lock(file, () =>
|
||||||
directory: tmp.path,
|
Effect.sync(() => {
|
||||||
fn: async () => {
|
hit = true
|
||||||
let executed = false
|
|
||||||
await FileTime.withLock(filepath, async () => {
|
|
||||||
executed = true
|
|
||||||
return "result"
|
return "result"
|
||||||
})
|
}),
|
||||||
expect(executed).toBe(true)
|
)
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("returns function result", async () => {
|
expect(hit).toBe(true)
|
||||||
await using tmp = await tmpdir()
|
}),
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
),
|
||||||
|
)
|
||||||
|
|
||||||
await Instance.provide({
|
it.live("returns function result", () =>
|
||||||
directory: tmp.path,
|
provideTmpdirInstance((dir) =>
|
||||||
fn: async () => {
|
Effect.gen(function* () {
|
||||||
const result = await FileTime.withLock(filepath, async () => {
|
const file = path.join(dir, "file.txt")
|
||||||
return "success"
|
const result = yield* lock(file, () => Effect.succeed("success"))
|
||||||
})
|
|
||||||
expect(result).toBe("success")
|
expect(result).toBe("success")
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("serializes concurrent operations on same file", async () => {
|
it.live("serializes concurrent operations on same file", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const order: number[] = []
|
const order: number[] = []
|
||||||
const hold = gate()
|
const hold = yield* Deferred.make<void>()
|
||||||
const ready = gate()
|
const ready = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath, async () => {
|
const one = yield* lock(file, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
order.push(1)
|
order.push(1)
|
||||||
ready.open()
|
yield* Deferred.succeed(ready, void 0)
|
||||||
await hold.wait
|
yield* Deferred.await(hold)
|
||||||
order.push(2)
|
order.push(2)
|
||||||
})
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
await ready.wait
|
yield* Deferred.await(ready)
|
||||||
|
|
||||||
const op2 = FileTime.withLock(filepath, async () => {
|
const two = yield* lock(file, () =>
|
||||||
|
Effect.sync(() => {
|
||||||
order.push(3)
|
order.push(3)
|
||||||
order.push(4)
|
order.push(4)
|
||||||
})
|
|
||||||
|
|
||||||
hold.open()
|
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
|
||||||
expect(order).toEqual([1, 2, 3, 4])
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("allows concurrent operations on different files", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath1 = path.join(tmp.path, "file1.txt")
|
|
||||||
const filepath2 = path.join(tmp.path, "file2.txt")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
let started1 = false
|
|
||||||
let started2 = false
|
|
||||||
const hold = gate()
|
|
||||||
const ready = gate()
|
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath1, async () => {
|
|
||||||
started1 = true
|
|
||||||
ready.open()
|
|
||||||
await hold.wait
|
|
||||||
expect(started2).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
await ready.wait
|
|
||||||
|
|
||||||
const op2 = FileTime.withLock(filepath2, async () => {
|
|
||||||
started2 = true
|
|
||||||
hold.open()
|
|
||||||
})
|
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
|
||||||
expect(started1).toBe(true)
|
|
||||||
expect(started2).toBe(true)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("releases lock even if function throws", async () => {
|
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await expect(
|
|
||||||
FileTime.withLock(filepath, async () => {
|
|
||||||
throw new Error("Test error")
|
|
||||||
}),
|
}),
|
||||||
).rejects.toThrow("Test error")
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
let executed = false
|
yield* Deferred.succeed(hold, void 0)
|
||||||
await FileTime.withLock(filepath, async () => {
|
yield* Fiber.join(one)
|
||||||
executed = true
|
yield* Fiber.join(two)
|
||||||
})
|
|
||||||
expect(executed).toBe(true)
|
expect(order).toEqual([1, 2, 3, 4])
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
|
it.live("allows concurrent operations on different files", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const onefile = path.join(dir, "file1.txt")
|
||||||
|
const twofile = path.join(dir, "file2.txt")
|
||||||
|
let one = false
|
||||||
|
let two = false
|
||||||
|
const hold = yield* Deferred.make<void>()
|
||||||
|
const ready = yield* Deferred.make<void>()
|
||||||
|
|
||||||
|
const a = yield* lock(onefile, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
one = true
|
||||||
|
yield* Deferred.succeed(ready, void 0)
|
||||||
|
yield* Deferred.await(hold)
|
||||||
|
expect(two).toBe(true)
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Deferred.await(ready)
|
||||||
|
|
||||||
|
const b = yield* lock(twofile, () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
two = true
|
||||||
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
|
yield* Fiber.join(b)
|
||||||
|
yield* Deferred.succeed(hold, void 0)
|
||||||
|
yield* Fiber.join(a)
|
||||||
|
|
||||||
|
expect(one).toBe(true)
|
||||||
|
expect(two).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.live("releases lock even if function throws", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
const err = yield* fail(lock(file, () => Effect.die(new Error("Test error"))))
|
||||||
|
expect(err.message).toContain("Test error")
|
||||||
|
|
||||||
|
let hit = false
|
||||||
|
yield* lock(file, () =>
|
||||||
|
Effect.sync(() => {
|
||||||
|
hit = true
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
expect(hit).toBe(true)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("path normalization", () => {
|
describe("path normalization", () => {
|
||||||
test("read with forward slashes, assert with backslashes", async () => {
|
it.live("read with forward slashes, assert with backslashes", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
const forward = file.replaceAll("\\", "/")
|
||||||
|
yield* read(id, forward)
|
||||||
|
yield* check(id, file)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
await Instance.provide({
|
it.live("read with backslashes, assert with forward slashes", () =>
|
||||||
directory: tmp.path,
|
provideTmpdirInstance((dir) =>
|
||||||
fn: async () => {
|
Effect.gen(function* () {
|
||||||
await FileTime.read(sessionID, forwardSlash)
|
const file = path.join(dir, "file.txt")
|
||||||
// assert with the native backslash path should still work
|
yield* put(file, "content")
|
||||||
await FileTime.assert(sessionID, filepath)
|
yield* touch(file, 1_000)
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("read with backslashes, assert with forward slashes", async () => {
|
const forward = file.replaceAll("\\", "/")
|
||||||
await using tmp = await tmpdir()
|
yield* read(id, file)
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
yield* check(id, forward)
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
}),
|
||||||
await touch(filepath, 1_000)
|
),
|
||||||
|
)
|
||||||
|
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
it.live("get returns timestamp regardless of slash direction", () =>
|
||||||
|
provideTmpdirInstance((dir) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
|
yield* put(file, "content")
|
||||||
|
|
||||||
await Instance.provide({
|
const forward = file.replaceAll("\\", "/")
|
||||||
directory: tmp.path,
|
yield* read(id, forward)
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
// assert with forward slashes should still work
|
|
||||||
await FileTime.assert(sessionID, forwardSlash)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("get returns timestamp regardless of slash direction", async () => {
|
const result = yield* get(id, file)
|
||||||
await using tmp = await tmpdir()
|
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
|
||||||
|
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, forwardSlash)
|
|
||||||
const result = await FileTime.get(sessionID, filepath)
|
|
||||||
expect(result).toBeInstanceOf(Date)
|
expect(result).toBeInstanceOf(Date)
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("withLock serializes regardless of slash direction", async () => {
|
it.live("withLock serializes regardless of slash direction", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
|
const file = path.join(dir, "file.txt")
|
||||||
const forwardSlash = filepath.replaceAll("\\", "/")
|
const forward = file.replaceAll("\\", "/")
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const order: number[] = []
|
const order: number[] = []
|
||||||
const hold = gate()
|
const hold = yield* Deferred.make<void>()
|
||||||
const ready = gate()
|
const ready = yield* Deferred.make<void>()
|
||||||
|
|
||||||
const op1 = FileTime.withLock(filepath, async () => {
|
const one = yield* lock(file, () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
order.push(1)
|
order.push(1)
|
||||||
ready.open()
|
yield* Deferred.succeed(ready, void 0)
|
||||||
await hold.wait
|
yield* Deferred.await(hold)
|
||||||
order.push(2)
|
order.push(2)
|
||||||
})
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
await ready.wait
|
yield* Deferred.await(ready)
|
||||||
|
|
||||||
// Use forward-slash variant -- should still serialize against op1
|
const two = yield* lock(forward, () =>
|
||||||
const op2 = FileTime.withLock(forwardSlash, async () => {
|
Effect.sync(() => {
|
||||||
order.push(3)
|
order.push(3)
|
||||||
order.push(4)
|
order.push(4)
|
||||||
})
|
}),
|
||||||
|
).pipe(Effect.forkScoped)
|
||||||
|
|
||||||
hold.open()
|
yield* Deferred.succeed(hold, void 0)
|
||||||
|
yield* Fiber.join(one)
|
||||||
|
yield* Fiber.join(two)
|
||||||
|
|
||||||
await Promise.all([op1, op2])
|
|
||||||
expect(order).toEqual([1, 2, 3, 4])
|
expect(order).toEqual([1, 2, 3, 4])
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("stat() Filesystem.stat pattern", () => {
|
describe("stat() Filesystem.stat pattern", () => {
|
||||||
test("reads file modification time via Filesystem.stat()", async () => {
|
it.live("reads file modification time via Filesystem.stat()", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "content", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "content")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
|
|
||||||
const stats = Filesystem.stat(filepath)
|
const stat = Filesystem.stat(file)
|
||||||
expect(stats?.mtime).toBeInstanceOf(Date)
|
expect(stat?.mtime).toBeInstanceOf(Date)
|
||||||
expect(stats!.mtime.getTime()).toBeGreaterThan(0)
|
expect(stat!.mtime.getTime()).toBeGreaterThan(0)
|
||||||
|
|
||||||
await FileTime.assert(sessionID, filepath)
|
yield* check(id, file)
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
|
|
||||||
test("detects modification via stat mtime", async () => {
|
it.live("detects modification via stat mtime", () =>
|
||||||
await using tmp = await tmpdir()
|
provideTmpdirInstance((dir) =>
|
||||||
const filepath = path.join(tmp.path, "file.txt")
|
Effect.gen(function* () {
|
||||||
await fs.writeFile(filepath, "original", "utf-8")
|
const file = path.join(dir, "file.txt")
|
||||||
await touch(filepath, 1_000)
|
yield* put(file, "original")
|
||||||
|
yield* touch(file, 1_000)
|
||||||
|
|
||||||
await Instance.provide({
|
yield* read(id, file)
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await FileTime.read(sessionID, filepath)
|
|
||||||
|
|
||||||
const originalStat = Filesystem.stat(filepath)
|
const first = Filesystem.stat(file)
|
||||||
|
|
||||||
await fs.writeFile(filepath, "modified", "utf-8")
|
yield* put(file, "modified")
|
||||||
await touch(filepath, 2_000)
|
yield* touch(file, 2_000)
|
||||||
|
|
||||||
const newStat = Filesystem.stat(filepath)
|
const second = Filesystem.stat(file)
|
||||||
expect(newStat!.mtime.getTime()).toBeGreaterThan(originalStat!.mtime.getTime())
|
expect(second!.mtime.getTime()).toBeGreaterThan(first!.mtime.getTime())
|
||||||
|
|
||||||
await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
|
yield* fail(check(id, file))
|
||||||
},
|
}),
|
||||||
})
|
),
|
||||||
})
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -162,6 +162,24 @@ describe("Keybind.match", () => {
|
|||||||
expect(Keybind.match(a, b)).toBe(true)
|
expect(Keybind.match(a, b)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("should match ctrl shortcuts by baseCode from alternate layouts", () => {
|
||||||
|
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "c" }
|
||||||
|
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||||
|
expect(Keybind.match(a, b)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should still match the reported character when baseCode is also present", () => {
|
||||||
|
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ" }
|
||||||
|
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||||
|
expect(Keybind.match(a, b)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should not match a different shortcut just because baseCode exists", () => {
|
||||||
|
const a: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "x" }
|
||||||
|
const b: Keybind.Info = { ctrl: true, meta: false, shift: false, leader: false, name: "ㅊ", baseCode: 99 }
|
||||||
|
expect(Keybind.match(a, b)).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
test("should match super+shift combination", () => {
|
test("should match super+shift combination", () => {
|
||||||
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
const a: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
||||||
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
const b: Keybind.Info = { ctrl: false, meta: false, shift: true, super: true, leader: false, name: "z" }
|
||||||
@@ -419,3 +437,68 @@ describe("Keybind.parse", () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Keybind.parseOne", () => {
|
||||||
|
test("should parse a single keybind", () => {
|
||||||
|
expect(Keybind.parseOne("ctrl+x")).toEqual({
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
leader: false,
|
||||||
|
name: "x",
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should reject multiple keybinds", () => {
|
||||||
|
expect(() => Keybind.parseOne("ctrl+x,ctrl+y")).toThrow("Expected exactly one keybind")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("Keybind.fromParsedKey", () => {
|
||||||
|
test("should preserve baseCode from ParsedKey", () => {
|
||||||
|
const result = Keybind.fromParsedKey({
|
||||||
|
name: "ㅊ",
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
number: false,
|
||||||
|
sequence: "ㅊ",
|
||||||
|
raw: "\x1b[12618::99;5u",
|
||||||
|
eventType: "press",
|
||||||
|
source: "kitty",
|
||||||
|
baseCode: 99,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
name: "ㅊ",
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
super: false,
|
||||||
|
leader: false,
|
||||||
|
baseCode: 99,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
test("should ignore leader unless explicitly requested", () => {
|
||||||
|
const key = {
|
||||||
|
name: "ㅊ",
|
||||||
|
ctrl: true,
|
||||||
|
meta: false,
|
||||||
|
shift: false,
|
||||||
|
option: false,
|
||||||
|
number: false,
|
||||||
|
sequence: "ㅊ",
|
||||||
|
raw: "\x1b[12618::99;5u",
|
||||||
|
eventType: "press" as const,
|
||||||
|
source: "kitty" as const,
|
||||||
|
baseCode: 99,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+c", key)).toBe(true)
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+x,ctrl+c", key)).toBe(true)
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+x,ctrl+y", key)).toBe(false)
|
||||||
|
expect(Keybind.matchParsedKey("ctrl+c", key, true)).toBe(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { abortAfterAny } from "../../src/util/abort"
|
||||||
|
|
||||||
|
const MB = 1024 * 1024
|
||||||
|
const ITERATIONS = 50
|
||||||
|
|
||||||
|
const heap = () => {
|
||||||
|
Bun.gc(true)
|
||||||
|
return process.memoryUsage().heapUsed / MB
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
port: 0,
|
||||||
|
fetch() {
|
||||||
|
return new Response("hello from local", {
|
||||||
|
headers: {
|
||||||
|
"content-type": "text/plain",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = `http://127.0.0.1:${server.port}`
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
const { signal, clearTimeout } = abortAfterAny(30000, new AbortController().signal)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal })
|
||||||
|
await response.text()
|
||||||
|
} finally {
|
||||||
|
clearTimeout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await run()
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline = heap()
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
await run()
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after = heap()
|
||||||
|
process.stdout.write(JSON.stringify({ baseline, after, growth: after - baseline }))
|
||||||
|
} finally {
|
||||||
|
server.stop(true)
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import path from "path"
|
||||||
|
|
||||||
|
const projectRoot = path.join(import.meta.dir, "../..")
|
||||||
|
const worker = path.join(import.meta.dir, "abort-leak-webfetch.ts")
|
||||||
|
|
||||||
|
const MB = 1024 * 1024
|
||||||
|
const ITERATIONS = 50
|
||||||
|
|
||||||
|
const getHeapMB = () => {
|
||||||
|
Bun.gc(true)
|
||||||
|
return process.memoryUsage().heapUsed / MB
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("memory: abort controller leak", () => {
|
||||||
|
test("webfetch does not leak memory over many invocations", async () => {
|
||||||
|
// Measure the abort-timed fetch path in a fresh process so shared tool
|
||||||
|
// runtime state does not dominate the heap signal.
|
||||||
|
const proc = Bun.spawn({
|
||||||
|
cmd: [process.execPath, worker],
|
||||||
|
cwd: projectRoot,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
env: process.env,
|
||||||
|
})
|
||||||
|
|
||||||
|
const [code, stdout, stderr] = await Promise.all([
|
||||||
|
proc.exited,
|
||||||
|
new Response(proc.stdout).text(),
|
||||||
|
new Response(proc.stderr).text(),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (code !== 0) {
|
||||||
|
throw new Error(stderr.trim() || stdout.trim() || `worker exited with code ${code}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = JSON.parse(stdout.trim()) as {
|
||||||
|
baseline: number
|
||||||
|
after: number
|
||||||
|
growth: number
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Baseline: ${result.baseline.toFixed(2)} MB`)
|
||||||
|
console.log(`After ${ITERATIONS} fetches: ${result.after.toFixed(2)} MB`)
|
||||||
|
console.log(`Growth: ${result.growth.toFixed(2)} MB`)
|
||||||
|
|
||||||
|
// Memory growth should be minimal - less than 1MB per 10 requests.
|
||||||
|
expect(result.growth).toBeLessThan(ITERATIONS / 10)
|
||||||
|
}, 60000)
|
||||||
|
|
||||||
|
test("compare closure vs bind pattern directly", async () => {
|
||||||
|
const ITERATIONS = 500
|
||||||
|
|
||||||
|
// Test OLD pattern: arrow function closure
|
||||||
|
// Store closures in a map keyed by content to force retention
|
||||||
|
const closureMap = new Map<string, () => void>()
|
||||||
|
const timers: Timer[] = []
|
||||||
|
const controllers: AbortController[] = []
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline = getHeapMB()
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
// Simulate large response body like webfetch would have
|
||||||
|
const content = `${i}:${"x".repeat(50 * 1024)}` // 50KB unique per iteration
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers.push(controller)
|
||||||
|
|
||||||
|
// OLD pattern - closure captures `content`
|
||||||
|
const handler = () => {
|
||||||
|
// Actually use content so it can't be optimized away
|
||||||
|
if (content.length > 1000000000) controller.abort()
|
||||||
|
}
|
||||||
|
closureMap.set(content, handler)
|
||||||
|
const timeoutId = setTimeout(handler, 30000)
|
||||||
|
timers.push(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after = getHeapMB()
|
||||||
|
const oldGrowth = after - baseline
|
||||||
|
|
||||||
|
console.log(`OLD pattern (closure): ${oldGrowth.toFixed(2)} MB growth (${closureMap.size} closures)`)
|
||||||
|
|
||||||
|
// Cleanup after measuring
|
||||||
|
timers.forEach(clearTimeout)
|
||||||
|
controllers.forEach((c) => c.abort())
|
||||||
|
closureMap.clear()
|
||||||
|
|
||||||
|
// Test NEW pattern: bind
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const baseline2 = getHeapMB()
|
||||||
|
const handlers2: (() => void)[] = []
|
||||||
|
const timers2: Timer[] = []
|
||||||
|
const controllers2: AbortController[] = []
|
||||||
|
|
||||||
|
for (let i = 0; i < ITERATIONS; i++) {
|
||||||
|
const _content = `${i}:${"x".repeat(50 * 1024)}` // 50KB - won't be captured
|
||||||
|
const controller = new AbortController()
|
||||||
|
controllers2.push(controller)
|
||||||
|
|
||||||
|
// NEW pattern - bind doesn't capture surrounding scope
|
||||||
|
const handler = controller.abort.bind(controller)
|
||||||
|
handlers2.push(handler)
|
||||||
|
const timeoutId = setTimeout(handler, 30000)
|
||||||
|
timers2.push(timeoutId)
|
||||||
|
}
|
||||||
|
|
||||||
|
Bun.gc(true)
|
||||||
|
Bun.sleepSync(100)
|
||||||
|
const after2 = getHeapMB()
|
||||||
|
const newGrowth = after2 - baseline2
|
||||||
|
|
||||||
|
// Cleanup after measuring
|
||||||
|
timers2.forEach(clearTimeout)
|
||||||
|
controllers2.forEach((c) => c.abort())
|
||||||
|
handlers2.length = 0
|
||||||
|
|
||||||
|
console.log(`NEW pattern (bind): ${newGrowth.toFixed(2)} MB growth`)
|
||||||
|
console.log(`Improvement: ${(oldGrowth - newGrowth).toFixed(2)} MB saved`)
|
||||||
|
|
||||||
|
expect(newGrowth).toBeLessThanOrEqual(oldGrowth)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, expect, mock, test } from "bun:test"
|
import { afterEach, expect, mock, test } from "bun:test"
|
||||||
import { CopilotModels } from "@/plugin/github-copilot/models"
|
import { CopilotModels } from "@/plugin/github-copilot/models"
|
||||||
|
import { CopilotAuthPlugin } from "@/plugin/github-copilot/copilot"
|
||||||
|
|
||||||
const originalFetch = globalThis.fetch
|
const originalFetch = globalThis.fetch
|
||||||
|
|
||||||
@@ -115,3 +116,45 @@ test("preserves temperature support from existing provider models", async () =>
|
|||||||
expect(models["gpt-4o"].capabilities.temperature).toBe(true)
|
expect(models["gpt-4o"].capabilities.temperature).toBe(true)
|
||||||
expect(models["brand-new"].capabilities.temperature).toBe(true)
|
expect(models["brand-new"].capabilities.temperature).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("remaps fallback oauth model urls to the enterprise host", async () => {
|
||||||
|
globalThis.fetch = mock(() => Promise.reject(new Error("timeout"))) as unknown as typeof fetch
|
||||||
|
|
||||||
|
const hooks = await CopilotAuthPlugin({
|
||||||
|
client: {} as never,
|
||||||
|
project: {} as never,
|
||||||
|
directory: "",
|
||||||
|
worktree: "",
|
||||||
|
serverUrl: new URL("https://example.com"),
|
||||||
|
$: {} as never,
|
||||||
|
})
|
||||||
|
|
||||||
|
const models = await hooks.provider!.models!(
|
||||||
|
{
|
||||||
|
id: "github-copilot",
|
||||||
|
models: {
|
||||||
|
claude: {
|
||||||
|
id: "claude",
|
||||||
|
providerID: "github-copilot",
|
||||||
|
api: {
|
||||||
|
id: "claude-sonnet-4.5",
|
||||||
|
url: "https://api.githubcopilot.com/v1",
|
||||||
|
npm: "@ai-sdk/anthropic",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
type: "oauth",
|
||||||
|
refresh: "token",
|
||||||
|
access: "token",
|
||||||
|
expires: Date.now() + 60_000,
|
||||||
|
enterpriseUrl: "ghe.example.com",
|
||||||
|
} as never,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(models.claude.api.url).toBe("https://copilot-api.ghe.example.com")
|
||||||
|
expect(models.claude.api.npm).toBe("@ai-sdk/github-copilot")
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,8 +13,6 @@ const { PluginLoader } = await import("../../src/plugin/loader")
|
|||||||
const { readPackageThemes } = await import("../../src/plugin/shared")
|
const { readPackageThemes } = await import("../../src/plugin/shared")
|
||||||
const { Instance } = await import("../../src/project/instance")
|
const { Instance } = await import("../../src/project/instance")
|
||||||
const { Npm } = await import("../../src/npm")
|
const { Npm } = await import("../../src/npm")
|
||||||
const { Bus } = await import("../../src/bus")
|
|
||||||
const { Session } = await import("../../src/session")
|
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
if (disableDefault === undefined) {
|
if (disableDefault === undefined) {
|
||||||
@@ -37,27 +35,6 @@ async function load(dir: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function errs(dir: string) {
|
|
||||||
return Instance.provide({
|
|
||||||
directory: dir,
|
|
||||||
fn: async () => {
|
|
||||||
const errors: string[] = []
|
|
||||||
const off = Bus.subscribe(Session.Event.Error, (evt) => {
|
|
||||||
const error = evt.properties.error
|
|
||||||
if (!error || typeof error !== "object") return
|
|
||||||
if (!("data" in error)) return
|
|
||||||
if (!error.data || typeof error.data !== "object") return
|
|
||||||
if (!("message" in error.data)) return
|
|
||||||
if (typeof error.data.message !== "string") return
|
|
||||||
errors.push(error.data.message)
|
|
||||||
})
|
|
||||||
await Plugin.list()
|
|
||||||
off()
|
|
||||||
return errors
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("plugin.loader.shared", () => {
|
describe("plugin.loader.shared", () => {
|
||||||
test("loads a file:// plugin function export", async () => {
|
test("loads a file:// plugin function export", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
@@ -184,14 +161,13 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("must export id"))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("rejects v1 plugin that exports server and tui together", async () => {
|
test("rejects v1 plugin that exports server and tui together", async () => {
|
||||||
@@ -223,14 +199,13 @@ describe("plugin.loader.shared", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("either server() or tui(), not both"))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("resolves npm plugin specs with explicit and default versions", async () => {
|
test("resolves npm plugin specs with explicit and default versions", async () => {
|
||||||
@@ -383,8 +358,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
@@ -436,8 +410,7 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("called")
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
@@ -482,14 +455,13 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
|
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors).toHaveLength(0)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
@@ -546,13 +518,12 @@ describe("plugin.loader.shared", () => {
|
|||||||
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
const install = spyOn(Npm, "add").mockResolvedValue({ directory: tmp.extra.mod, entrypoint: tmp.extra.mod })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
const called = await Bun.file(tmp.extra.mark)
|
const called = await Bun.file(tmp.extra.mark)
|
||||||
.text()
|
.text()
|
||||||
.then(() => true)
|
.then(() => true)
|
||||||
.catch(() => false)
|
.catch(() => false)
|
||||||
expect(called).toBe(false)
|
expect(called).toBe(false)
|
||||||
expect(errors.some((x) => x.includes("outside plugin directory"))).toBe(true)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
@@ -588,30 +559,49 @@ describe("plugin.loader.shared", () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when install fails", async () => {
|
test("skips broken plugin when install fails", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: ["broken-plugin@9.9.9"] }, null, 2))
|
const ok = path.join(dir, "ok.ts")
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
|
await Bun.write(
|
||||||
|
ok,
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "opencode.json"),
|
||||||
|
JSON.stringify({ plugin: ["broken-plugin@9.9.9", pathToFileURL(ok).href] }, null, 2),
|
||||||
|
)
|
||||||
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
const install = spyOn(Npm, "add").mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(install).toHaveBeenCalledWith("broken-plugin@9.9.9")
|
||||||
expect(errors.some((x) => x.includes("Failed to install plugin broken-plugin@9.9.9") && x.includes("boom"))).toBe(
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
true,
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
install.mockRestore()
|
install.mockRestore()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin init throws", async () => {
|
test("continues loading plugins when plugin init throws", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
const file = pathToFileURL(path.join(dir, "throws.ts")).href
|
||||||
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(dir, "throws.ts"),
|
path.join(dir, "throws.ts"),
|
||||||
[
|
[
|
||||||
@@ -624,51 +614,91 @@ describe("plugin.loader.shared", () => {
|
|||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
)
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||||
|
|
||||||
return { file }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}: explode`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin module has invalid export", async () => {
|
test("continues loading plugins when plugin module has invalid export", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
const file = pathToFileURL(path.join(dir, "invalid.ts")).href
|
||||||
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
await Bun.write(
|
await Bun.write(
|
||||||
path.join(dir, "invalid.ts"),
|
path.join(dir, "invalid.ts"),
|
||||||
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
["export default {", ' id: "demo.invalid",', " nope: true,", "}", ""].join("\n"),
|
||||||
)
|
)
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file] }, null, 2))
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [file, ok] }, null, 2))
|
||||||
|
|
||||||
return { file }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.file}`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("publishes session.error when plugin import fails", async () => {
|
test("continues loading plugins when plugin import fails", async () => {
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
const missing = pathToFileURL(path.join(dir, "missing-plugin.ts")).href
|
||||||
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing] }, null, 2))
|
const ok = pathToFileURL(path.join(dir, "ok.ts")).href
|
||||||
|
const mark = path.join(dir, "ok.txt")
|
||||||
|
await Bun.write(
|
||||||
|
path.join(dir, "ok.ts"),
|
||||||
|
[
|
||||||
|
"export default {",
|
||||||
|
' id: "demo.ok",',
|
||||||
|
" server: async () => {",
|
||||||
|
` await Bun.write(${JSON.stringify(mark)}, "ok")`,
|
||||||
|
" return {}",
|
||||||
|
" },",
|
||||||
|
"}",
|
||||||
|
"",
|
||||||
|
].join("\n"),
|
||||||
|
)
|
||||||
|
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ plugin: [missing, ok] }, null, 2))
|
||||||
|
|
||||||
return { missing }
|
return { mark }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const errors = await errs(tmp.path)
|
await load(tmp.path)
|
||||||
|
expect(await Bun.file(tmp.extra.mark).text()).toBe("ok")
|
||||||
expect(errors.some((x) => x.includes(`Failed to load plugin ${tmp.extra.missing}`))).toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test("loads object plugin via plugin.server", async () => {
|
test("loads object plugin via plugin.server", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { afterEach, describe, expect, test } from "bun:test"
|
|||||||
import fs from "fs/promises"
|
import fs from "fs/promises"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
import { FileWatcher } from "../../src/file/watcher"
|
import { FileWatcher } from "../../src/file/watcher"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { GlobalBus } from "../../src/bus/global"
|
import { GlobalBus } from "../../src/bus/global"
|
||||||
@@ -19,7 +20,7 @@ async function withVcs(directory: string, body: () => Promise<void>) {
|
|||||||
return Instance.provide({
|
return Instance.provide({
|
||||||
directory,
|
directory,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
FileWatcher.init()
|
void AppRuntime.runPromise(FileWatcher.Service.use((svc) => svc.init()))
|
||||||
Vcs.init()
|
Vcs.init()
|
||||||
await Bun.sleep(500)
|
await Bun.sleep(500)
|
||||||
await body()
|
await body()
|
||||||
|
|||||||
@@ -104,6 +104,58 @@ describe("ProviderTransform.options - setCacheKey", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("ProviderTransform.options - zai/zhipuai thinking", () => {
|
||||||
|
const sessionID = "test-session-123"
|
||||||
|
|
||||||
|
const createModel = (providerID: string) =>
|
||||||
|
({
|
||||||
|
id: `${providerID}/glm-4.6`,
|
||||||
|
providerID,
|
||||||
|
api: {
|
||||||
|
id: "glm-4.6",
|
||||||
|
url: "https://open.bigmodel.cn/api/paas/v4",
|
||||||
|
npm: "@ai-sdk/openai-compatible",
|
||||||
|
},
|
||||||
|
name: "GLM 4.6",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: { text: true, audio: false, image: true, video: false, pdf: true },
|
||||||
|
output: { text: true, audio: false, image: false, video: false, pdf: false },
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0.001,
|
||||||
|
output: 0.002,
|
||||||
|
cache: { read: 0.0001, write: 0.0002 },
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128000,
|
||||||
|
output: 8192,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {},
|
||||||
|
}) as any
|
||||||
|
|
||||||
|
for (const providerID of ["zai-coding-plan", "zai", "zhipuai-coding-plan", "zhipuai"]) {
|
||||||
|
test(`${providerID} should set thinking cfg`, () => {
|
||||||
|
const result = ProviderTransform.options({
|
||||||
|
model: createModel(providerID),
|
||||||
|
sessionID,
|
||||||
|
providerOptions: {},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.thinking).toEqual({
|
||||||
|
type: "enabled",
|
||||||
|
clear_thinking: false,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
describe("ProviderTransform.options - google thinkingConfig gating", () => {
|
describe("ProviderTransform.options - google thinkingConfig gating", () => {
|
||||||
const sessionID = "test-session-123"
|
const sessionID = "test-session-123"
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,17 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { QuestionID } from "../../src/question/schema"
|
import { QuestionID } from "../../src/question/schema"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID } from "../../src/session/schema"
|
import { SessionID } from "../../src/session/schema"
|
||||||
|
import { AppRuntime } from "../../src/effect/app-runtime"
|
||||||
|
|
||||||
|
const ask = (input: { sessionID: SessionID; questions: Question.Info[]; tool?: { messageID: any; callID: string } }) =>
|
||||||
|
AppRuntime.runPromise(Question.Service.use((svc) => svc.ask(input)))
|
||||||
|
|
||||||
|
const list = () => AppRuntime.runPromise(Question.Service.use((svc) => svc.list()))
|
||||||
|
|
||||||
|
const reply = (input: { requestID: QuestionID; answers: Question.Answer[] }) =>
|
||||||
|
AppRuntime.runPromise(Question.Service.use((svc) => svc.reply(input)))
|
||||||
|
|
||||||
|
const reject = (id: QuestionID) => AppRuntime.runPromise(Question.Service.use((svc) => svc.reject(id)))
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
@@ -11,9 +22,9 @@ afterEach(async () => {
|
|||||||
|
|
||||||
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
|
/** Reject all pending questions so dangling Deferred fibers don't hang the test. */
|
||||||
async function rejectAll() {
|
async function rejectAll() {
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
for (const req of pending) {
|
for (const req of pending) {
|
||||||
await Question.reject(req.id)
|
await reject(req.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,7 +33,7 @@ test("ask - returns pending promise", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const promise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -58,16 +69,16 @@ test("ask - adds to pending list", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
expect(pending[0].questions).toEqual(questions)
|
expect(pending[0].questions).toEqual(questions)
|
||||||
await rejectAll()
|
await rejectAll()
|
||||||
await askPromise.catch(() => {})
|
await promise.catch(() => {})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -90,20 +101,20 @@ test("reply - resolves the pending ask with answers", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
const requestID = pending[0].id
|
const requestID = pending[0].id
|
||||||
|
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID,
|
requestID,
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
|
|
||||||
const answers = await askPromise
|
const answers = await promise
|
||||||
expect(answers).toEqual([["Option 1"]])
|
expect(answers).toEqual([["Option 1"]])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -114,7 +125,7 @@ test("reply - removes from pending list", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -128,17 +139,17 @@ test("reply - removes from pending list", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
|
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID: pending[0].id,
|
requestID: pending[0].id,
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
await askPromise
|
await promise
|
||||||
|
|
||||||
const pendingAfter = await Question.list()
|
const after = await list()
|
||||||
expect(pendingAfter.length).toBe(0)
|
expect(after.length).toBe(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -148,7 +159,7 @@ test("reply - does nothing for unknown requestID", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID: QuestionID.make("que_unknown"),
|
requestID: QuestionID.make("que_unknown"),
|
||||||
answers: [["Option 1"]],
|
answers: [["Option 1"]],
|
||||||
})
|
})
|
||||||
@@ -164,7 +175,7 @@ test("reject - throws RejectedError", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -178,10 +189,10 @@ test("reject - throws RejectedError", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
await Question.reject(pending[0].id)
|
await reject(pending[0].id)
|
||||||
|
|
||||||
await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
|
await expect(promise).rejects.toBeInstanceOf(Question.RejectedError)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -191,7 +202,7 @@ test("reject - removes from pending list", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -205,14 +216,14 @@ test("reject - removes from pending list", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(1)
|
expect(pending.length).toBe(1)
|
||||||
|
|
||||||
await Question.reject(pending[0].id)
|
await reject(pending[0].id)
|
||||||
askPromise.catch(() => {}) // Ignore rejection
|
promise.catch(() => {}) // Ignore rejection
|
||||||
|
|
||||||
const pendingAfter = await Question.list()
|
const after = await list()
|
||||||
expect(pendingAfter.length).toBe(0)
|
expect(after.length).toBe(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -222,7 +233,7 @@ test("reject - does nothing for unknown requestID", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
await Question.reject(QuestionID.make("que_unknown"))
|
await reject(QuestionID.make("que_unknown"))
|
||||||
// Should not throw
|
// Should not throw
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -254,19 +265,19 @@ test("ask - handles multiple questions", async () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const askPromise = Question.ask({
|
const promise = ask({
|
||||||
sessionID: SessionID.make("ses_test"),
|
sessionID: SessionID.make("ses_test"),
|
||||||
questions,
|
questions,
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
|
|
||||||
await Question.reply({
|
await reply({
|
||||||
requestID: pending[0].id,
|
requestID: pending[0].id,
|
||||||
answers: [["Build"], ["Dev"]],
|
answers: [["Build"], ["Dev"]],
|
||||||
})
|
})
|
||||||
|
|
||||||
const answers = await askPromise
|
const answers = await promise
|
||||||
expect(answers).toEqual([["Build"], ["Dev"]])
|
expect(answers).toEqual([["Build"], ["Dev"]])
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -279,7 +290,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const p1 = Question.ask({
|
const p1 = ask({
|
||||||
sessionID: SessionID.make("ses_test1"),
|
sessionID: SessionID.make("ses_test1"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -290,7 +301,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const p2 = Question.ask({
|
const p2 = ask({
|
||||||
sessionID: SessionID.make("ses_test2"),
|
sessionID: SessionID.make("ses_test2"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -301,7 +312,7 @@ test("list - returns all pending requests", async () => {
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(2)
|
expect(pending.length).toBe(2)
|
||||||
await rejectAll()
|
await rejectAll()
|
||||||
p1.catch(() => {})
|
p1.catch(() => {})
|
||||||
@@ -315,7 +326,7 @@ test("list - returns empty when no pending", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const pending = await Question.list()
|
const pending = await list()
|
||||||
expect(pending.length).toBe(0)
|
expect(pending.length).toBe(0)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -328,7 +339,7 @@ test("questions stay isolated by directory", async () => {
|
|||||||
const p1 = Instance.provide({
|
const p1 = Instance.provide({
|
||||||
directory: one.path,
|
directory: one.path,
|
||||||
fn: () =>
|
fn: () =>
|
||||||
Question.ask({
|
ask({
|
||||||
sessionID: SessionID.make("ses_one"),
|
sessionID: SessionID.make("ses_one"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -343,7 +354,7 @@ test("questions stay isolated by directory", async () => {
|
|||||||
const p2 = Instance.provide({
|
const p2 = Instance.provide({
|
||||||
directory: two.path,
|
directory: two.path,
|
||||||
fn: () =>
|
fn: () =>
|
||||||
Question.ask({
|
ask({
|
||||||
sessionID: SessionID.make("ses_two"),
|
sessionID: SessionID.make("ses_two"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -357,11 +368,11 @@ test("questions stay isolated by directory", async () => {
|
|||||||
|
|
||||||
const onePending = await Instance.provide({
|
const onePending = await Instance.provide({
|
||||||
directory: one.path,
|
directory: one.path,
|
||||||
fn: () => Question.list(),
|
fn: () => list(),
|
||||||
})
|
})
|
||||||
const twoPending = await Instance.provide({
|
const twoPending = await Instance.provide({
|
||||||
directory: two.path,
|
directory: two.path,
|
||||||
fn: () => Question.list(),
|
fn: () => list(),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(onePending.length).toBe(1)
|
expect(onePending.length).toBe(1)
|
||||||
@@ -371,11 +382,11 @@ test("questions stay isolated by directory", async () => {
|
|||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: one.path,
|
directory: one.path,
|
||||||
fn: () => Question.reject(onePending[0].id),
|
fn: () => reject(onePending[0].id),
|
||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: two.path,
|
directory: two.path,
|
||||||
fn: () => Question.reject(twoPending[0].id),
|
fn: () => reject(twoPending[0].id),
|
||||||
})
|
})
|
||||||
|
|
||||||
await p1.catch(() => {})
|
await p1.catch(() => {})
|
||||||
@@ -385,10 +396,10 @@ test("questions stay isolated by directory", async () => {
|
|||||||
test("pending question rejects on instance dispose", async () => {
|
test("pending question rejects on instance dispose", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
const ask = Instance.provide({
|
const pending = Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
return Question.ask({
|
return ask({
|
||||||
sessionID: SessionID.make("ses_dispose"),
|
sessionID: SessionID.make("ses_dispose"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -400,7 +411,7 @@ test("pending question rejects on instance dispose", async () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const result = ask.then(
|
const result = pending.then(
|
||||||
() => "resolved" as const,
|
() => "resolved" as const,
|
||||||
(err) => err,
|
(err) => err,
|
||||||
)
|
)
|
||||||
@@ -408,8 +419,8 @@ test("pending question rejects on instance dispose", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const pending = await Question.list()
|
const items = await list()
|
||||||
expect(pending).toHaveLength(1)
|
expect(items).toHaveLength(1)
|
||||||
await Instance.dispose()
|
await Instance.dispose()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -420,10 +431,10 @@ test("pending question rejects on instance dispose", async () => {
|
|||||||
test("pending question rejects on instance reload", async () => {
|
test("pending question rejects on instance reload", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
|
||||||
const ask = Instance.provide({
|
const pending = Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: () => {
|
fn: () => {
|
||||||
return Question.ask({
|
return ask({
|
||||||
sessionID: SessionID.make("ses_reload"),
|
sessionID: SessionID.make("ses_reload"),
|
||||||
questions: [
|
questions: [
|
||||||
{
|
{
|
||||||
@@ -435,7 +446,7 @@ test("pending question rejects on instance reload", async () => {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const result = ask.then(
|
const result = pending.then(
|
||||||
() => "resolved" as const,
|
() => "resolved" as const,
|
||||||
(err) => err,
|
(err) => err,
|
||||||
)
|
)
|
||||||
@@ -443,8 +454,8 @@ test("pending question rejects on instance reload", async () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const pending = await Question.list()
|
const items = await list()
|
||||||
expect(pending).toHaveLength(1)
|
expect(items).toHaveLength(1)
|
||||||
await Instance.reload({ directory: tmp.path })
|
await Instance.reload({ directory: tmp.path })
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { Server } from "../../src/server/server"
|
import { Server } from "../../src/server/server"
|
||||||
import { Session } from "../../src/session"
|
import { Session } from "../../src/session"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
|
||||||
import { MessageID, PartID, type SessionID } from "../../src/session/schema"
|
|
||||||
import { SessionPrompt } from "../../src/session/prompt"
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
import { SessionRunState } from "../../src/session/run-state"
|
|
||||||
import { Log } from "../../src/util/log"
|
import { Log } from "../../src/util/log"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
@@ -16,25 +14,6 @@ afterEach(async () => {
|
|||||||
await Instance.disposeAll()
|
await Instance.disposeAll()
|
||||||
})
|
})
|
||||||
|
|
||||||
async function user(sessionID: SessionID, text: string) {
|
|
||||||
const msg = await Session.updateMessage({
|
|
||||||
id: MessageID.ascending(),
|
|
||||||
role: "user",
|
|
||||||
sessionID,
|
|
||||||
agent: "build",
|
|
||||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
|
||||||
time: { created: Date.now() },
|
|
||||||
})
|
|
||||||
await Session.updatePart({
|
|
||||||
id: PartID.ascending(),
|
|
||||||
sessionID,
|
|
||||||
messageID: msg.id,
|
|
||||||
type: "text",
|
|
||||||
text,
|
|
||||||
})
|
|
||||||
return msg
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("session action routes", () => {
|
describe("session action routes", () => {
|
||||||
test("abort route calls SessionPrompt.cancel", async () => {
|
test("abort route calls SessionPrompt.cancel", async () => {
|
||||||
await using tmp = await tmpdir({ git: true })
|
await using tmp = await tmpdir({ git: true })
|
||||||
@@ -45,9 +24,7 @@ describe("session action routes", () => {
|
|||||||
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
|
const cancel = spyOn(SessionPrompt, "cancel").mockResolvedValue()
|
||||||
const app = Server.Default().app
|
const app = Server.Default().app
|
||||||
|
|
||||||
const res = await app.request(`/session/${session.id}/abort`, {
|
const res = await app.request(`/session/${session.id}/abort`, { method: "POST" })
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.status).toBe(200)
|
expect(res.status).toBe(200)
|
||||||
expect(await res.json()).toBe(true)
|
expect(await res.json()).toBe(true)
|
||||||
@@ -57,28 +34,4 @@ describe("session action routes", () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("delete message route returns 400 when session is busy", async () => {
|
|
||||||
await using tmp = await tmpdir({ git: true })
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const session = await Session.create({})
|
|
||||||
const msg = await user(session.id, "hello")
|
|
||||||
const busy = spyOn(SessionRunState, "assertNotBusy").mockRejectedValue(new Session.BusyError(session.id))
|
|
||||||
const remove = spyOn(Session, "removeMessage").mockResolvedValue(msg.id)
|
|
||||||
const app = Server.Default().app
|
|
||||||
|
|
||||||
const res = await app.request(`/session/${session.id}/message/${msg.id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(res.status).toBe(400)
|
|
||||||
expect(busy).toHaveBeenCalledWith(session.id)
|
|
||||||
expect(remove).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
await Session.remove(session.id)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ describe("session messages endpoint", () => {
|
|||||||
|
|
||||||
describe("session.prompt_async error handling", () => {
|
describe("session.prompt_async error handling", () => {
|
||||||
test("prompt_async route has error handler for detached prompt call", async () => {
|
test("prompt_async route has error handler for detached prompt call", async () => {
|
||||||
const src = await Bun.file(new URL("../../src/server/routes/session.ts", import.meta.url)).text()
|
const src = await Bun.file(new URL("../../src/server/instance/session.ts", import.meta.url)).text()
|
||||||
const start = src.indexOf('"/:sessionID/prompt_async"')
|
const start = src.indexOf('"/:sessionID/prompt_async"')
|
||||||
const end = src.indexOf('"/:sessionID/command"', start)
|
const end = src.indexOf('"/:sessionID/command"', start)
|
||||||
expect(start).toBeGreaterThan(-1)
|
expect(start).toBeGreaterThan(-1)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
import type { MessageV2 } from "../../src/session/message-v2"
|
import type { MessageV2 } from "../../src/session/message-v2"
|
||||||
@@ -8,6 +9,9 @@ import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
|||||||
import { Global } from "../../src/global"
|
import { Global } from "../../src/global"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const run = <A>(effect: Effect.Effect<A, any, Instruction.Service>) =>
|
||||||
|
Effect.runPromise(effect.pipe(Effect.provide(Instruction.defaultLayer)))
|
||||||
|
|
||||||
function loaded(filepath: string): MessageV2.WithParts[] {
|
function loaded(filepath: string): MessageV2.WithParts[] {
|
||||||
const sessionID = SessionID.make("session-loaded-1")
|
const sessionID = SessionID.make("session-loaded-1")
|
||||||
const messageID = MessageID.make("message-loaded-1")
|
const messageID = MessageID.make("message-loaded-1")
|
||||||
@@ -57,17 +61,22 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const system = await Instruction.systemPaths()
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const system = yield* svc.systemPaths()
|
||||||
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
expect(system.has(path.join(tmp.path, "AGENTS.md"))).toBe(true)
|
||||||
|
|
||||||
const results = await Instruction.resolve(
|
const results = yield* svc.resolve(
|
||||||
[],
|
[],
|
||||||
path.join(tmp.path, "src", "file.ts"),
|
path.join(tmp.path, "src", "file.ts"),
|
||||||
MessageID.make("message-test-1"),
|
MessageID.make("message-test-1"),
|
||||||
)
|
)
|
||||||
expect(results).toEqual([])
|
expect(results).toEqual([])
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -80,18 +89,23 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const system = await Instruction.systemPaths()
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const system = yield* svc.systemPaths()
|
||||||
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
expect(system.has(path.join(tmp.path, "subdir", "AGENTS.md"))).toBe(false)
|
||||||
|
|
||||||
const results = await Instruction.resolve(
|
const results = yield* svc.resolve(
|
||||||
[],
|
[],
|
||||||
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
path.join(tmp.path, "subdir", "nested", "file.ts"),
|
||||||
MessageID.make("message-test-2"),
|
MessageID.make("message-test-2"),
|
||||||
)
|
)
|
||||||
expect(results.length).toBe(1)
|
expect(results.length).toBe(1)
|
||||||
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
expect(results[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -104,14 +118,19 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
|
const filepath = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||||
const system = await Instruction.systemPaths()
|
const system = yield* svc.systemPaths()
|
||||||
expect(system.has(filepath)).toBe(false)
|
expect(system.has(filepath)).toBe(false)
|
||||||
|
|
||||||
const results = await Instruction.resolve([], filepath, MessageID.make("message-test-3"))
|
const results = yield* svc.resolve([], filepath, MessageID.make("message-test-3"))
|
||||||
expect(results).toEqual([])
|
expect(results).toEqual([])
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -124,17 +143,22 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||||
const id = MessageID.make("message-claim-1")
|
const id = MessageID.make("message-claim-1")
|
||||||
|
|
||||||
const first = await Instruction.resolve([], filepath, id)
|
const first = yield* svc.resolve([], filepath, id)
|
||||||
const second = await Instruction.resolve([], filepath, id)
|
const second = yield* svc.resolve([], filepath, id)
|
||||||
|
|
||||||
expect(first).toHaveLength(1)
|
expect(first).toHaveLength(1)
|
||||||
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
expect(first[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||||
expect(second).toEqual([])
|
expect(second).toEqual([])
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -147,18 +171,23 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||||
const id = MessageID.make("message-claim-2")
|
const id = MessageID.make("message-claim-2")
|
||||||
|
|
||||||
const first = await Instruction.resolve([], filepath, id)
|
const first = yield* svc.resolve([], filepath, id)
|
||||||
await Instruction.clear(id)
|
yield* svc.clear(id)
|
||||||
const second = await Instruction.resolve([], filepath, id)
|
const second = yield* svc.resolve([], filepath, id)
|
||||||
|
|
||||||
expect(first).toHaveLength(1)
|
expect(first).toHaveLength(1)
|
||||||
expect(second).toHaveLength(1)
|
expect(second).toHaveLength(1)
|
||||||
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
expect(second[0].filepath).toBe(path.join(tmp.path, "subdir", "AGENTS.md"))
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -171,15 +200,19 @@ describe("Instruction.resolve", () => {
|
|||||||
})
|
})
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
|
const agents = path.join(tmp.path, "subdir", "AGENTS.md")
|
||||||
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
const filepath = path.join(tmp.path, "subdir", "nested", "file.ts")
|
||||||
const id = MessageID.make("message-claim-3")
|
const id = MessageID.make("message-claim-3")
|
||||||
|
|
||||||
const results = await Instruction.resolve(loaded(agents), filepath, id)
|
const results = yield* svc.resolve(loaded(agents), filepath, id)
|
||||||
|
|
||||||
expect(results).toEqual([])
|
expect(results).toEqual([])
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -221,11 +254,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
|||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectTmp.path,
|
directory: projectTmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const paths = await Instruction.systemPaths()
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const paths = yield* svc.systemPaths()
|
||||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(true)
|
||||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(false)
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||||
@@ -248,11 +286,16 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
|||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectTmp.path,
|
directory: projectTmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const paths = await Instruction.systemPaths()
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const paths = yield* svc.systemPaths()
|
||||||
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
expect(paths.has(path.join(profileTmp.path, "AGENTS.md"))).toBe(false)
|
||||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||||
@@ -274,10 +317,15 @@ describe("Instruction.systemPaths OPENCODE_CONFIG_DIR", () => {
|
|||||||
try {
|
try {
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectTmp.path,
|
directory: projectTmp.path,
|
||||||
fn: async () => {
|
fn: () =>
|
||||||
const paths = await Instruction.systemPaths()
|
run(
|
||||||
|
Instruction.Service.use((svc) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const paths = yield* svc.systemPaths()
|
||||||
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
expect(paths.has(path.join(globalTmp.path, "AGENTS.md"))).toBe(true)
|
||||||
},
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
;(Global.Path as { config: string }).config = originalGlobalConfig
|
;(Global.Path as { config: string }).config = originalGlobalConfig
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import { SessionRunState } from "../../src/session/run-state"
|
|||||||
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
import { MessageID, PartID, SessionID } from "../../src/session/schema"
|
||||||
import { SessionStatus } from "../../src/session/status"
|
import { SessionStatus } from "../../src/session/status"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
import { Shell } from "../../src/shell/shell"
|
import { Shell } from "../../src/shell/shell"
|
||||||
import { Snapshot } from "../../src/snapshot"
|
import { Snapshot } from "../../src/snapshot"
|
||||||
import { ToolRegistry } from "../../src/tool/registry"
|
import { ToolRegistry } from "../../src/tool/registry"
|
||||||
@@ -193,6 +194,7 @@ function makeHttp() {
|
|||||||
Layer.provideMerge(registry),
|
Layer.provideMerge(registry),
|
||||||
Layer.provideMerge(trunc),
|
Layer.provideMerge(trunc),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
|
Layer.provide(SystemPrompt.defaultLayer),
|
||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin"
|
|||||||
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
import { Provider as ProviderSvc } from "../../src/provider/provider"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
import { Todo } from "../../src/session/todo"
|
import { Todo } from "../../src/session/todo"
|
||||||
import { SessionCompaction } from "../../src/session/compaction"
|
import { SessionCompaction } from "../../src/session/compaction"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
@@ -157,6 +158,7 @@ function makeHttp() {
|
|||||||
Layer.provideMerge(registry),
|
Layer.provideMerge(registry),
|
||||||
Layer.provideMerge(trunc),
|
Layer.provideMerge(trunc),
|
||||||
Layer.provide(Instruction.defaultLayer),
|
Layer.provide(Instruction.defaultLayer),
|
||||||
|
Layer.provide(SystemPrompt.defaultLayer),
|
||||||
Layer.provideMerge(deps),
|
Layer.provideMerge(deps),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
|
import { Effect } from "effect"
|
||||||
import { Agent } from "../../src/agent/agent"
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { SystemPrompt } from "../../src/session/system"
|
import { SystemPrompt } from "../../src/session/system"
|
||||||
@@ -38,8 +39,13 @@ description: ${description}
|
|||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const build = await Agent.get("build")
|
const build = await Agent.get("build")
|
||||||
const first = await SystemPrompt.skills(build!)
|
const runSkills = Effect.gen(function* () {
|
||||||
const second = await SystemPrompt.skills(build!)
|
const svc = yield* SystemPrompt.Service
|
||||||
|
return yield* svc.skills(build!)
|
||||||
|
}).pipe(Effect.provide(SystemPrompt.defaultLayer))
|
||||||
|
|
||||||
|
const first = await Effect.runPromise(runSkills)
|
||||||
|
const second = await Effect.runPromise(runSkills)
|
||||||
|
|
||||||
expect(first).toBe(second)
|
expect(first).toBe(second)
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { AppFileSystem } from "../../src/filesystem"
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(LSP.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
Layer.mergeAll(
|
||||||
|
LSP.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Format.defaultLayer,
|
||||||
|
Bus.layer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const baseCtx = {
|
const baseCtx = {
|
||||||
@@ -50,7 +59,7 @@ type ToolCtx = typeof baseCtx & {
|
|||||||
|
|
||||||
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
const execute = async (params: { patchText: string }, ctx: ToolCtx) => {
|
||||||
const info = await runtime.runPromise(ApplyPatchTool)
|
const info = await runtime.runPromise(ApplyPatchTool)
|
||||||
const tool = await info.init()
|
const tool = await runtime.runPromise(info.init())
|
||||||
return Effect.runPromise(tool.execute(params, ctx))
|
return Effect.runPromise(tool.execute(params, ctx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import type { Permission } from "../../src/permission"
|
import type { Permission } from "../../src/permission"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Truncate } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
@@ -15,11 +16,17 @@ import { AppFileSystem } from "../../src/filesystem"
|
|||||||
import { Plugin } from "../../src/plugin"
|
import { Plugin } from "../../src/plugin"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer, Plugin.defaultLayer),
|
Layer.mergeAll(
|
||||||
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Plugin.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function initBash() {
|
function initBash() {
|
||||||
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
|
return runtime.runPromise(BashTool.pipe(Effect.flatMap((info) => info.init())))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import { FileTime } from "../../src/file/time"
|
|||||||
import { LSP } from "../../src/lsp"
|
import { LSP } from "../../src/lsp"
|
||||||
import { AppFileSystem } from "../../src/filesystem"
|
import { AppFileSystem } from "../../src/filesystem"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { BusEvent } from "../../src/bus/bus-event"
|
import { BusEvent } from "../../src/bus/bus-event"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
@@ -34,7 +36,15 @@ async function touch(file: string, time: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(
|
const runtime = ManagedRuntime.make(
|
||||||
Layer.mergeAll(LSP.defaultLayer, FileTime.defaultLayer, AppFileSystem.defaultLayer, Format.defaultLayer, Bus.layer),
|
Layer.mergeAll(
|
||||||
|
LSP.defaultLayer,
|
||||||
|
FileTime.defaultLayer,
|
||||||
|
AppFileSystem.defaultLayer,
|
||||||
|
Format.defaultLayer,
|
||||||
|
Bus.layer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -45,7 +55,7 @@ const resolve = () =>
|
|||||||
runtime.runPromise(
|
runtime.runPromise(
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const info = yield* EditTool
|
const info = yield* EditTool
|
||||||
return yield* Effect.promise(() => info.init())
|
return yield* info.init()
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,15 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { tmpdir } from "../fixture/fixture"
|
import { tmpdir } from "../fixture/fixture"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
|
||||||
const runtime = ManagedRuntime.make(Layer.mergeAll(CrossSpawnSpawner.defaultLayer))
|
const runtime = ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
function initGrep() {
|
function initGrep() {
|
||||||
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => Effect.promise(() => info.init()))))
|
return runtime.runPromise(GrepTool.pipe(Effect.flatMap((info) => info.init())))
|
||||||
}
|
}
|
||||||
|
|
||||||
const ctx = {
|
const ctx = {
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import { Tool } from "../../src/tool/tool"
|
|||||||
import { QuestionTool } from "../../src/tool/question"
|
import { QuestionTool } from "../../src/tool/question"
|
||||||
import { Question } from "../../src/question"
|
import { Question } from "../../src/question"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
|
|
||||||
@@ -19,7 +21,9 @@ const ctx = {
|
|||||||
ask: () => Effect.void,
|
ask: () => Effect.void,
|
||||||
}
|
}
|
||||||
|
|
||||||
const it = testEffect(Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer))
|
const it = testEffect(
|
||||||
|
Layer.mergeAll(Question.defaultLayer, CrossSpawnSpawner.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
|
)
|
||||||
|
|
||||||
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
const pending = Effect.fn("QuestionToolTest.pending")(function* (question: Question.Interface) {
|
||||||
for (;;) {
|
for (;;) {
|
||||||
@@ -36,7 +40,7 @@ describe("tool.question", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const question = yield* Question.Service
|
const question = yield* Question.Service
|
||||||
const toolInfo = yield* QuestionTool
|
const toolInfo = yield* QuestionTool
|
||||||
const tool = yield* Effect.promise(() => toolInfo.init())
|
const tool = yield* toolInfo.init()
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
question: "What is your favorite color?",
|
question: "What is your favorite color?",
|
||||||
@@ -64,7 +68,7 @@ describe("tool.question", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const question = yield* Question.Service
|
const question = yield* Question.Service
|
||||||
const toolInfo = yield* QuestionTool
|
const toolInfo = yield* QuestionTool
|
||||||
const tool = yield* Effect.promise(() => toolInfo.init())
|
const tool = yield* toolInfo.init()
|
||||||
const questions = [
|
const questions = [
|
||||||
{
|
{
|
||||||
question: "What is your favorite animal?",
|
question: "What is your favorite animal?",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Instance } from "../../src/project/instance"
|
|||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import { Instruction } from "../../src/session/instruction"
|
import { Instruction } from "../../src/session/instruction"
|
||||||
import { ReadTool } from "../../src/tool/read"
|
import { ReadTool } from "../../src/tool/read"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
import { provideInstance, tmpdirScoped } from "../fixture/fixture"
|
||||||
@@ -41,12 +42,13 @@ const it = testEffect(
|
|||||||
FileTime.defaultLayer,
|
FileTime.defaultLayer,
|
||||||
Instruction.defaultLayer,
|
Instruction.defaultLayer,
|
||||||
LSP.defaultLayer,
|
LSP.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const init = Effect.fn("ReadToolTest.init")(function* () {
|
const init = Effect.fn("ReadToolTest.init")(function* () {
|
||||||
const info = yield* ReadTool
|
const info = yield* ReadTool
|
||||||
return yield* Effect.promise(() => info.init())
|
return yield* info.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = Effect.fn("ReadToolTest.run")(function* (
|
const run = Effect.fn("ReadToolTest.run")(function* (
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Effect, Layer, ManagedRuntime } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Skill } from "../../src/skill"
|
import { Skill } from "../../src/skill"
|
||||||
import { Ripgrep } from "../../src/file/ripgrep"
|
import { Ripgrep } from "../../src/file/ripgrep"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { afterEach, describe, expect, test } from "bun:test"
|
import { afterEach, describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { pathToFileURL } from "url"
|
import { pathToFileURL } from "url"
|
||||||
@@ -150,9 +152,11 @@ Use this skill.
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const runtime = ManagedRuntime.make(Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer))
|
const runtime = ManagedRuntime.make(
|
||||||
|
Layer.mergeAll(Skill.defaultLayer, Ripgrep.defaultLayer, Truncate.defaultLayer, Agent.defaultLayer),
|
||||||
|
)
|
||||||
const info = await runtime.runPromise(SkillTool)
|
const info = await runtime.runPromise(SkillTool)
|
||||||
const tool = await info.init()
|
const tool = await runtime.runPromise(info.init())
|
||||||
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
const requests: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
|
||||||
const ctx: Tool.Context = {
|
const ctx: Tool.Context = {
|
||||||
...baseCtx,
|
...baseCtx,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { SessionPrompt } from "../../src/session/prompt"
|
|||||||
import { MessageID, PartID } from "../../src/session/schema"
|
import { MessageID, PartID } from "../../src/session/schema"
|
||||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||||
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
import { TaskTool, type TaskPromptOps } from "../../src/tool/task"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { ToolRegistry } from "../../src/tool/registry"
|
import { ToolRegistry } from "../../src/tool/registry"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
import { testEffect } from "../lib/effect"
|
import { testEffect } from "../lib/effect"
|
||||||
@@ -29,6 +30,7 @@ const it = testEffect(
|
|||||||
Config.defaultLayer,
|
Config.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
Session.defaultLayer,
|
Session.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
ToolRegistry.defaultLayer,
|
ToolRegistry.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -191,7 +193,7 @@ describe("tool.task", () => {
|
|||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
const child = yield* sessions.create({ parentID: chat.id, title: "Existing child" })
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
let seen: SessionPrompt.PromptInput | undefined
|
let seen: SessionPrompt.PromptInput | undefined
|
||||||
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
const promptOps = stubOps({ text: "resumed", onPrompt: (input) => (seen = input) })
|
||||||
|
|
||||||
@@ -229,7 +231,7 @@ describe("tool.task", () => {
|
|||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
const calls: unknown[] = []
|
const calls: unknown[] = []
|
||||||
const promptOps = stubOps()
|
const promptOps = stubOps()
|
||||||
|
|
||||||
@@ -278,7 +280,7 @@ describe("tool.task", () => {
|
|||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
let seen: SessionPrompt.PromptInput | undefined
|
let seen: SessionPrompt.PromptInput | undefined
|
||||||
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
const promptOps = stubOps({ text: "created", onPrompt: (input) => (seen = input) })
|
||||||
|
|
||||||
@@ -318,7 +320,7 @@ describe("tool.task", () => {
|
|||||||
const sessions = yield* Session.Service
|
const sessions = yield* Session.Service
|
||||||
const { chat, assistant } = yield* seed()
|
const { chat, assistant } = yield* seed()
|
||||||
const tool = yield* TaskTool
|
const tool = yield* TaskTool
|
||||||
const def = yield* Effect.promise(() => tool.init())
|
const def = yield* tool.init()
|
||||||
let seen: SessionPrompt.PromptInput | undefined
|
let seen: SessionPrompt.PromptInput | undefined
|
||||||
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
const promptOps = stubOps({ onPrompt: (input) => (seen = input) })
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer, ManagedRuntime } from "effect"
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
|
|
||||||
|
const runtime = ManagedRuntime.make(Layer.mergeAll(Truncate.defaultLayer, Agent.defaultLayer))
|
||||||
|
|
||||||
const params = z.object({ input: z.string() })
|
const params = z.object({ input: z.string() })
|
||||||
|
|
||||||
@@ -21,34 +25,34 @@ describe("Tool.define", () => {
|
|||||||
const original = makeTool("test")
|
const original = makeTool("test")
|
||||||
const originalExecute = original.execute
|
const originalExecute = original.execute
|
||||||
|
|
||||||
const info = await Effect.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
const info = await runtime.runPromise(Tool.define("test-tool", Effect.succeed(original)))
|
||||||
|
|
||||||
await info.init()
|
await Effect.runPromise(info.init())
|
||||||
await info.init()
|
await Effect.runPromise(info.init())
|
||||||
await info.init()
|
await Effect.runPromise(info.init())
|
||||||
|
|
||||||
expect(original.execute).toBe(originalExecute)
|
expect(original.execute).toBe(originalExecute)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("function-defined tool returns fresh objects and is unaffected", async () => {
|
test("effect-defined tool returns fresh objects and is unaffected", async () => {
|
||||||
const info = await Effect.runPromise(
|
const info = await runtime.runPromise(
|
||||||
Tool.define(
|
Tool.define(
|
||||||
"test-fn-tool",
|
"test-fn-tool",
|
||||||
Effect.succeed(() => Promise.resolve(makeTool("test"))),
|
Effect.succeed(() => Effect.succeed(makeTool("test"))),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const first = await info.init()
|
const first = await Effect.runPromise(info.init())
|
||||||
const second = await info.init()
|
const second = await Effect.runPromise(info.init())
|
||||||
|
|
||||||
expect(first).not.toBe(second)
|
expect(first).not.toBe(second)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("object-defined tool returns distinct objects per init() call", async () => {
|
test("object-defined tool returns distinct objects per init() call", async () => {
|
||||||
const info = await Effect.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
const info = await runtime.runPromise(Tool.define("test-copy", Effect.succeed(makeTool("test"))))
|
||||||
|
|
||||||
const first = await info.init()
|
const first = await Effect.runPromise(info.init())
|
||||||
const second = await info.init()
|
const second = await Effect.runPromise(info.init())
|
||||||
|
|
||||||
expect(first).not.toBe(second)
|
expect(first).not.toBe(second)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect } from "bun:test"
|
import { describe, test, expect } from "bun:test"
|
||||||
import { NodeFileSystem } from "@effect/platform-node"
|
import { NodeFileSystem } from "@effect/platform-node"
|
||||||
import { Effect, FileSystem, Layer } from "effect"
|
import { Effect, FileSystem, Layer } from "effect"
|
||||||
import { Truncate, Truncate as TruncateSvc } from "../../src/tool/truncate"
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Identifier } from "../../src/id/id"
|
import { Identifier } from "../../src/id/id"
|
||||||
import { Process } from "../../src/util/process"
|
import { Process } from "../../src/util/process"
|
||||||
import { Filesystem } from "../../src/util/filesystem"
|
import { Filesystem } from "../../src/util/filesystem"
|
||||||
@@ -12,80 +12,105 @@ import { writeFileStringScoped } from "../lib/filesystem"
|
|||||||
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
const FIXTURES_DIR = path.join(import.meta.dir, "fixtures")
|
||||||
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
const ROOT = path.resolve(import.meta.dir, "..", "..")
|
||||||
|
|
||||||
|
const it = testEffect(Layer.mergeAll(Truncate.defaultLayer, NodeFileSystem.layer))
|
||||||
|
|
||||||
describe("Truncate", () => {
|
describe("Truncate", () => {
|
||||||
describe("output", () => {
|
describe("output", () => {
|
||||||
test("truncates large json file by bytes", async () => {
|
it.live("truncates large json file by bytes", () =>
|
||||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("truncated...")
|
expect(result.content).toContain("truncated...")
|
||||||
if (result.truncated) expect(result.outputPath).toBeDefined()
|
if (result.truncated) expect(result.outputPath).toBeDefined()
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("returns content unchanged when under limits", async () => {
|
it.live("returns content unchanged when under limits", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const content = "line1\nline2\nline3"
|
const content = "line1\nline2\nline3"
|
||||||
const result = await Truncate.output(content)
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(false)
|
expect(result.truncated).toBe(false)
|
||||||
expect(result.content).toBe(content)
|
expect(result.content).toBe(content)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates by line count", async () => {
|
it.live("truncates by line count", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
const result = yield* svc.output(lines, { maxLines: 10 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("...90 lines truncated...")
|
expect(result.content).toContain("...90 lines truncated...")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates by byte count", async () => {
|
it.live("truncates by byte count", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const content = "a".repeat(1000)
|
const content = "a".repeat(1000)
|
||||||
const result = await Truncate.output(content, { maxBytes: 100 })
|
const result = yield* svc.output(content, { maxBytes: 100 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("truncated...")
|
expect(result.content).toContain("truncated...")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates from head by default", async () => {
|
it.live("truncates from head by default", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||||
const result = await Truncate.output(lines, { maxLines: 3 })
|
const result = yield* svc.output(lines, { maxLines: 3 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("line0")
|
expect(result.content).toContain("line0")
|
||||||
expect(result.content).toContain("line1")
|
expect(result.content).toContain("line1")
|
||||||
expect(result.content).toContain("line2")
|
expect(result.content).toContain("line2")
|
||||||
expect(result.content).not.toContain("line9")
|
expect(result.content).not.toContain("line9")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("truncates from tail when direction is tail", async () => {
|
it.live("truncates from tail when direction is tail", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
const lines = Array.from({ length: 10 }, (_, i) => `line${i}`).join("\n")
|
||||||
const result = await Truncate.output(lines, { maxLines: 3, direction: "tail" })
|
const result = yield* svc.output(lines, { maxLines: 3, direction: "tail" })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("line7")
|
expect(result.content).toContain("line7")
|
||||||
expect(result.content).toContain("line8")
|
expect(result.content).toContain("line8")
|
||||||
expect(result.content).toContain("line9")
|
expect(result.content).toContain("line9")
|
||||||
expect(result.content).not.toContain("line0")
|
expect(result.content).not.toContain("line0")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("uses default MAX_LINES and MAX_BYTES", () => {
|
test("uses default MAX_LINES and MAX_BYTES", () => {
|
||||||
expect(Truncate.MAX_LINES).toBe(2000)
|
expect(Truncate.MAX_LINES).toBe(2000)
|
||||||
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
expect(Truncate.MAX_BYTES).toBe(50 * 1024)
|
||||||
})
|
})
|
||||||
|
|
||||||
test("large single-line file truncates with byte message", async () => {
|
it.live("large single-line file truncates with byte message", () =>
|
||||||
const content = await Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json"))
|
Effect.gen(function* () {
|
||||||
const result = await Truncate.output(content)
|
const svc = yield* Truncate.Service
|
||||||
|
const content = yield* Effect.promise(() => Filesystem.readText(path.join(FIXTURES_DIR, "models-api.json")))
|
||||||
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("bytes truncated...")
|
expect(result.content).toContain("bytes truncated...")
|
||||||
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
expect(Buffer.byteLength(content, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("writes full output to file when truncated", async () => {
|
it.live("writes full output to file when truncated", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 })
|
const result = yield* svc.output(lines, { maxLines: 10 })
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("The tool call succeeded but the output was truncated")
|
expect(result.content).toContain("The tool call succeeded but the output was truncated")
|
||||||
@@ -94,38 +119,48 @@ describe("Truncate", () => {
|
|||||||
expect(result.outputPath).toBeDefined()
|
expect(result.outputPath).toBeDefined()
|
||||||
expect(result.outputPath).toContain("tool_")
|
expect(result.outputPath).toContain("tool_")
|
||||||
|
|
||||||
const written = await Filesystem.readText(result.outputPath!)
|
const written = yield* Effect.promise(() => Filesystem.readText(result.outputPath!))
|
||||||
expect(written).toBe(lines)
|
expect(written).toBe(lines)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("suggests Task tool when agent has task permission", async () => {
|
it.live("suggests Task tool when agent has task permission", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
|
const agent = { permission: [{ permission: "task", pattern: "*", action: "allow" as const }] }
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
expect(result.content).toContain("Task tool")
|
expect(result.content).toContain("Task tool")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("omits Task tool hint when agent lacks task permission", async () => {
|
it.live("omits Task tool hint when agent lacks task permission", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
const lines = Array.from({ length: 100 }, (_, i) => `line${i}`).join("\n")
|
||||||
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
|
const agent = { permission: [{ permission: "task", pattern: "*", action: "deny" as const }] }
|
||||||
const result = await Truncate.output(lines, { maxLines: 10 }, agent as any)
|
const result = yield* svc.output(lines, { maxLines: 10 }, agent as any)
|
||||||
|
|
||||||
expect(result.truncated).toBe(true)
|
expect(result.truncated).toBe(true)
|
||||||
expect(result.content).toContain("Grep")
|
expect(result.content).toContain("Grep")
|
||||||
expect(result.content).not.toContain("Task tool")
|
expect(result.content).not.toContain("Task tool")
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("does not write file when not truncated", async () => {
|
it.live("does not write file when not truncated", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const content = "short content"
|
const content = "short content"
|
||||||
const result = await Truncate.output(content)
|
const result = yield* svc.output(content)
|
||||||
|
|
||||||
expect(result.truncated).toBe(false)
|
expect(result.truncated).toBe(false)
|
||||||
if (result.truncated) throw new Error("expected not truncated")
|
if (result.truncated) throw new Error("expected not truncated")
|
||||||
expect("outputPath" in result).toBe(false)
|
expect("outputPath" in result).toBe(false)
|
||||||
})
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
test("loads truncate effect in a fresh process", async () => {
|
test("loads truncate effect in a fresh process", async () => {
|
||||||
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
|
const out = await Process.run([process.execPath, "run", path.join(ROOT, "src", "tool", "truncate.ts")], {
|
||||||
@@ -138,10 +173,10 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
describe("cleanup", () => {
|
describe("cleanup", () => {
|
||||||
const DAY_MS = 24 * 60 * 60 * 1000
|
const DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const it = testEffect(Layer.mergeAll(TruncateSvc.defaultLayer, NodeFileSystem.layer))
|
|
||||||
|
|
||||||
it.live("deletes files older than 7 days and preserves recent files", () =>
|
it.live("deletes files older than 7 days and preserves recent files", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
|
const svc = yield* Truncate.Service
|
||||||
const fs = yield* FileSystem.FileSystem
|
const fs = yield* FileSystem.FileSystem
|
||||||
|
|
||||||
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
yield* fs.makeDirectory(Truncate.DIR, { recursive: true })
|
||||||
@@ -151,7 +186,7 @@ describe("Truncate", () => {
|
|||||||
|
|
||||||
yield* writeFileStringScoped(old, "old content")
|
yield* writeFileStringScoped(old, "old content")
|
||||||
yield* writeFileStringScoped(recent, "recent content")
|
yield* writeFileStringScoped(recent, "recent content")
|
||||||
yield* TruncateSvc.Service.use((s) => s.cleanup())
|
yield* svc.cleanup()
|
||||||
|
|
||||||
expect(yield* fs.exists(old)).toBe(false)
|
expect(yield* fs.exists(old)).toBe(false)
|
||||||
expect(yield* fs.exists(recent)).toBe(true)
|
expect(yield* fs.exists(recent)).toBe(true)
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import path from "path"
|
import path from "path"
|
||||||
import { Effect } from "effect"
|
import { Effect, Layer } from "effect"
|
||||||
import { FetchHttpClient } from "effect/unstable/http"
|
import { FetchHttpClient } from "effect/unstable/http"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Instance } from "../../src/project/instance"
|
import { Instance } from "../../src/project/instance"
|
||||||
import { WebFetchTool } from "../../src/tool/webfetch"
|
import { WebFetchTool } from "../../src/tool/webfetch"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
@@ -24,10 +26,11 @@ async function withFetch(fetch: (req: Request) => Response | Promise<Response>,
|
|||||||
await fn(server.url)
|
await fn(server.url)
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTool() {
|
function exec(args: { url: string; format: "text" | "markdown" | "html" }) {
|
||||||
return WebFetchTool.pipe(
|
return WebFetchTool.pipe(
|
||||||
Effect.flatMap((info) => Effect.promise(() => info.init())),
|
Effect.flatMap((info) => info.init()),
|
||||||
Effect.provide(FetchHttpClient.layer),
|
Effect.flatMap((tool) => tool.execute(args, ctx)),
|
||||||
|
Effect.provide(Layer.mergeAll(FetchHttpClient.layer, Truncate.defaultLayer, Agent.defaultLayer)),
|
||||||
Effect.runPromise,
|
Effect.runPromise,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -41,10 +44,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/image.png", url).toString(), format: "markdown" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/image.png", url).toString(), format: "markdown" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toBe("Image fetched successfully")
|
expect(result.output).toBe("Image fetched successfully")
|
||||||
expect(result.attachments).toBeDefined()
|
expect(result.attachments).toBeDefined()
|
||||||
expect(result.attachments?.length).toBe(1)
|
expect(result.attachments?.length).toBe(1)
|
||||||
@@ -72,10 +72,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/image.svg", url).toString(), format: "html" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/image.svg", url).toString(), format: "html" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toContain("<svg")
|
expect(result.output).toContain("<svg")
|
||||||
expect(result.attachments).toBeUndefined()
|
expect(result.attachments).toBeUndefined()
|
||||||
},
|
},
|
||||||
@@ -95,10 +92,7 @@ describe("tool.webfetch", () => {
|
|||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: projectRoot,
|
directory: projectRoot,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const webfetch = await initTool()
|
const result = await exec({ url: new URL("/file.txt", url).toString(), format: "text" })
|
||||||
const result = await Effect.runPromise(
|
|
||||||
webfetch.execute({ url: new URL("/file.txt", url).toString(), format: "text" }, ctx),
|
|
||||||
)
|
|
||||||
expect(result.output).toBe("hello from webfetch")
|
expect(result.output).toBe("hello from webfetch")
|
||||||
expect(result.attachments).toBeUndefined()
|
expect(result.attachments).toBeUndefined()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import { AppFileSystem } from "../../src/filesystem"
|
|||||||
import { FileTime } from "../../src/file/time"
|
import { FileTime } from "../../src/file/time"
|
||||||
import { Bus } from "../../src/bus"
|
import { Bus } from "../../src/bus"
|
||||||
import { Format } from "../../src/format"
|
import { Format } from "../../src/format"
|
||||||
|
import { Truncate } from "../../src/tool/truncate"
|
||||||
import { Tool } from "../../src/tool/tool"
|
import { Tool } from "../../src/tool/tool"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
import { SessionID, MessageID } from "../../src/session/schema"
|
import { SessionID, MessageID } from "../../src/session/schema"
|
||||||
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
|
||||||
import { provideTmpdirInstance } from "../fixture/fixture"
|
import { provideTmpdirInstance } from "../fixture/fixture"
|
||||||
@@ -38,12 +40,14 @@ const it = testEffect(
|
|||||||
Bus.layer,
|
Bus.layer,
|
||||||
Format.defaultLayer,
|
Format.defaultLayer,
|
||||||
CrossSpawnSpawner.defaultLayer,
|
CrossSpawnSpawner.defaultLayer,
|
||||||
|
Truncate.defaultLayer,
|
||||||
|
Agent.defaultLayer,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const init = Effect.fn("WriteToolTest.init")(function* () {
|
const init = Effect.fn("WriteToolTest.init")(function* () {
|
||||||
const info = yield* WriteTool
|
const info = yield* WriteTool
|
||||||
return yield* Effect.promise(() => info.init())
|
return yield* info.init()
|
||||||
})
|
})
|
||||||
|
|
||||||
const run = Effect.fn("WriteToolTest.run")(function* (
|
const run = Effect.fn("WriteToolTest.run")(function* (
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { afterEach, expect, test } from "bun:test"
|
||||||
|
import fs from "fs/promises"
|
||||||
|
import path from "path"
|
||||||
|
import { Global } from "../../src/global"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
const log = Global.Path.log
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
Global.Path.log = log
|
||||||
|
})
|
||||||
|
|
||||||
|
async function files(dir: string) {
|
||||||
|
let last = ""
|
||||||
|
let same = 0
|
||||||
|
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const list = (await fs.readdir(dir)).sort()
|
||||||
|
const next = JSON.stringify(list)
|
||||||
|
same = next === last ? same + 1 : 0
|
||||||
|
if (same >= 2 && list.length === 11) return list
|
||||||
|
last = next
|
||||||
|
await Bun.sleep(10)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await fs.readdir(dir)).sort()
|
||||||
|
}
|
||||||
|
|
||||||
|
test("init cleanup keeps the newest timestamped logs", async () => {
|
||||||
|
await using tmp = await tmpdir()
|
||||||
|
Global.Path.log = tmp.path
|
||||||
|
|
||||||
|
const list = Array.from({ length: 12 }, (_, i) => `2000-01-${String(i + 1).padStart(2, "0")}T000000.log`)
|
||||||
|
|
||||||
|
await Promise.all(list.map((file) => fs.writeFile(path.join(tmp.path, file), file)))
|
||||||
|
|
||||||
|
await Log.init({ print: false, dev: false })
|
||||||
|
|
||||||
|
const next = await files(tmp.path)
|
||||||
|
|
||||||
|
expect(next).not.toContain(list[0]!)
|
||||||
|
expect(next).toContain(list.at(-1)!)
|
||||||
|
})
|
||||||
@@ -1725,6 +1725,7 @@ export class Session2 extends HeyApiClient {
|
|||||||
directory?: string
|
directory?: string
|
||||||
workspace?: string
|
workspace?: string
|
||||||
title?: string
|
title?: string
|
||||||
|
permission?: PermissionRuleset
|
||||||
time?: {
|
time?: {
|
||||||
archived?: number
|
archived?: number
|
||||||
}
|
}
|
||||||
@@ -1740,6 +1741,7 @@ export class Session2 extends HeyApiClient {
|
|||||||
{ in: "query", key: "directory" },
|
{ in: "query", key: "directory" },
|
||||||
{ in: "query", key: "workspace" },
|
{ in: "query", key: "workspace" },
|
||||||
{ in: "body", key: "title" },
|
{ in: "body", key: "title" },
|
||||||
|
{ in: "body", key: "permission" },
|
||||||
{ in: "body", key: "time" },
|
{ in: "body", key: "time" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -316,29 +316,6 @@ export type EventCommandExecuted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EventWorkspaceReady = {
|
|
||||||
type: "workspace.ready"
|
|
||||||
properties: {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventWorkspaceFailed = {
|
|
||||||
type: "workspace.failed"
|
|
||||||
properties: {
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventWorkspaceStatus = {
|
|
||||||
type: "workspace.status"
|
|
||||||
properties: {
|
|
||||||
workspaceID: string
|
|
||||||
status: "connected" | "connecting" | "disconnected" | "error"
|
|
||||||
error?: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type QuestionOption = {
|
export type QuestionOption = {
|
||||||
/**
|
/**
|
||||||
* Display text (1-5 words, concise)
|
* Display text (1-5 words, concise)
|
||||||
@@ -410,6 +387,29 @@ export type EventQuestionRejected = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Todo = {
|
||||||
|
/**
|
||||||
|
* Brief description of the task
|
||||||
|
*/
|
||||||
|
content: string
|
||||||
|
/**
|
||||||
|
* Current status of the task: pending, in_progress, completed, cancelled
|
||||||
|
*/
|
||||||
|
status: string
|
||||||
|
/**
|
||||||
|
* Priority level of the task: high, medium, low
|
||||||
|
*/
|
||||||
|
priority: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventTodoUpdated = {
|
||||||
|
type: "todo.updated"
|
||||||
|
properties: {
|
||||||
|
sessionID: string
|
||||||
|
todos: Array<Todo>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionStatus =
|
export type SessionStatus =
|
||||||
| {
|
| {
|
||||||
type: "idle"
|
type: "idle"
|
||||||
@@ -446,29 +446,6 @@ export type EventSessionCompacted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Todo = {
|
|
||||||
/**
|
|
||||||
* Brief description of the task
|
|
||||||
*/
|
|
||||||
content: string
|
|
||||||
/**
|
|
||||||
* Current status of the task: pending, in_progress, completed, cancelled
|
|
||||||
*/
|
|
||||||
status: string
|
|
||||||
/**
|
|
||||||
* Priority level of the task: high, medium, low
|
|
||||||
*/
|
|
||||||
priority: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventTodoUpdated = {
|
|
||||||
type: "todo.updated"
|
|
||||||
properties: {
|
|
||||||
sessionID: string
|
|
||||||
todos: Array<Todo>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type EventWorktreeReady = {
|
export type EventWorktreeReady = {
|
||||||
type: "worktree.ready"
|
type: "worktree.ready"
|
||||||
properties: {
|
properties: {
|
||||||
@@ -523,6 +500,29 @@ export type EventPtyDeleted = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type EventWorkspaceReady = {
|
||||||
|
type: "workspace.ready"
|
||||||
|
properties: {
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventWorkspaceFailed = {
|
||||||
|
type: "workspace.failed"
|
||||||
|
properties: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EventWorkspaceStatus = {
|
||||||
|
type: "workspace.status"
|
||||||
|
properties: {
|
||||||
|
workspaceID: string
|
||||||
|
status: "connected" | "connecting" | "disconnected" | "error"
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export type OutputFormatText = {
|
export type OutputFormatText = {
|
||||||
type: "text"
|
type: "text"
|
||||||
}
|
}
|
||||||
@@ -995,22 +995,22 @@ export type Event =
|
|||||||
| EventMcpToolsChanged
|
| EventMcpToolsChanged
|
||||||
| EventMcpBrowserOpenFailed
|
| EventMcpBrowserOpenFailed
|
||||||
| EventCommandExecuted
|
| EventCommandExecuted
|
||||||
| EventWorkspaceReady
|
|
||||||
| EventWorkspaceFailed
|
|
||||||
| EventWorkspaceStatus
|
|
||||||
| EventQuestionAsked
|
| EventQuestionAsked
|
||||||
| EventQuestionReplied
|
| EventQuestionReplied
|
||||||
| EventQuestionRejected
|
| EventQuestionRejected
|
||||||
|
| EventTodoUpdated
|
||||||
| EventSessionStatus
|
| EventSessionStatus
|
||||||
| EventSessionIdle
|
| EventSessionIdle
|
||||||
| EventSessionCompacted
|
| EventSessionCompacted
|
||||||
| EventTodoUpdated
|
|
||||||
| EventWorktreeReady
|
| EventWorktreeReady
|
||||||
| EventWorktreeFailed
|
| EventWorktreeFailed
|
||||||
| EventPtyCreated
|
| EventPtyCreated
|
||||||
| EventPtyUpdated
|
| EventPtyUpdated
|
||||||
| EventPtyExited
|
| EventPtyExited
|
||||||
| EventPtyDeleted
|
| EventPtyDeleted
|
||||||
|
| EventWorkspaceReady
|
||||||
|
| EventWorkspaceFailed
|
||||||
|
| EventWorkspaceStatus
|
||||||
| EventMessageUpdated
|
| EventMessageUpdated
|
||||||
| EventMessageRemoved
|
| EventMessageRemoved
|
||||||
| EventMessagePartUpdated
|
| EventMessagePartUpdated
|
||||||
@@ -3266,6 +3266,7 @@ export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]
|
|||||||
export type SessionUpdateData = {
|
export type SessionUpdateData = {
|
||||||
body?: {
|
body?: {
|
||||||
title?: string
|
title?: string
|
||||||
|
permission?: PermissionRuleset
|
||||||
time?: {
|
time?: {
|
||||||
archived?: number
|
archived?: number
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-121
@@ -2550,6 +2550,9 @@
|
|||||||
"title": {
|
"title": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"permission": {
|
||||||
|
"$ref": "#/components/schemas/PermissionRuleset"
|
||||||
|
},
|
||||||
"time": {
|
"time": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -7986,71 +7989,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
"Event.workspace.ready": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "workspace.ready"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"name": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["name"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.workspace.failed": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "workspace.failed"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["message"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.workspace.status": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "workspace.status"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"workspaceID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^wrk.*"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"type": "string",
|
|
||||||
"enum": ["connected", "connecting", "disconnected", "error"]
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["workspaceID", "status"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"QuestionOption": {
|
"QuestionOption": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -8201,6 +8139,50 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
|
"Todo": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"content": {
|
||||||
|
"description": "Brief description of the task",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"priority": {
|
||||||
|
"description": "Priority level of the task: high, medium, low",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["content", "status", "priority"]
|
||||||
|
},
|
||||||
|
"Event.todo.updated": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "todo.updated"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sessionID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^ses.*"
|
||||||
|
},
|
||||||
|
"todos": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/Todo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["sessionID", "todos"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
"SessionStatus": {
|
"SessionStatus": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -8307,50 +8289,6 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
"Todo": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"content": {
|
|
||||||
"description": "Brief description of the task",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"status": {
|
|
||||||
"description": "Current status of the task: pending, in_progress, completed, cancelled",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"priority": {
|
|
||||||
"description": "Priority level of the task: high, medium, low",
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["content", "status", "priority"]
|
|
||||||
},
|
|
||||||
"Event.todo.updated": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"type": {
|
|
||||||
"type": "string",
|
|
||||||
"const": "todo.updated"
|
|
||||||
},
|
|
||||||
"properties": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"sessionID": {
|
|
||||||
"type": "string",
|
|
||||||
"pattern": "^ses.*"
|
|
||||||
},
|
|
||||||
"todos": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"$ref": "#/components/schemas/Todo"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["sessionID", "todos"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["type", "properties"]
|
|
||||||
},
|
|
||||||
"Event.worktree.ready": {
|
"Event.worktree.ready": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -8505,6 +8443,71 @@
|
|||||||
},
|
},
|
||||||
"required": ["type", "properties"]
|
"required": ["type", "properties"]
|
||||||
},
|
},
|
||||||
|
"Event.workspace.ready": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "workspace.ready"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["name"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
|
"Event.workspace.failed": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "workspace.failed"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
|
"Event.workspace.status": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"const": "workspace.status"
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"workspaceID": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^wrk.*"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["connected", "connecting", "disconnected", "error"]
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["workspaceID", "status"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["type", "properties"]
|
||||||
|
},
|
||||||
"OutputFormatText": {
|
"OutputFormatText": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -9937,15 +9940,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.command.executed"
|
"$ref": "#/components/schemas/Event.command.executed"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.workspace.ready"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.workspace.failed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.workspace.status"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.question.asked"
|
"$ref": "#/components/schemas/Event.question.asked"
|
||||||
},
|
},
|
||||||
@@ -9955,6 +9949,9 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.question.rejected"
|
"$ref": "#/components/schemas/Event.question.rejected"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.todo.updated"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.session.status"
|
"$ref": "#/components/schemas/Event.session.status"
|
||||||
},
|
},
|
||||||
@@ -9964,9 +9961,6 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.session.compacted"
|
"$ref": "#/components/schemas/Event.session.compacted"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Event.todo.updated"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.worktree.ready"
|
"$ref": "#/components/schemas/Event.worktree.ready"
|
||||||
},
|
},
|
||||||
@@ -9985,6 +9979,15 @@
|
|||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.pty.deleted"
|
"$ref": "#/components/schemas/Event.pty.deleted"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.workspace.ready"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.workspace.failed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/Event.workspace.status"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/Event.message.updated"
|
"$ref": "#/components/schemas/Event.message.updated"
|
||||||
},
|
},
|
||||||
|
|||||||
Executable
+120
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# opencode Korean IME Fix Installer
|
||||||
|
# https://github.com/anomalyco/opencode/issues/14371
|
||||||
|
#
|
||||||
|
# Patches opencode to prevent Korean (and other CJK) IME last character
|
||||||
|
# truncation when pressing Enter in Kitty and other terminals.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# curl -fsSL https://raw.githubusercontent.com/claudianus/opencode/fix-zhipuai-coding-plan-thinking/patches/install-korean-ime-fix.sh | bash
|
||||||
|
# # or from a cloned repo:
|
||||||
|
# ./patches/install-korean-ime-fix.sh
|
||||||
|
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
ORANGE='\033[38;5;214m'
|
||||||
|
MUTED='\033[0;2m'
|
||||||
|
NC='\033[0m'
|
||||||
|
|
||||||
|
OPENCODE_DIR="${OPENCODE_DIR:-$HOME/.opencode}"
|
||||||
|
OPENCODE_SRC="${OPENCODE_SRC:-$HOME/.opencode-src}"
|
||||||
|
FORK_REPO="${FORK_REPO:-https://github.com/claudianus/opencode.git}"
|
||||||
|
FORK_BRANCH="${FORK_BRANCH:-fix-zhipuai-coding-plan-thinking}"
|
||||||
|
|
||||||
|
info() { echo -e "${MUTED}$*${NC}"; }
|
||||||
|
warn() { echo -e "${ORANGE}$*${NC}"; }
|
||||||
|
err() { echo -e "${RED}$*${NC}" >&2; }
|
||||||
|
ok() { echo -e "${GREEN}$*${NC}"; }
|
||||||
|
|
||||||
|
need() {
|
||||||
|
if ! command -v "$1" >/dev/null 2>&1; then
|
||||||
|
err "Error: $1 is required but not installed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
need git
|
||||||
|
need bun
|
||||||
|
|
||||||
|
# ── 1. Clone or update fork ────────────────────────────────────────────
|
||||||
|
if [ -d "$OPENCODE_SRC/.git" ]; then
|
||||||
|
info "Updating existing source at $OPENCODE_SRC ..."
|
||||||
|
git -C "$OPENCODE_SRC" fetch origin "$FORK_BRANCH"
|
||||||
|
git -C "$OPENCODE_SRC" checkout "$FORK_BRANCH"
|
||||||
|
git -C "$OPENCODE_SRC" reset --hard "origin/$FORK_BRANCH"
|
||||||
|
else
|
||||||
|
info "Cloning fork (shallow) to $OPENCODE_SRC ..."
|
||||||
|
git clone --depth 1 --branch "$FORK_BRANCH" "$FORK_REPO" "$OPENCODE_SRC"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Verify the IME fix is present in source ────────────────────────
|
||||||
|
PROMPT_FILE="$OPENCODE_SRC/packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx"
|
||||||
|
if [ ! -f "$PROMPT_FILE" ]; then
|
||||||
|
err "Prompt file not found: $PROMPT_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then
|
||||||
|
ok "IME fix already present in source."
|
||||||
|
else
|
||||||
|
warn "IME fix not found. Applying patch ..."
|
||||||
|
# Apply the fix: replace onSubmit={submit} with double-deferred version
|
||||||
|
sed -i 's|onSubmit={submit}|onSubmit={() => {\n // IME: double-defer so the last composed character (e.g. Korean\n // hangul) is flushed to plainText before we read it for submission.\n setTimeout(() => setTimeout(() => submit(), 0), 0)\n }}|' "$PROMPT_FILE"
|
||||||
|
if grep -q "setTimeout(() => setTimeout" "$PROMPT_FILE"; then
|
||||||
|
ok "Patch applied."
|
||||||
|
else
|
||||||
|
err "Failed to apply patch. The source may have changed."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 3. Install dependencies ────────────────────────────────────────────
|
||||||
|
info "Installing dependencies (this may take a minute) ..."
|
||||||
|
cd "$OPENCODE_SRC"
|
||||||
|
bun install --frozen-lockfile 2>/dev/null || bun install
|
||||||
|
|
||||||
|
# ── 4. Build (current platform only) ──────────────────────────────────
|
||||||
|
info "Building opencode for current platform ..."
|
||||||
|
cd "$OPENCODE_SRC/packages/opencode"
|
||||||
|
bun run build --single
|
||||||
|
|
||||||
|
# ── 5. Install binary ──────────────────────────────────────────────────
|
||||||
|
mkdir -p "$OPENCODE_DIR/bin"
|
||||||
|
|
||||||
|
PLATFORM=$(uname -s | tr '[:upper:]' '[:lower:]')
|
||||||
|
ARCH=$(uname -m)
|
||||||
|
[ "$ARCH" = "aarch64" ] && ARCH="arm64"
|
||||||
|
[ "$ARCH" = "x86_64" ] && ARCH="x64"
|
||||||
|
[ "$PLATFORM" = "darwin" ] && true
|
||||||
|
[ "$PLATFORM" = "linux" ] && true
|
||||||
|
|
||||||
|
BUILT_BINARY="$OPENCODE_SRC/packages/opencode/dist/opencode-${PLATFORM}-${ARCH}/bin/opencode"
|
||||||
|
|
||||||
|
if [ ! -f "$BUILT_BINARY" ]; then
|
||||||
|
BUILT_BINARY=$(find "$OPENCODE_SRC/packages/opencode/dist" -name "opencode" -type f -executable 2>/dev/null | head -1)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$BUILT_BINARY" ]; then
|
||||||
|
if [ -f "$OPENCODE_DIR/bin/opencode" ]; then
|
||||||
|
cp "$OPENCODE_DIR/bin/opencode" "$OPENCODE_DIR/bin/opencode.bak.$(date +%Y%m%d%H%M%S)"
|
||||||
|
fi
|
||||||
|
cp "$BUILT_BINARY" "$OPENCODE_DIR/bin/opencode"
|
||||||
|
chmod +x "$OPENCODE_DIR/bin/opencode"
|
||||||
|
ok "Installed to $OPENCODE_DIR/bin/opencode"
|
||||||
|
else
|
||||||
|
err "Build failed - binary not found in dist/"
|
||||||
|
info "Try running manually:"
|
||||||
|
echo " cd $OPENCODE_SRC/packages/opencode && bun run build --single"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "Done! Korean IME fix is now active."
|
||||||
|
echo ""
|
||||||
|
info "To uninstall and revert to the official release:"
|
||||||
|
echo " curl -fsSL https://opencode.ai/install | bash"
|
||||||
|
echo ""
|
||||||
|
info "To update (re-pull and rebuild):"
|
||||||
|
echo " $0"
|
||||||
Reference in New Issue
Block a user