Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea27e2457e | ||
|
|
f00a681fc5 | ||
|
|
9d2e2af865 | ||
|
|
47560358de | ||
|
|
84eff93bfa | ||
|
|
e2fd4413c8 | ||
|
|
ed95ba73f4 | ||
|
|
8890121da0 | ||
|
|
2fc0793f1f | ||
|
|
22f79f05cf | ||
|
|
4655dc84c7 |
@@ -199,15 +199,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
@@ -409,15 +409,15 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
@@ -810,7 +810,7 @@
|
||||
|
||||
"@ai-sdk/alibaba": ["@ai-sdk/alibaba@1.0.17", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZbE+U5bWz2JBc5DERLowx5+TKbjGBE93LqKZAWvuEn7HOSQMraxFMZuc0ST335QZJAyfBOzh7m1mPQ+y7EaaoA=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.107", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "@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-8nT08pGPy25rleJNk56ep00UHK6kCtCmu+ZNqVVSSPDieADlIZqcaN1iRXAFBoCH0Fb9F6C2EjFDaySdsargfQ=="],
|
||||
"@ai-sdk/amazon-bedrock": ["@ai-sdk/amazon-bedrock@4.0.96", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@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-Mc4Ias2jRMD1jOB6xWtKNPdhECeuCZyIlbr9EAGfBnyBt++sS13ziZh9qv9TdyMCAZJ7xoQcpbchoRJcKwPdpA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -832,9 +832,9 @@
|
||||
|
||||
"@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.104", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.2.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA=="],
|
||||
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.75", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XAm31ftiOrzlb8NjDzT7kw0xw+4lmgFdGFn1QKM73nXFFKyN1kWLESBV75UGNfjXP8X1YJ0YydnMVqO0jaPghw=="],
|
||||
"@ai-sdk/google": ["@ai-sdk/google@3.0.63", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-RfOZWVMYSPu2sPRfGajrauWAZ9BSaRopSn+AszkKWQ1MFj8nhaXvCqRHB5pBQUaHTfZKagvOmMpNfa/s3gPLgQ=="],
|
||||
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.131", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.78", "@ai-sdk/google": "3.0.75", "@ai-sdk/openai-compatible": "2.0.47", "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Oj1X8p0rVgvEoR5OOSxWi6XgzJ3QDlE/n30MZVtpKkCiToYYDyvlvVDGXz3IqhMyUev2JhlcuUk1brScKT01kA=="],
|
||||
"@ai-sdk/google-vertex": ["@ai-sdk/google-vertex@4.0.112", "", { "dependencies": { "@ai-sdk/anthropic": "3.0.71", "@ai-sdk/google": "3.0.64", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "google-auth-library": "^10.5.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cSfHCkM+9ZrFtQWIN1WlV93JPD+isGSdFxKj7u1L9m2aLVZajlXdcE41GL9hMt7ld7bZYE4NnZ+4VLxBAHE+Eg=="],
|
||||
|
||||
"@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="],
|
||||
|
||||
@@ -5090,13 +5090,9 @@
|
||||
|
||||
"@ai-sdk/alibaba/@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=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.14", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw=="],
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec": ["@smithy/eventstream-codec@4.2.13", "", { "dependencies": { "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.0", "@smithy/util-hex-encoding": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYahwBAtRaAcFbOmE9aLr12z7RiHYDSLcnogSdxfm7kKfsNa3wH+NU5r7vTeB5rKvLsWyPjVX8iH94brP7umiQ=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -5112,17 +5108,11 @@
|
||||
|
||||
"@ai-sdk/fireworks/@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=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.71", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bUWOzrzR0gJKJO/PLGMR4uH2dqEgqGhrsCV+sSpk4KtOEnUQlfjZI/F7BFlqSvVpFbjdgYRRLysAeEZpJ6S1lg=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
"@ai-sdk/google-vertex/@ai-sdk/google": ["@ai-sdk/google@3.0.64", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CbR82EgGPNrj/6q0HtclwuCqe0/pDShyv3nWDP/A9DroujzWXnLMlUJVrgPOsg4b40zQCwwVs2XSKCxvt/4QaA=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.78", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-0OY12G20cUt6iU6htpEA1491Oz++NVxZxlmWGX4B7rSbeZ5pnDmOu6YtW9BKzdZlNx5Gn23i6WMxyZFoMKNcgA=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.47", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Enm5UlL0zUCrW3792opk5h7hRWxZOZzDe6eQYVFqX9LUOGGCe1h8MZWAGim765nwzgnjlpeYOsuzZmLtRsTPlg=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="],
|
||||
"@ai-sdk/google-vertex/@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=="],
|
||||
|
||||
"@ai-sdk/groq/@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=="],
|
||||
|
||||
@@ -6016,12 +6006,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=="],
|
||||
|
||||
"@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/amazon-bedrock/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/amazon-bedrock/@smithy/eventstream-codec/@smithy/types": ["@smithy/types@4.14.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg=="],
|
||||
|
||||
"@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=="],
|
||||
@@ -6032,14 +6016,6 @@
|
||||
|
||||
"@ai-sdk/deepinfra/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/google-vertex/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/google/@ai-sdk/provider-utils/eventsource-parser": ["eventsource-parser@3.0.8", "", {}, "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ=="],
|
||||
|
||||
"@ai-sdk/groq/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
"@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-aHOQ9TMdmfbSIuKee1uflHiuAQ3+69NH7RUMv6LztVY=",
|
||||
"aarch64-linux": "sha256-DBBT/nLkLOtHlKL3ea/Q7ysTG5asnWUx0zQ90+D6cuI=",
|
||||
"aarch64-darwin": "sha256-VIiz1RRTu/qdWd63tXv5kxVTFYjqZbnbaKaR7V++7lI=",
|
||||
"x86_64-darwin": "sha256-TUtJaedvRpZcM7Fs5/P6fkI/aT+rIo3Wsp9kZiN45Rg="
|
||||
"x86_64-linux": "sha256-qq8cKNcW10NRbPx2T/Gpzby4nx2qHzj0LWDwqyPl7rY=",
|
||||
"aarch64-linux": "sha256-UguK4nObAL/ovGZQousveoXCmkcxEgWy7XSSzxVJNEY=",
|
||||
"aarch64-darwin": "sha256-NibSqZiF0XMbFhOtl2CQvFe4N/BXB5UAdt2iKv7dIcI=",
|
||||
"x86_64-darwin": "sha256-ZNeZSpYitdUjXx/sULAIoLXrM6+Vz+GXQxKpdfgXh60="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,15 +27,15 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
|
||||
@@ -57,11 +57,6 @@ const OpenAIResponsesReasoningItem = Schema.Struct({
|
||||
encrypted_content: optionalNull(Schema.String),
|
||||
})
|
||||
|
||||
const OpenAIResponsesItemReference = Schema.Struct({
|
||||
type: Schema.tag("item_reference"),
|
||||
id: Schema.String,
|
||||
})
|
||||
|
||||
// `function_call_output.output` accepts either a plain string or an ordered
|
||||
// array of content items so tools can return images in addition to text.
|
||||
// https://platform.openai.com/docs/api-reference/responses/object
|
||||
@@ -77,7 +72,6 @@ const OpenAIResponsesInputItem = Schema.Union([
|
||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
||||
OpenAIResponsesReasoningItem,
|
||||
OpenAIResponsesItemReference,
|
||||
Schema.Struct({
|
||||
type: Schema.tag("function_call"),
|
||||
call_id: Schema.String,
|
||||
@@ -92,15 +86,6 @@ const OpenAIResponsesInputItem = Schema.Union([
|
||||
])
|
||||
type OpenAIResponsesInputItem = Schema.Schema.Type<typeof OpenAIResponsesInputItem>
|
||||
|
||||
// Mutable counterpart of the schema reasoning item so `lowerMessages` can fold
|
||||
// multiple streamed summary parts into the same item before flushing.
|
||||
type OpenAIResponsesReasoningInput = {
|
||||
type: "reasoning"
|
||||
id: string
|
||||
summary: Array<{ type: "summary_text"; text: string }>
|
||||
encrypted_content?: string | null
|
||||
}
|
||||
|
||||
const OpenAIResponsesTool = Schema.Struct({
|
||||
type: Schema.tag("function"),
|
||||
name: Schema.String,
|
||||
@@ -127,7 +112,7 @@ const OpenAIResponsesCoreFields = {
|
||||
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
|
||||
store: Schema.optional(Schema.Boolean),
|
||||
prompt_cache_key: Schema.optional(Schema.String),
|
||||
include: optionalArray(OpenAIOptions.OpenAIResponseIncludable),
|
||||
include: optionalArray(Schema.Literal("reasoning.encrypted_content")),
|
||||
reasoning: Schema.optional(
|
||||
Schema.Struct({
|
||||
effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
|
||||
@@ -208,7 +193,6 @@ const OpenAIResponsesEvent = Schema.Struct({
|
||||
type: Schema.String,
|
||||
delta: Schema.optional(Schema.String),
|
||||
item_id: Schema.optional(Schema.String),
|
||||
summary_index: Schema.optional(Schema.Number),
|
||||
item: Schema.optional(OpenAIResponsesStreamItem),
|
||||
response: Schema.optional(
|
||||
Schema.StructWithRest(
|
||||
@@ -232,18 +216,6 @@ interface ParserState {
|
||||
readonly tools: ToolStream.State<string>
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
}
|
||||
|
||||
type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded"
|
||||
|
||||
interface ReasoningStreamItem {
|
||||
readonly encryptedContent: string | null | undefined
|
||||
// Keyed by OpenAI's numeric `summary_index`. JS object keys coerce to
|
||||
// strings, but typing the map as `Record<number, ...>` documents intent
|
||||
// and matches the wire field.
|
||||
readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>
|
||||
}
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
@@ -273,21 +245,22 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
||||
arguments: ProviderShared.encodeJson(part.input),
|
||||
})
|
||||
|
||||
const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
|
||||
const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => {
|
||||
const openai = part.providerMetadata?.openai
|
||||
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
|
||||
return undefined
|
||||
const encryptedContent =
|
||||
typeof openai.reasoningEncryptedContent === "string"
|
||||
? openai.reasoningEncryptedContent
|
||||
: openai.reasoningEncryptedContent === null
|
||||
? null
|
||||
: undefined
|
||||
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
|
||||
// With store:false, OpenAI only accepts previous reasoning items when the
|
||||
// encrypted state is present. Bare rs_* ids point to non-persisted items.
|
||||
if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
|
||||
return {
|
||||
type: "reasoning",
|
||||
id: openai.itemId,
|
||||
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||
encrypted_content: encryptedContent,
|
||||
encrypted_content:
|
||||
typeof openai.reasoningEncryptedContent === "string"
|
||||
? openai.reasoningEncryptedContent
|
||||
: openai.reasoningEncryptedContent === null
|
||||
? null
|
||||
: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,8 +310,6 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
|
||||
if (message.role === "assistant") {
|
||||
const content: TextPart[] = []
|
||||
const reasoningItems: Record<string, OpenAIResponsesReasoningInput> = {}
|
||||
const reasoningReferences = new Set<string>()
|
||||
const flushText = () => {
|
||||
if (content.length === 0) return
|
||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
||||
@@ -351,22 +322,8 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
}
|
||||
if (part.type === "reasoning") {
|
||||
flushText()
|
||||
const reasoning = lowerReasoning(part)
|
||||
if (!reasoning) continue
|
||||
if (store !== false && reasoning.id) {
|
||||
if (!reasoningReferences.has(reasoning.id)) input.push({ type: "item_reference", id: reasoning.id })
|
||||
reasoningReferences.add(reasoning.id)
|
||||
continue
|
||||
}
|
||||
const existing = reasoningItems[reasoning.id]
|
||||
if (existing) {
|
||||
existing.summary.push(...reasoning.summary)
|
||||
if (typeof reasoning.encrypted_content === "string")
|
||||
existing.encrypted_content = reasoning.encrypted_content
|
||||
continue
|
||||
}
|
||||
reasoningItems[reasoning.id] = reasoning
|
||||
input.push(reasoning)
|
||||
const reasoning = lowerReasoning(part, store)
|
||||
if (reasoning) input.push(reasoning)
|
||||
continue
|
||||
}
|
||||
if (part.type === "tool-call") {
|
||||
@@ -395,14 +352,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
}
|
||||
}
|
||||
|
||||
// With store:false, OpenAI only accepts previous reasoning items when the
|
||||
// complete item has encrypted state. Summary blocks for one item may carry
|
||||
// that state only on the last block, so filter after they have been joined.
|
||||
return store === false
|
||||
? input.filter(
|
||||
(item) => !("type" in item) || item.type !== "reasoning" || typeof item.encrypted_content === "string",
|
||||
)
|
||||
: input
|
||||
return input
|
||||
})
|
||||
|
||||
const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (request: LLMRequest) {
|
||||
@@ -412,14 +362,14 @@ const lowerOptions = Effect.fn("OpenAIResponses.lowerOptions")(function* (reques
|
||||
if (effort && !OpenAIOptions.isReasoningEffort(effort))
|
||||
return yield* invalid(`OpenAI Responses does not support reasoning effort ${effort}`)
|
||||
const summary = OpenAIOptions.reasoningSummary(request)
|
||||
const include = OpenAIOptions.include(request)
|
||||
const encryptedState = OpenAIOptions.encryptedReasoning(request)
|
||||
const verbosity = OpenAIOptions.textVerbosity(request)
|
||||
const instructions = OpenAIOptions.instructions(request)
|
||||
return {
|
||||
...(instructions ? { instructions } : {}),
|
||||
...(store !== undefined ? { store } : {}),
|
||||
...(promptCacheKey ? { prompt_cache_key: promptCacheKey } : {}),
|
||||
...(include ? { include } : {}),
|
||||
...(encryptedState ? { include: ["reasoning.encrypted_content"] as const } : {}),
|
||||
...(effort || summary ? { reasoning: { effort, summary } } : {}),
|
||||
...(verbosity ? { text: { verbosity } } : {}),
|
||||
}
|
||||
@@ -567,51 +517,24 @@ const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
||||
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.delta) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
const itemID = event.item_id ?? "reasoning-0"
|
||||
const id =
|
||||
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
|
||||
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, event.item_id ?? "reasoning-0", event.delta),
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
// The summary done event does not carry encrypted continuation state. Finish the
|
||||
// common reasoning block when the full reasoning item arrives in output_item.done.
|
||||
const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
|
||||
|
||||
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
||||
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||
|
||||
// OpenAI Responses streams reasoning items in a stable order:
|
||||
// `output_item.added` (reasoning) →
|
||||
// `reasoning_summary_part.added` (index=0) →
|
||||
// `reasoning_summary_text.delta` →
|
||||
// `reasoning_summary_part.done` (index=0) →
|
||||
// (repeat for index>0) →
|
||||
// `output_item.done` (reasoning).
|
||||
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
|
||||
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
|
||||
// short-circuits when the entry already exists, and higher-index handlers
|
||||
// fold against the same entry. Behaviour for out-of-order events is
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
const item = event.item
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
if (item?.type !== "function_call" || !item.id) return [state, NO_EVENTS]
|
||||
const providerMetadata = openaiMetadata({ itemId: item.id })
|
||||
const events: LLMEvent[] = []
|
||||
@@ -632,103 +555,6 @@ const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): Ste
|
||||
]
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id] ?? { encryptedContent: undefined, summaryParts: {} }
|
||||
if (event.summary_index === 0) {
|
||||
if (state.reasoningItems[event.item_id]) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:0`,
|
||||
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: { ...item, summaryParts: { 0: "active" } },
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const events: LLMEvent[] = []
|
||||
const closed = Object.entries(item.summaryParts)
|
||||
.filter((entry) => entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) =>
|
||||
Lifecycle.reasoningEnd(
|
||||
lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${entry[0]}`,
|
||||
openaiMetadata({ itemId: event.item_id }),
|
||||
),
|
||||
state.lifecycle,
|
||||
)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle: Lifecycle.reasoningStart(
|
||||
closed,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
openaiMetadata({ itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null }),
|
||||
),
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...Object.fromEntries(
|
||||
Object.entries(item.summaryParts).map((entry) =>
|
||||
entry[1] === "can-conclude" ? [entry[0], "concluded" as const] : entry,
|
||||
),
|
||||
),
|
||||
[event.summary_index]: "active",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onReasoningSummaryPartDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||
if (!event.item_id || event.summary_index === undefined) return [state, NO_EVENTS]
|
||||
const item = state.reasoningItems[event.item_id]
|
||||
if (!item) return [state, NO_EVENTS]
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
lifecycle:
|
||||
state.store !== false
|
||||
? Lifecycle.reasoningEnd(
|
||||
state.lifecycle,
|
||||
events,
|
||||
`${event.item_id}:${event.summary_index}`,
|
||||
openaiMetadata({ itemId: event.item_id }),
|
||||
)
|
||||
: state.lifecycle,
|
||||
reasoningItems: {
|
||||
...state.reasoningItems,
|
||||
[event.item_id]: {
|
||||
...item,
|
||||
summaryParts: {
|
||||
...item.summaryParts,
|
||||
[event.summary_index]: state.store !== false ? "concluded" : "can-conclude",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
events,
|
||||
]
|
||||
}
|
||||
|
||||
const onFunctionCallArgumentsDelta = Effect.fn("OpenAIResponses.onFunctionCallArgumentsDelta")(function* (
|
||||
state: ParserState,
|
||||
event: OpenAIResponsesEvent,
|
||||
@@ -789,17 +615,6 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||
if (isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
const providerMetadata = reasoningMetadata(item)
|
||||
const reasoningItem = state.reasoningItems[item.id]
|
||||
if (reasoningItem) {
|
||||
const lifecycle = Object.entries(reasoningItem.summaryParts)
|
||||
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
|
||||
.reduce(
|
||||
(lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, providerMetadata),
|
||||
state.lifecycle,
|
||||
)
|
||||
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems
|
||||
return [{ ...state, lifecycle, reasoningItems }, events] satisfies StepResult
|
||||
}
|
||||
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
|
||||
@@ -868,10 +683,6 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => {
|
||||
event.type === "response.reasoning_summary_text.done"
|
||||
)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
if (event.type === "response.reasoning_summary_part.done")
|
||||
return Effect.succeed(onReasoningSummaryPartDone(state, event))
|
||||
if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event))
|
||||
if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event)
|
||||
if (event.type === "response.output_item.done") return onOutputItemDone(state, event)
|
||||
@@ -898,13 +709,7 @@ export const protocol = Protocol.make({
|
||||
},
|
||||
stream: {
|
||||
event: Protocol.jsonEvent(OpenAIResponsesEvent),
|
||||
initial: (request) => ({
|
||||
hasFunctionCall: false,
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
reasoningItems: {},
|
||||
store: OpenAIOptions.store(request),
|
||||
}),
|
||||
initial: () => ({ hasFunctionCall: false, tools: ToolStream.empty<string>(), lifecycle: Lifecycle.initial() }),
|
||||
step,
|
||||
terminal: (event) => TERMINAL_TYPES.has(event.type),
|
||||
},
|
||||
|
||||
@@ -24,22 +24,14 @@ export const textDelta = (state: State, events: LLMEvent[], id: string, text: st
|
||||
return { ...stepped, text: new Set([...stepped.text, id]) }
|
||||
}
|
||||
|
||||
export const reasoningStart = (
|
||||
state: State,
|
||||
events: LLMEvent[],
|
||||
id: string,
|
||||
providerMetadata?: ProviderMetadata,
|
||||
): State => {
|
||||
if (state.reasoning.has(id)) return state
|
||||
const stepped = stepStart(state, events)
|
||||
events.push(LLMEvent.reasoningStart({ id, providerMetadata }))
|
||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||
}
|
||||
|
||||
export const reasoningDelta = (state: State, events: LLMEvent[], id: string, text: string): State => {
|
||||
const started = reasoningStart(state, events, id)
|
||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
||||
return started
|
||||
const stepped = stepStart(state, events)
|
||||
if (stepped.reasoning.has(id)) {
|
||||
events.push(LLMEvent.reasoningDelta({ id, text }))
|
||||
return stepped
|
||||
}
|
||||
events.push(LLMEvent.reasoningStart({ id }), LLMEvent.reasoningDelta({ id, text }))
|
||||
return { ...stepped, reasoning: new Set([...stepped.reasoning, id]) }
|
||||
}
|
||||
|
||||
export const reasoningEnd = (
|
||||
|
||||
@@ -7,28 +7,12 @@ export const OpenAIReasoningEfforts = ReasoningEfforts.filter(
|
||||
)
|
||||
export type OpenAIReasoningEffort = (typeof OpenAIReasoningEfforts)[number]
|
||||
|
||||
// Mirrors OpenAI's `ResponseIncludable` union from the official SDK. Keep this
|
||||
// in lockstep with `openai-node/src/resources/responses/responses.ts`.
|
||||
export const OpenAIResponseIncludables = [
|
||||
"file_search_call.results",
|
||||
"web_search_call.results",
|
||||
"web_search_call.action.sources",
|
||||
"message.input_image.image_url",
|
||||
"computer_call_output.output.image_url",
|
||||
"code_interpreter_call.outputs",
|
||||
"reasoning.encrypted_content",
|
||||
"message.output_text.logprobs",
|
||||
] as const
|
||||
export type OpenAIResponseIncludable = (typeof OpenAIResponseIncludables)[number]
|
||||
|
||||
const REASONING_EFFORTS = new Set<string>(ReasoningEfforts)
|
||||
const OPENAI_REASONING_EFFORTS = new Set<string>(OpenAIReasoningEfforts)
|
||||
const TEXT_VERBOSITY = new Set<string>(["low", "medium", "high"])
|
||||
const INCLUDABLES = new Set<string>(OpenAIResponseIncludables)
|
||||
|
||||
export const OpenAIReasoningEffort = Schema.Literals(OpenAIReasoningEfforts)
|
||||
export const OpenAITextVerbosity = TextVerbosity
|
||||
export const OpenAIResponseIncludable = Schema.Literals(OpenAIResponseIncludables)
|
||||
|
||||
const isAnyReasoningEffort = (effort: unknown): effort is ReasoningEffort =>
|
||||
typeof effort === "string" && REASONING_EFFORTS.has(effort)
|
||||
@@ -51,21 +35,13 @@ export const reasoningEffort = (request: LLMRequest): ReasoningEffort | undefine
|
||||
return isAnyReasoningEffort(value) ? value : undefined
|
||||
}
|
||||
|
||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined =>
|
||||
options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
||||
|
||||
// Resolve the OpenAI Responses `include` field. Filters out unknown
|
||||
// includable values defensively so a typo in upstream config drops the
|
||||
// invalid entry instead of poisoning the wire body. An empty array (either
|
||||
// passed directly or produced by filtering) is treated as "no include" and
|
||||
// returns undefined so the request body omits the field entirely.
|
||||
export const include = (request: LLMRequest): ReadonlyArray<OpenAIResponseIncludable> | undefined => {
|
||||
const value = options(request)?.include
|
||||
if (!Array.isArray(value)) return undefined
|
||||
const filtered = value.filter((entry): entry is OpenAIResponseIncludable => INCLUDABLES.has(entry))
|
||||
return filtered.length > 0 ? filtered : undefined
|
||||
export const reasoningSummary = (request: LLMRequest): "auto" | undefined => {
|
||||
return options(request)?.reasoningSummary === "auto" ? "auto" : undefined
|
||||
}
|
||||
|
||||
export const encryptedReasoning = (request: LLMRequest) =>
|
||||
options(request)?.includeEncryptedReasoning === true ? true : undefined
|
||||
|
||||
export const promptCacheKey = (request: LLMRequest) => {
|
||||
const value = options(request)?.promptCacheKey
|
||||
return typeof value === "string" ? value : undefined
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import type { ProviderOptions, ReasoningEffort, TextVerbosity } from "../schema"
|
||||
import { mergeProviderOptions } from "../schema"
|
||||
import type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
|
||||
|
||||
export type { OpenAIResponseIncludable } from "../protocols/utils/openai-options"
|
||||
|
||||
export interface OpenAIOptionsInput {
|
||||
readonly [key: string]: unknown
|
||||
@@ -10,10 +7,7 @@ export interface OpenAIOptionsInput {
|
||||
readonly promptCacheKey?: string
|
||||
readonly reasoningEffort?: ReasoningEffort
|
||||
readonly reasoningSummary?: "auto"
|
||||
// OpenAI Responses `include` wire field. Mirrors the official SDK's
|
||||
// `ResponseIncludable[]` union exactly so AI SDK callers and direct
|
||||
// native-SDK callers share one shape and no translation is required.
|
||||
readonly include?: ReadonlyArray<OpenAIResponseIncludable>
|
||||
readonly includeEncryptedReasoning?: boolean
|
||||
readonly textVerbosity?: TextVerbosity
|
||||
}
|
||||
|
||||
@@ -31,7 +25,7 @@ const openAIProviderOptions = (options: OpenAIOptionsInput | undefined): Provide
|
||||
promptCacheKey: options?.promptCacheKey,
|
||||
reasoningEffort: options?.reasoningEffort,
|
||||
reasoningSummary: options?.reasoningSummary,
|
||||
include: options?.include,
|
||||
includeEncryptedReasoning: options?.includeEncryptedReasoning,
|
||||
textVerbosity: options?.textVerbosity,
|
||||
}),
|
||||
)
|
||||
@@ -48,12 +42,6 @@ export const gpt5DefaultOptions = (
|
||||
return openAIProviderOptions({
|
||||
reasoningEffort: "medium",
|
||||
reasoningSummary: "auto",
|
||||
// GPT-5 reasoning models are configured stateless (`store: false`) by
|
||||
// `openAIDefaultOptions` below, so the only way a follow-up turn can
|
||||
// carry reasoning state is via the encrypted reasoning include. Without
|
||||
// this, callers using the default model facade get reasoning summaries
|
||||
// they cannot replay statelessly.
|
||||
include: ["reasoning.encrypted_content"],
|
||||
textVerbosity:
|
||||
options.textVerbosity === true && id.includes("gpt-5.") && !id.includes("codex") && !id.includes("-chat")
|
||||
? "low"
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as OpenAIChat from "../protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../protocols/openai-responses"
|
||||
import { withOpenAIOptions, type OpenAIProviderOptionsInput } from "./openai-options"
|
||||
|
||||
export type { OpenAIOptionsInput, OpenAIResponseIncludable } from "./openai-options"
|
||||
export type { OpenAIOptionsInput } from "./openai-options"
|
||||
|
||||
export const id = ProviderID.make("openai")
|
||||
|
||||
|
||||
@@ -283,7 +283,7 @@ function makeFromTransport<Body, Prepared, Frame, Event, State>(
|
||||
)
|
||||
return events.pipe(
|
||||
Stream.mapAccumEffect(
|
||||
() => protocol.stream.initial(request),
|
||||
protocol.stream.initial,
|
||||
protocol.stream.step,
|
||||
protocol.stream.onHalt ? { onHalt: protocol.stream.onHalt } : undefined,
|
||||
),
|
||||
|
||||
@@ -52,8 +52,8 @@ export interface ProtocolBody<Body> {
|
||||
export interface ProtocolStream<Frame, Event, State> {
|
||||
/** Schema for one decoded streaming event, decoded from a transport frame. */
|
||||
readonly event: Schema.Codec<Event, Frame>
|
||||
/** Initial parser state. Called once per response with the resolved request. */
|
||||
readonly initial: (request: LLMRequest) => State
|
||||
/** Initial parser state. Called once per response. */
|
||||
readonly initial: () => State
|
||||
/** Translate one event into emitted `LLMEvent`s plus the next state. */
|
||||
readonly step: (state: State, event: Event) => Effect.Effect<readonly [State, ReadonlyArray<LLMEvent>], LLMError>
|
||||
/** Optional request-completion signal for transports that do not end naturally. */
|
||||
|
||||
@@ -97,7 +97,7 @@ export function continuationRequest(input: {
|
||||
tools: features.has("tool-call") ? [continuationTool] : [],
|
||||
cache: "none",
|
||||
providerOptions: features.has("encrypted-reasoning")
|
||||
? { openai: { store: false, include: ["reasoning.encrypted_content"], reasoningSummary: "auto" } }
|
||||
? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
|
||||
: undefined,
|
||||
generation: { maxTokens: 80, temperature: 0 },
|
||||
})
|
||||
|
||||
+3
-3
File diff suppressed because one or more lines are too long
+4
-4
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
@@ -393,7 +393,7 @@ describe("OpenAI Responses route", () => {
|
||||
promptCacheKey: "session_123",
|
||||
reasoningEffort: "high",
|
||||
reasoningSummary: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
includeEncryptedReasoning: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
@@ -407,108 +407,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("accepts the full ResponseIncludable union", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
providerOptions: {
|
||||
openai: {
|
||||
include: ["reasoning.encrypted_content", "code_interpreter_call.outputs", "web_search_call.results"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toEqual([
|
||||
"reasoning.encrypted_content",
|
||||
"code_interpreter_call.outputs",
|
||||
"web_search_call.results",
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("filters unknown includable values out of the include array", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
prompt: "hi",
|
||||
// The user passed one invalid entry alongside a valid one. Keep the
|
||||
// valid one so the request still succeeds rather than failing on a
|
||||
// typo from upstream config.
|
||||
providerOptions: { openai: { include: ["reasoning.encrypted_content", "bogus.thing"] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an explicit empty include as no include at all", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: [] } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("treats an all-invalid include as no include at all", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { include: ["bogus.thing"] } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("omits include when no include is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({ model, prompt: "hi", providerOptions: { openai: { store: false } } }),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("requests encrypted reasoning by default for GPT-5 reasoning models", () =>
|
||||
Effect.gen(function* () {
|
||||
// The native OpenAI facade configures GPT-5 stateless (store: false) with
|
||||
// reasoningSummary: "auto" by default. Without `include`, a follow-up
|
||||
// turn cannot replay reasoning state, so the facade also opts into
|
||||
// `reasoning.encrypted_content` automatically.
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||
prompt: "hi",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.store).toBe(false)
|
||||
expect(prepared.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(prepared.body.reasoning).toEqual({ effort: "medium", summary: "auto" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lets callers opt out of the GPT-5 default include", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model: OpenAI.configure({ baseURL: "https://api.openai.test/v1/", apiKey: "test" }).responses("gpt-5.2"),
|
||||
prompt: "hi",
|
||||
providerOptions: { openai: { include: [] } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.include).toBeUndefined()
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("request OpenAI provider options override route defaults", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
@@ -649,94 +547,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("streams each reasoning summary part as a separate block", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, { providerOptions: { openai: { store: false } } }),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.reasoning).toBe("FirstSecond")
|
||||
expect(response.events).toMatchObject([
|
||||
{ type: "step-start", index: 0 },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:0",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:0", text: "First" },
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{
|
||||
type: "reasoning-start",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: null } },
|
||||
},
|
||||
{ type: "reasoning-delta", id: "rs_1:1", text: "Second" },
|
||||
{
|
||||
type: "reasoning-end",
|
||||
id: "rs_1:1",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
{ type: "step-finish", index: 0, reason: "stop" },
|
||||
{ type: "finish", reason: "stop" },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("closes reasoning summary parts when storage is not disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(
|
||||
fixedResponse(
|
||||
sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 0, delta: "First" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 1 },
|
||||
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", summary_index: 1, delta: "Second" },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 1 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.completed", response: { id: "resp_1" } },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
expect(response.events.filter((event) => event.type === "reasoning-end")).toEqual([
|
||||
{ type: "reasoning-end", id: "rs_1:0", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
{ type: "reasoning-end", id: "rs_1:1", providerMetadata: { openai: { itemId: "rs_1" } } },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("continues a stateless reasoning conversation", () =>
|
||||
Effect.gen(function* () {
|
||||
const response = yield* LLMClient.generate(
|
||||
@@ -760,7 +570,6 @@ describe("OpenAI Responses route", () => {
|
||||
]),
|
||||
Message.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
).pipe(
|
||||
Effect.provide(
|
||||
@@ -818,7 +627,6 @@ describe("OpenAI Responses route", () => {
|
||||
{ type: "text", text: "After." },
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -835,66 +643,6 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored reasoning items by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Checked the previous diff.",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: true } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([{ type: "item_reference", id: "rs_1" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
id: "req_multi_summary_continuation",
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "First",
|
||||
providerMetadata: { openai: { itemId: "rs_1" } },
|
||||
},
|
||||
{
|
||||
type: "reasoning",
|
||||
text: "Second",
|
||||
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||
},
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: false } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
type: "reasoning",
|
||||
id: "rs_1",
|
||||
encrypted_content: "encrypted-state",
|
||||
summary: [
|
||||
{ type: "summary_text", text: "First" },
|
||||
{ type: "summary_text", text: "Second" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
|
||||
@@ -158,7 +158,7 @@ const normalizeImageText = (value: string) =>
|
||||
const encryptedReasoningOptions = {
|
||||
openai: {
|
||||
store: false,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
includeEncryptedReasoning: true,
|
||||
reasoningEffort: "low",
|
||||
reasoningSummary: "auto",
|
||||
},
|
||||
|
||||
@@ -4,7 +4,6 @@ import { GenerationOptions, LLM, LLMEvent, LLMRequest, LLMResponse, ToolChoice }
|
||||
import { Auth, LLMClient } from "../src/route"
|
||||
import * as AnthropicMessages from "../src/protocols/anthropic-messages"
|
||||
import * as OpenAIChat from "../src/protocols/openai-chat"
|
||||
import * as OpenAIResponses from "../src/protocols/openai-responses"
|
||||
import { tool, ToolFailure, type ToolExecuteContext } from "../src/tool"
|
||||
import { ToolRuntime } from "../src/tool-runtime"
|
||||
import { it } from "./lib/effect"
|
||||
@@ -310,80 +309,6 @@ describe("LLMClient tools", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays encrypted OpenAI reasoning items with tool outputs", () =>
|
||||
Effect.gen(function* () {
|
||||
const bodies: unknown[] = []
|
||||
const layer = dynamicResponse((input) =>
|
||||
Effect.sync(() => {
|
||||
bodies.push(decodeJson(input.text))
|
||||
return input.respond(
|
||||
bodies.length === 1
|
||||
? sseEvents(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: null },
|
||||
},
|
||||
{ type: "response.reasoning_summary_part.added", item_id: "rs_1", summary_index: 0 },
|
||||
{ type: "response.reasoning_summary_part.done", item_id: "rs_1", summary_index: 0 },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: { type: "reasoning", id: "rs_1", encrypted_content: "encrypted-state" },
|
||||
},
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "get_weather",
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
{ type: "response.function_call_arguments.delta", item_id: "item_1", delta: '{"city":"Paris"}' },
|
||||
{
|
||||
type: "response.output_item.done",
|
||||
item: {
|
||||
type: "function_call",
|
||||
id: "item_1",
|
||||
call_id: "call_1",
|
||||
name: "get_weather",
|
||||
arguments: '{"city":"Paris"}',
|
||||
},
|
||||
},
|
||||
{ type: "response.completed", response: {} },
|
||||
)
|
||||
: sseEvents(
|
||||
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Done." },
|
||||
{ type: "response.completed", response: {} },
|
||||
),
|
||||
{ headers: { "content-type": "text/event-stream" } },
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
yield* TestToolRuntime.runTools({
|
||||
request: LLM.request({
|
||||
model: OpenAIResponses.route
|
||||
.with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
|
||||
.model({ id: "gpt-5.5" }),
|
||||
prompt: "Use the tool.",
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
}),
|
||||
tools: { get_weather },
|
||||
}).pipe(Stream.runCollect, Effect.provide(layer))
|
||||
|
||||
expect(bodies[1]).toMatchObject({
|
||||
include: ["reasoning.encrypted_content"],
|
||||
input: [
|
||||
{ role: "user" },
|
||||
{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" },
|
||||
{ type: "function_call", call_id: "call_1", name: "get_weather" },
|
||||
{ type: "function_call_output", call_id: "call_1" },
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits tool-error for unknown tools so the model can self-correct", () =>
|
||||
Effect.gen(function* () {
|
||||
const layer = scriptedResponses([
|
||||
|
||||
@@ -74,15 +74,15 @@
|
||||
"@actions/github": "6.0.1",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@ai-sdk/alibaba": "1.0.17",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.107",
|
||||
"@ai-sdk/amazon-bedrock": "4.0.96",
|
||||
"@ai-sdk/anthropic": "3.0.71",
|
||||
"@ai-sdk/azure": "3.0.49",
|
||||
"@ai-sdk/cerebras": "2.0.41",
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/deepinfra": "2.0.41",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google": "3.0.75",
|
||||
"@ai-sdk/google-vertex": "4.0.131",
|
||||
"@ai-sdk/google": "3.0.63",
|
||||
"@ai-sdk/google-vertex": "4.0.112",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.27",
|
||||
"@ai-sdk/openai": "3.0.53",
|
||||
|
||||
@@ -253,20 +253,17 @@ export const Info = Schema.Struct({
|
||||
}),
|
||||
),
|
||||
tool_output: Schema.optional(
|
||||
Schema.Union([
|
||||
Schema.Literal(false).annotate({ description: "Disable tool output truncation" }),
|
||||
Schema.Struct({
|
||||
max_lines: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
|
||||
}),
|
||||
max_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
|
||||
}),
|
||||
Schema.Struct({
|
||||
max_lines: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum lines of tool output before it is truncated and saved to disk (default: 2000)",
|
||||
}),
|
||||
]),
|
||||
max_bytes: Schema.optional(PositiveInt).annotate({
|
||||
description: "Maximum bytes of tool output before it is truncated and saved to disk (default: 51200)",
|
||||
}),
|
||||
}),
|
||||
).annotate({
|
||||
description:
|
||||
"Configure tool output truncation. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
|
||||
"Thresholds for truncating tool output. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.",
|
||||
}),
|
||||
compaction: Schema.optional(
|
||||
Schema.Struct({
|
||||
|
||||
@@ -17,11 +17,6 @@ function mimeToModality(mime: string): Modality | undefined {
|
||||
|
||||
export const OUTPUT_TOKEN_MAX = 32_000
|
||||
|
||||
// OpenAI Responses `include` value that returns the encrypted reasoning state
|
||||
// needed for stateless multi-turn reasoning (store: false). Hoisted so every
|
||||
// branch that requests it stays in lockstep.
|
||||
const INCLUDE_ENCRYPTED_REASONING = ["reasoning.encrypted_content"] as const
|
||||
|
||||
export function sanitizeSurrogates(content: string) {
|
||||
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
|
||||
}
|
||||
@@ -761,7 +756,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: INCLUDE_ENCRYPTED_REASONING,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -795,7 +790,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: INCLUDE_ENCRYPTED_REASONING,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -808,7 +803,7 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
|
||||
{
|
||||
reasoningEffort: effort,
|
||||
reasoningSummary: "auto",
|
||||
include: INCLUDE_ENCRYPTED_REASONING,
|
||||
include: ["reasoning.encrypted_content"],
|
||||
},
|
||||
]),
|
||||
)
|
||||
@@ -1139,9 +1134,6 @@ export function options(input: {
|
||||
if (!input.model.api.id.includes("gpt-5-pro")) {
|
||||
result["reasoningEffort"] = "medium"
|
||||
result["reasoningSummary"] = "auto"
|
||||
if (input.model.api.npm === "@ai-sdk/openai") {
|
||||
result["include"] = INCLUDE_ENCRYPTED_REASONING
|
||||
}
|
||||
}
|
||||
|
||||
// Only set textVerbosity for non-chat gpt-5.x models
|
||||
@@ -1157,7 +1149,7 @@ export function options(input: {
|
||||
|
||||
if (input.model.providerID.startsWith("opencode")) {
|
||||
result["promptCacheKey"] = input.sessionID
|
||||
result["include"] = INCLUDE_ENCRYPTED_REASONING
|
||||
result["include"] = ["reasoning.encrypted_content"]
|
||||
result["reasoningSummary"] = "auto"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,14 +70,6 @@ export function stream(input: StreamInput): StreamResult {
|
||||
|
||||
// Integration point with @opencode-ai/llm: native-request lowers session data
|
||||
// into an LLMRequest, then LLMClient handles route selection and transport.
|
||||
//
|
||||
// ProviderTransform.providerOptions builds AI-SDK-shaped options for the
|
||||
// selected SDK key (e.g. "openai") and the native LLM SDK reads the same
|
||||
// keys via OpenAIOptions.* (store, reasoningEffort, reasoningSummary,
|
||||
// include, textVerbosity, promptCacheKey). Both sides intentionally use
|
||||
// OpenAI's official wire field names, so this is identity, not translation
|
||||
// — if a field ever needs to differ between the two surfaces, the
|
||||
// translation belongs here, not split across both packages.
|
||||
const stream = input.llmClient.stream({
|
||||
request: LLMNative.request({
|
||||
model: input.model,
|
||||
|
||||
@@ -16,6 +16,7 @@ import * as Log from "@opencode-ai/core/util/log"
|
||||
import { Discovery } from "./discovery"
|
||||
import CUSTOMIZE_OPENCODE_SKILL_BODY from "./prompt/customize-opencode.md" with { type: "text" }
|
||||
import { isRecord } from "@/util/record"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "skill" })
|
||||
const CLAUDE_EXTERNAL_DIR = ".claude"
|
||||
@@ -243,6 +244,8 @@ const loadSkills = Effect.fnUntraced(function* (state: State, discovered: Discov
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Skill") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -23,10 +23,6 @@ shape before writing config, **fetch that URL and read the schema directly**
|
||||
rather than guessing. opencode hard-fails on invalid config, so the cost of a
|
||||
wrong shape is a broken startup.
|
||||
|
||||
The full schema is large. Prefer using JavaScript or Bash to fetch and extract
|
||||
the relevant property definition instead of reading the entire schema into
|
||||
context when you only need to check one setting.
|
||||
|
||||
Independently, every `opencode.json` should declare
|
||||
`"$schema": "https://opencode.ai/config.json"` so the user's editor catches
|
||||
mistakes as they type.
|
||||
|
||||
@@ -55,6 +55,7 @@ import { Reference } from "@/reference/reference"
|
||||
import { BackgroundJob } from "@/background/job"
|
||||
import { SessionStatus } from "@/session/status"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import { serviceUse } from "@opencode-ai/core/effect/service-use"
|
||||
|
||||
const log = Log.create({ service: "tool.registry" })
|
||||
|
||||
@@ -81,6 +82,8 @@ export interface Interface {
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/ToolRegistry") {}
|
||||
|
||||
export const use = serviceUse(Service)
|
||||
|
||||
export const layer: Layer.Layer<
|
||||
Service,
|
||||
never,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Effect, Option, Stream } from "effect"
|
||||
import { Effect, Stream } from "effect"
|
||||
import os from "os"
|
||||
import { createWriteStream } from "node:fs"
|
||||
import * as Tool from "./tool"
|
||||
@@ -433,10 +433,7 @@ export const ShellTool = Tool.define(
|
||||
ctx: Tool.Context,
|
||||
) {
|
||||
const limits = yield* trunc.limits()
|
||||
const keep = Option.match(limits, {
|
||||
onNone: () => Number.POSITIVE_INFINITY,
|
||||
onSome: (l) => l.maxBytes * 2,
|
||||
})
|
||||
const keep = limits.maxBytes * 2
|
||||
let full = ""
|
||||
let last = ""
|
||||
const list: Chunk[] = []
|
||||
@@ -502,7 +499,7 @@ export const ShellTool = Tool.define(
|
||||
sink?.write(chunk)
|
||||
} else {
|
||||
full += chunk
|
||||
if (Option.isSome(limits) && Buffer.byteLength(full, "utf-8") > limits.value.maxBytes) {
|
||||
if (Buffer.byteLength(full, "utf-8") > limits.maxBytes) {
|
||||
return trunc.write(full).pipe(
|
||||
Effect.andThen((next) =>
|
||||
Effect.sync(() => {
|
||||
@@ -569,10 +566,7 @@ export const ShellTool = Tool.define(
|
||||
}
|
||||
if (aborted) meta.push("User aborted the command")
|
||||
const raw = list.map((item) => item.text).join("")
|
||||
const end = Option.match(limits, {
|
||||
onNone: () => ({ text: raw, cut: false }),
|
||||
onSome: (l) => tail(raw, l.maxLines, l.maxBytes),
|
||||
})
|
||||
const end = tail(raw, limits.maxLines, limits.maxBytes)
|
||||
if (end.cut) cut = true
|
||||
if (!file && end.cut) {
|
||||
file = yield* trunc.write(raw)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Option, Schema } from "effect"
|
||||
import { Schema } from "effect"
|
||||
import DESCRIPTION from "./shell.txt"
|
||||
import { PositiveInt } from "@opencode-ai/core/schema"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
@@ -83,12 +83,7 @@ function chainGuidance(name: string) {
|
||||
return "If the commands depend on each other and must run sequentially, use a single Bash call with '&&' to chain them together (e.g., `git add . && git commit -m \"message\" && git push`). For instance, if one operation must complete before another starts (like mkdir before cp, Write before Bash for git operations, or git add before git commit), run these operations sequentially instead."
|
||||
}
|
||||
|
||||
function truncationGuidance(limits: Option.Option<Limits>, commands: string) {
|
||||
if (Option.isNone(limits)) return ""
|
||||
return `\n - If the output exceeds ${limits.value.maxLines} lines or ${limits.value.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use ${commands} to limit output; the full output will already be captured to a file for more precise searching.`
|
||||
}
|
||||
|
||||
function bashCommandSection(chain: string, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
function bashCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
return `Before executing the command, please follow these steps:
|
||||
|
||||
1. Directory Verification:
|
||||
@@ -108,7 +103,8 @@ function bashCommandSection(chain: string, limits: Option.Option<Limits>, defaul
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`head`, `tail`, or other truncation commands")}
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`head\`, \`tail\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Bash with the \`find\`, \`grep\`, \`cat\`, \`head\`, \`tail\`, \`sed\`, \`awk\`, or \`echo\` commands, unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT find or ls)
|
||||
@@ -135,7 +131,7 @@ function powershellCommandSection(
|
||||
name: string,
|
||||
chain: string,
|
||||
pathSep: string,
|
||||
limits: Option.Option<Limits>,
|
||||
limits: Limits,
|
||||
defaultTimeoutMs: number,
|
||||
) {
|
||||
return `${powershellNotes(name)}
|
||||
@@ -159,7 +155,8 @@ Before executing the command, please follow these steps:
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`Select-Object -First`, `Select-Object -Last`, or other truncation commands")}
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`Select-Object -First\`, \`Select-Object -Last\`, or other truncation commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Shell with PowerShell file/content cmdlets unless explicitly instructed or when these cmdlets are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT Get-ChildItem)
|
||||
@@ -182,7 +179,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function cmdCommandSection(chain: string, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
function cmdCommandSection(chain: string, limits: Limits, defaultTimeoutMs: number) {
|
||||
return `# cmd.exe shell notes
|
||||
- Use double quotes for paths with spaces.
|
||||
- Use %VAR% for environment variables.
|
||||
@@ -208,7 +205,8 @@ Before executing the command, please follow these steps:
|
||||
Usage notes:
|
||||
- The command argument is required.
|
||||
- You can specify an optional timeout in milliseconds. If not specified, commands will time out after ${defaultTimeoutMs}ms.
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.${truncationGuidance(limits, "`more` or other pagination commands")}
|
||||
- It is very helpful if you write a clear, concise description of what this command does in 5-10 words.
|
||||
- If the output exceeds ${limits.maxLines} lines or ${limits.maxBytes} bytes, it will be truncated and the full output will be written to a file. You can use Read with offset/limit to read specific sections or Grep to search the full content. Do NOT use \`more\` or other pagination commands to limit output; the full output will already be captured to a file for more precise searching.
|
||||
|
||||
- Avoid using Shell with cmd.exe file/content commands unless explicitly instructed or when these commands are truly necessary for the task. Instead, always prefer using the dedicated tools for these commands:
|
||||
- File search: Use Glob (NOT dir /s)
|
||||
@@ -231,7 +229,7 @@ Usage notes:
|
||||
</bad-example>`
|
||||
}
|
||||
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
function profile(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
const isPowerShell = PS.has(name)
|
||||
const chain = chainGuidance(name)
|
||||
if (CMD.has(name)) {
|
||||
@@ -286,7 +284,7 @@ function profile(name: string, platform: NodeJS.Platform, limits: Option.Option<
|
||||
}
|
||||
}
|
||||
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Option.Option<Limits>, defaultTimeoutMs: number) {
|
||||
export function render(name: string, platform: NodeJS.Platform, limits: Limits, defaultTimeoutMs: number) {
|
||||
const selected = profile(name, platform, limits, defaultTimeoutMs)
|
||||
return {
|
||||
description: renderPrompt(DESCRIPTION, {
|
||||
|
||||
@@ -19,7 +19,6 @@ export const DIR = TRUNCATION_DIR
|
||||
export const GLOB = path.join(TRUNCATION_DIR, "*")
|
||||
|
||||
export type Result = { content: string; truncated: false } | { content: string; truncated: true; outputPath: string }
|
||||
export type Limits = { maxLines: number; maxBytes: number }
|
||||
|
||||
export interface Options {
|
||||
maxLines?: number
|
||||
@@ -41,11 +40,9 @@ export interface Interface {
|
||||
*/
|
||||
readonly output: (text: string, options?: Options, agent?: Agent.Info) => Effect.Effect<Result>
|
||||
/**
|
||||
* Resolved truncation limits from `tool_output` in opencode config.
|
||||
* Returns `None` when the user has disabled truncation (`tool_output: false`),
|
||||
* in which case callers should pass output through without enforcing thresholds.
|
||||
* Resolved truncation limits: values from `tool_output` in opencode config, or MAX_LINES / MAX_BYTES if unset.
|
||||
*/
|
||||
readonly limits: () => Effect.Effect<Option.Option<Limits>>
|
||||
readonly limits: () => Effect.Effect<{ maxLines: number; maxBytes: number }>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Truncate") {}
|
||||
@@ -78,21 +75,18 @@ export const layer = Layer.effect(
|
||||
|
||||
const limits = Effect.fn("Truncate.limits")(function* () {
|
||||
const configSvc = yield* Effect.serviceOption(Config.Service)
|
||||
if (Option.isNone(configSvc)) return Option.some({ maxLines: MAX_LINES, maxBytes: MAX_BYTES })
|
||||
if (Option.isNone(configSvc)) return { maxLines: MAX_LINES, maxBytes: MAX_BYTES }
|
||||
const cfg = yield* configSvc.value.get().pipe(Effect.catch(() => Effect.succeed(undefined)))
|
||||
const tool_output = cfg?.tool_output
|
||||
if (tool_output === false) return Option.none<Limits>()
|
||||
return Option.some({
|
||||
maxLines: tool_output?.max_lines ?? MAX_LINES,
|
||||
maxBytes: tool_output?.max_bytes ?? MAX_BYTES,
|
||||
})
|
||||
return {
|
||||
maxLines: cfg?.tool_output?.max_lines ?? MAX_LINES,
|
||||
maxBytes: cfg?.tool_output?.max_bytes ?? MAX_BYTES,
|
||||
}
|
||||
})
|
||||
|
||||
const output = Effect.fn("Truncate.output")(function* (text: string, options: Options = {}, agent?: Agent.Info) {
|
||||
const resolved = yield* limits()
|
||||
if (Option.isNone(resolved)) return { content: text, truncated: false } as const
|
||||
const maxLines = options.maxLines ?? resolved.value.maxLines
|
||||
const maxBytes = options.maxBytes ?? resolved.value.maxBytes
|
||||
const maxLines = options.maxLines ?? resolved.maxLines
|
||||
const maxBytes = options.maxBytes ?? resolved.maxBytes
|
||||
const direction = options.direction ?? "head"
|
||||
const lines = text.split("\n")
|
||||
const totalBytes = Buffer.byteLength(text, "utf-8")
|
||||
|
||||
@@ -1292,52 +1292,6 @@ test("config parser preserves permission order while rejecting unknown top-level
|
||||
}
|
||||
})
|
||||
|
||||
test("tool_output accepts thresholds or disables truncation", () => {
|
||||
expect(ConfigParse.schema(Config.Info, { tool_output: false }, "test").tool_output).toBe(false)
|
||||
expect(
|
||||
ConfigParse.schema(Config.Info, { tool_output: { max_lines: 200, max_bytes: 8192 } }, "test").tool_output,
|
||||
).toEqual({
|
||||
max_lines: 200,
|
||||
max_bytes: 8192,
|
||||
})
|
||||
})
|
||||
|
||||
it.effect("project tool_output limits replace disabled global truncation", () =>
|
||||
withConfigTree(
|
||||
{
|
||||
global: { tool_output: false },
|
||||
project: { tool_output: { max_lines: 200 } },
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
expect((yield* Config.use.get()).tool_output).toEqual({ max_lines: 200 })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("project disabled tool_output replaces global limits", () =>
|
||||
withConfigTree(
|
||||
{
|
||||
global: { tool_output: { max_lines: 200, max_bytes: 8192 } },
|
||||
project: { tool_output: false },
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
expect((yield* Config.use.get()).tool_output).toBe(false)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
it.effect("enabled tool_output limits still merge across layers", () =>
|
||||
withConfigTree(
|
||||
{
|
||||
global: { tool_output: { max_bytes: 8192 } },
|
||||
project: { tool_output: { max_lines: 200 } },
|
||||
},
|
||||
Effect.gen(function* () {
|
||||
expect((yield* Config.use.get()).tool_output).toEqual({ max_bytes: 8192, max_lines: 200 })
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// MCP config merging tests
|
||||
|
||||
it.instance("project config can override MCP server enabled status", () =>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { $ } from "bun"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
import { File } from "../../src/file"
|
||||
import { disposeAllInstances, TestInstance, withTmpdirInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -48,6 +47,11 @@ const gitAddAll = (directory: string) => Effect.promise(() => $`git add .`.cwd(d
|
||||
const gitCommit = (directory: string, message: string) =>
|
||||
Effect.promise(() => $`git commit -m ${message}`.cwd(directory).quiet())
|
||||
|
||||
const writeFixtureFile = (directory: string, file: string, content: string | Uint8Array) =>
|
||||
AppFileSystem.use.writeWithDirs(path.join(directory, file), content)
|
||||
|
||||
const removeFixtureFile = (directory: string, file: string) => AppFileSystem.use.remove(path.join(directory, file))
|
||||
|
||||
const failureMessage = <A, E, R>(self: Effect.Effect<A, E, R>) =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* self.pipe(Effect.exit)
|
||||
@@ -72,7 +76,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("reads text file via Filesystem.readText()", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "Hello World", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "test.txt", "Hello World")
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -91,9 +95,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("trims whitespace from text content", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.txt"), " content with spaces \n\n", "utf-8"),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "test.txt", " content with spaces \n\n")
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.content).toBe("content with spaces")
|
||||
@@ -103,7 +105,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("handles empty text file", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "empty.txt"), "", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "empty.txt", "")
|
||||
|
||||
const result = yield* read("empty.txt")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -114,9 +116,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("handles multi-line text files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "multiline.txt"), "line1\nline2\nline3", "utf-8"),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "multiline.txt", "line1\nline2\nline3")
|
||||
|
||||
const result = yield* read("multiline.txt")
|
||||
expect(result.content).toBe("line1\nline2\nline3")
|
||||
@@ -129,7 +129,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "image.png"), binaryContent))
|
||||
yield* writeFixtureFile(test.directory, "image.png", binaryContent)
|
||||
|
||||
const result = yield* read("image.png")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -142,9 +142,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("returns empty for binary non-image files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "binary.so"), Buffer.from([0x7f, 0x45, 0x4c, 0x46])),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "binary.so", Buffer.from([0x7f, 0x45, 0x4c, 0x46]))
|
||||
|
||||
const result = yield* read("binary.so")
|
||||
expect(result.type).toBe("binary")
|
||||
@@ -158,7 +156,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "test.json")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, '{"key": "value"}', "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, '{"key": "value"}')
|
||||
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain("application/json")
|
||||
|
||||
@@ -179,7 +177,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
|
||||
for (const testCase of testCases) {
|
||||
const filepath = path.join(test.directory, `test.${testCase.ext}`)
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00])))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]))
|
||||
expect(AppFileSystem.mimeType(filepath)).toContain(testCase.mime)
|
||||
}
|
||||
}),
|
||||
@@ -288,9 +286,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats .ts files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.ts"), "export const value = 1", "utf-8"),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "test.ts", "export const value = 1")
|
||||
|
||||
const result = yield* read("test.ts")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -301,9 +297,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats .mts files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.mts"), "export const value = 1", "utf-8"),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "test.mts", "export const value = 1")
|
||||
|
||||
const result = yield* read("test.mts")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -314,9 +308,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats .sh files as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.sh"), "#!/usr/bin/env bash\necho hello", "utf-8"),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "test.sh", "#!/usr/bin/env bash\necho hello")
|
||||
|
||||
const result = yield* read("test.sh")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -327,7 +319,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("treats Dockerfile as text", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "Dockerfile"), "FROM alpine:3.20", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "Dockerfile", "FROM alpine:3.20")
|
||||
|
||||
const result = yield* read("Dockerfile")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -338,7 +330,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("returns encoding info for text files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "test.txt"), "simple text", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "test.txt", "simple text")
|
||||
|
||||
const result = yield* read("test.txt")
|
||||
expect(result.encoding).toBeUndefined()
|
||||
@@ -349,9 +341,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("returns base64 encoding for images", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "test.jpg"), Buffer.from([0xff, 0xd8, 0xff, 0xe0])),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "test.jpg", Buffer.from([0xff, 0xd8, 0xff, 0xe0]))
|
||||
|
||||
const result = yield* read("test.jpg")
|
||||
expect(result.encoding).toBe("base64")
|
||||
@@ -381,10 +371,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "original\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "original\n")
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "modified\nextra line\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "modified\nextra line\n")
|
||||
|
||||
const result = yield* status()
|
||||
const entry = result.find((file) => file.path === "file.txt")
|
||||
@@ -401,9 +391,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "new.txt"), "line1\nline2\nline3\n", "utf-8"),
|
||||
)
|
||||
yield* writeFixtureFile(test.directory, "new.txt", "line1\nline2\nline3\n")
|
||||
|
||||
const result = yield* status()
|
||||
const entry = result.find((file) => file.path === "new.txt")
|
||||
@@ -421,10 +409,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "gone.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "content\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "content\n")
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.rm(filepath))
|
||||
yield* AppFileSystem.use.remove(filepath)
|
||||
|
||||
const result = yield* status()
|
||||
const entries = result.filter((file) => file.path === "gone.txt")
|
||||
@@ -438,14 +426,14 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "keep\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "remove.txt"), "remove\n", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "keep.txt", "keep\n")
|
||||
yield* writeFixtureFile(test.directory, "remove.txt", "remove\n")
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "initial")
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "keep.txt"), "changed\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.rm(path.join(test.directory, "remove.txt")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "brand-new.txt"), "hello\n", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "keep.txt", "changed\n")
|
||||
yield* removeFixtureFile(test.directory, "remove.txt")
|
||||
yield* writeFixtureFile(test.directory, "brand-new.txt", "hello\n")
|
||||
|
||||
const result = yield* status()
|
||||
expect(result.some((file) => file.path === "keep.txt" && file.status === "modified")).toBe(true)
|
||||
@@ -476,13 +464,15 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "data.bin")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(filepath, Buffer.from(Array.from({ length: 256 }, (_, index) => index))),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
filepath,
|
||||
Buffer.from(Array.from({ length: 256 }, (_, index) => index)),
|
||||
)
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add binary")
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(filepath, Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256))),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
filepath,
|
||||
Buffer.from(Array.from({ length: 512 }, (_, index) => index % 256)),
|
||||
)
|
||||
|
||||
const result = yield* status()
|
||||
@@ -502,11 +492,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "subdir")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "content", "utf-8"))
|
||||
yield* Effect.promise(() =>
|
||||
fs.writeFile(path.join(test.directory, "subdir", "nested.txt"), "nested", "utf-8"),
|
||||
)
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "subdir"))
|
||||
yield* writeFixtureFile(test.directory, "file.txt", "content")
|
||||
yield* writeFixtureFile(test.directory, path.join("subdir", "nested.txt"), "nested")
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(2)
|
||||
@@ -527,10 +515,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "beta")))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "alpha")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "zz.txt"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "aa.txt"), "", "utf-8"))
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "beta"))
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "alpha"))
|
||||
yield* writeFixtureFile(test.directory, "zz.txt", "")
|
||||
yield* writeFixtureFile(test.directory, "aa.txt", "")
|
||||
|
||||
const nodes = yield* list()
|
||||
const dirs = nodes.filter((node) => node.type === "directory")
|
||||
@@ -551,8 +539,8 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".DS_Store"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "visible.txt"), "", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, ".DS_Store", "")
|
||||
yield* writeFixtureFile(test.directory, "visible.txt", "")
|
||||
|
||||
const names = (yield* list()).map((node) => node.name)
|
||||
expect(names).not.toContain(".git")
|
||||
@@ -567,10 +555,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, ".gitignore"), "*.log\nbuild/\n", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "app.log"), "log data", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "main.ts"), "code", "utf-8"))
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "build")))
|
||||
yield* writeFixtureFile(test.directory, ".gitignore", "*.log\nbuild/\n")
|
||||
yield* writeFixtureFile(test.directory, "app.log", "log data")
|
||||
yield* writeFixtureFile(test.directory, "main.ts", "code")
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "build"))
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.find((node) => node.name === "app.log")?.ignored).toBe(true)
|
||||
@@ -585,9 +573,9 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(test.directory, "sub")))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "a.txt"), "", "utf-8"))
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "sub", "b.txt"), "", "utf-8"))
|
||||
yield* AppFileSystem.use.ensureDir(path.join(test.directory, "sub"))
|
||||
yield* writeFixtureFile(test.directory, path.join("sub", "a.txt"), "")
|
||||
yield* writeFixtureFile(test.directory, path.join("sub", "b.txt"), "")
|
||||
|
||||
const nodes = yield* list("sub")
|
||||
expect(nodes.length).toBe(2)
|
||||
@@ -609,7 +597,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
it.instance("works without git", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "file.txt"), "hi", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "file.txt", "hi")
|
||||
|
||||
const nodes = yield* list()
|
||||
expect(nodes.length).toBeGreaterThanOrEqual(1)
|
||||
@@ -755,7 +743,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
yield* init()
|
||||
expect(yield* search({ query: "fresh", type: "file" })).toEqual([])
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "fresh.ts"), "fresh", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "fresh.ts", "fresh")
|
||||
|
||||
expect(yield* search({ query: "fresh", type: "file" })).toContain("fresh.ts")
|
||||
}),
|
||||
@@ -770,10 +758,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "file.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "original content\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "original content\n")
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "modified content\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "modified content\n")
|
||||
|
||||
const result = yield* read("file.txt")
|
||||
expect(result.type).toBe("text")
|
||||
@@ -793,10 +781,10 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "staged.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "before\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "before\n")
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "after\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "after\n")
|
||||
yield* gitAddAll(test.directory)
|
||||
|
||||
const result = yield* read("staged.txt")
|
||||
@@ -812,7 +800,7 @@ describe("file/index Filesystem patterns", () => {
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const filepath = path.join(test.directory, "clean.txt")
|
||||
yield* Effect.promise(() => fs.writeFile(filepath, "unchanged\n", "utf-8"))
|
||||
yield* AppFileSystem.use.writeWithDirs(filepath, "unchanged\n")
|
||||
yield* gitAddAll(test.directory)
|
||||
yield* gitCommit(test.directory, "add file")
|
||||
|
||||
@@ -832,14 +820,14 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const one = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(one.directory, "a.ts"), "one", "utf-8"))
|
||||
yield* writeFixtureFile(one.directory, "a.ts", "one")
|
||||
yield* init()
|
||||
expect(yield* search({ query: "a.ts", type: "file" })).toContain("a.ts")
|
||||
expect(yield* search({ query: "b.ts", type: "file" })).not.toContain("b.ts")
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const two = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(two.directory, "b.ts"), "two", "utf-8"))
|
||||
yield* writeFixtureFile(two.directory, "b.ts", "two")
|
||||
yield* init()
|
||||
expect(yield* search({ query: "b.ts", type: "file" })).toContain("b.ts")
|
||||
expect(yield* search({ query: "a.ts", type: "file" })).not.toContain("a.ts")
|
||||
@@ -853,14 +841,14 @@ describe("file/index Filesystem patterns", () => {
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "before.ts"), "before", "utf-8"))
|
||||
yield* writeFixtureFile(test.directory, "before.ts", "before")
|
||||
yield* init()
|
||||
expect(yield* search({ query: "before", type: "file" })).toContain("before.ts")
|
||||
|
||||
yield* Effect.promise(() => disposeAllInstances())
|
||||
|
||||
yield* Effect.promise(() => fs.writeFile(path.join(test.directory, "after.ts"), "after", "utf-8"))
|
||||
yield* Effect.promise(() => fs.rm(path.join(test.directory, "before.ts")))
|
||||
yield* writeFixtureFile(test.directory, "after.ts", "after")
|
||||
yield* removeFixtureFile(test.directory, "before.ts")
|
||||
|
||||
yield* init()
|
||||
expect(yield* search({ query: "after", type: "file" })).toContain("after.ts")
|
||||
|
||||
+5
-5
File diff suppressed because one or more lines are too long
+5
-5
File diff suppressed because one or more lines are too long
@@ -1,11 +1,16 @@
|
||||
import { beforeEach, describe, expect, test } from "bun:test"
|
||||
import { beforeEach, describe, expect } from "bun:test"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import path from "path"
|
||||
import { pathToFileURL } from "url"
|
||||
import { tmpdir, withTestInstance } from "../fixture/fixture"
|
||||
import { Effect } from "effect"
|
||||
import { pollWithTimeout, testEffect } from "../lib/effect"
|
||||
import { requireInstance, TestInstance } from "../fixture/fixture"
|
||||
import { LSPClient } from "@/lsp/client"
|
||||
import * as LSPServer from "@/lsp/server"
|
||||
import * as Log from "@opencode-ai/core/util/log"
|
||||
|
||||
const it = testEffect(AppFileSystem.defaultLayer)
|
||||
|
||||
function spawnFakeServer() {
|
||||
const { spawn } = require("child_process")
|
||||
const serverPath = path.join(__dirname, "../fixture/lsp/fake-lsp-server.js")
|
||||
@@ -16,202 +21,164 @@ function spawnFakeServer() {
|
||||
}
|
||||
}
|
||||
|
||||
const createClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const instance = yield* requireInstance
|
||||
return yield* Effect.promise(() =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: initialization ? { ...handle, initialization } : handle,
|
||||
root: test.directory,
|
||||
directory: test.directory,
|
||||
instance,
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
const createScopedClient = (handle: LSPServer.Handle, initialization?: LSPServer.Handle["initialization"]) =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createClient(handle, initialization)
|
||||
yield* Effect.addFinalizer(() => Effect.promise(() => client.shutdown()).pipe(Effect.ignore))
|
||||
return client
|
||||
})
|
||||
|
||||
const writeFile = (file: string, content: string) => AppFileSystem.use.writeWithDirs(file, content)
|
||||
|
||||
describe("LSPClient interop", () => {
|
||||
beforeEach(async () => {
|
||||
await Log.init({ print: true })
|
||||
})
|
||||
|
||||
test("handles workspace/workspaceFolders request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
it.instance("handles workspace/workspaceFolders request", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/trigger", {
|
||||
method: "workspace/workspaceFolders",
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "workspace/workspaceFolders",
|
||||
})
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(client.connection).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
it.instance("handles client/registerCapability request", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
test("handles client/registerCapability request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/trigger", {
|
||||
method: "client/registerCapability",
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "client/registerCapability",
|
||||
})
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(client.connection).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
it.instance("handles client/unregisterCapability request", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
test("handles client/unregisterCapability request", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/trigger", {
|
||||
method: "client/unregisterCapability",
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
await client.connection.sendNotification("test/trigger", {
|
||||
method: "client/unregisterCapability",
|
||||
})
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(client.connection).toBeDefined()
|
||||
}),
|
||||
)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
expect(client.connection).toBeDefined()
|
||||
await client.shutdown()
|
||||
})
|
||||
it.instance("initialize does not overclaim unsupported diagnostics capabilities", () =>
|
||||
Effect.gen(function* () {
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
test("initialize does not overclaim unsupported diagnostics capabilities", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
const params = yield* Effect.promise(() =>
|
||||
client.connection.sendRequest<{
|
||||
capabilities: {
|
||||
workspace: { diagnostics: { refreshSupport: boolean } }
|
||||
textDocument: { publishDiagnostics: { versionSupport: boolean } }
|
||||
}
|
||||
}>("test/get-initialize-params", {}),
|
||||
)
|
||||
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
|
||||
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
it.instance("workspace/configuration returns one result per requested item", () =>
|
||||
Effect.gen(function* () {
|
||||
const initialization = {
|
||||
alpha: {
|
||||
beta: 1,
|
||||
},
|
||||
gamma: true,
|
||||
}
|
||||
|
||||
const client = yield* createScopedClient(spawnFakeServer(), initialization)
|
||||
|
||||
const response = yield* Effect.promise(() =>
|
||||
client.connection.sendRequest<unknown[]>("test/request-configuration", {
|
||||
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
|
||||
}),
|
||||
})
|
||||
)
|
||||
|
||||
const params = await client.connection.sendRequest<any>("test/get-initialize-params", {})
|
||||
expect(params.capabilities.workspace.diagnostics.refreshSupport).toBe(false)
|
||||
expect(params.capabilities.textDocument.publishDiagnostics.versionSupport).toBe(false)
|
||||
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
|
||||
}),
|
||||
)
|
||||
|
||||
await client.shutdown()
|
||||
})
|
||||
it.instance("sends ranged didChange for incremental sync servers", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.ts")
|
||||
yield* writeFile(file, "first\n")
|
||||
|
||||
test("workspace/configuration returns one result per requested item", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
const initialization = {
|
||||
alpha: {
|
||||
beta: 1,
|
||||
},
|
||||
gamma: true,
|
||||
}
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
const client = await withTestInstance({
|
||||
directory: process.cwd(),
|
||||
fn: (ctx) =>
|
||||
LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: {
|
||||
...(handle as unknown as LSPServer.Handle),
|
||||
initialization,
|
||||
},
|
||||
root: process.cwd(),
|
||||
directory: process.cwd(),
|
||||
instance: ctx,
|
||||
}),
|
||||
})
|
||||
yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* writeFile(file, "second\nthird\n")
|
||||
yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
|
||||
const response = await client.connection.sendRequest<any[]>("test/request-configuration", {
|
||||
items: [{ section: "alpha" }, { section: "alpha.beta" }, { section: "missing" }, {}],
|
||||
})
|
||||
|
||||
expect(response).toEqual([{ beta: 1 }, 1, null, initialization])
|
||||
|
||||
await client.shutdown()
|
||||
})
|
||||
|
||||
test("sends ranged didChange for incremental sync servers", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "first\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.notify.open({ path: file })
|
||||
await Bun.write(file, "second\nthird\n")
|
||||
await client.notify.open({ path: file })
|
||||
|
||||
const change = await client.connection.sendRequest<{
|
||||
const change = yield* Effect.promise(() =>
|
||||
client.connection.sendRequest<{
|
||||
textDocument: { version: number }
|
||||
contentChanges: {
|
||||
range?: { start: { line: number; character: number }; end: { line: number; character: number } }
|
||||
text: string
|
||||
}[]
|
||||
}>("test/get-last-change", {})
|
||||
expect(change.textDocument.version).toBe(1)
|
||||
expect(change.contentChanges).toEqual([
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 1, character: 0 },
|
||||
},
|
||||
text: "second\nthird\n",
|
||||
}>("test/get-last-change", {}),
|
||||
)
|
||||
expect(change.textDocument.version).toBe(1)
|
||||
expect(change.contentChanges).toEqual([
|
||||
{
|
||||
range: {
|
||||
start: { line: 0, character: 0 },
|
||||
end: { line: 1, character: 0 },
|
||||
},
|
||||
])
|
||||
text: "second\nthird\n",
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("document mode falls back to push diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.ts")
|
||||
yield* writeFile(file, "const x = 1\n")
|
||||
|
||||
test("document mode falls back to push diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "const x = 1\n")
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
await client.connection.sendNotification("test/publish-diagnostics", {
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
const wait = client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/publish-diagnostics", {
|
||||
uri: pathToFileURL(file).href,
|
||||
version,
|
||||
diagnostics: [
|
||||
@@ -224,40 +191,30 @@ describe("LSPClient interop", () => {
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
await wait
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() => wait)
|
||||
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("push diagnostic")
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("push diagnostic")
|
||||
|
||||
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
|
||||
expect(count).toBe(0)
|
||||
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(count).toBe(0)
|
||||
}),
|
||||
)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("document mode accepts matching push diagnostics published before waiting", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.ts")
|
||||
yield* writeFile(file, "const x = 1\n")
|
||||
|
||||
test("document mode accepts matching push diagnostics published before waiting", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.ts")
|
||||
await Bun.write(file, "const x = 1\n")
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.connection.sendNotification("test/publish-diagnostics", {
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendNotification("test/publish-diagnostics", {
|
||||
uri: pathToFileURL(file).href,
|
||||
version,
|
||||
diagnostics: [
|
||||
@@ -270,41 +227,31 @@ describe("LSPClient interop", () => {
|
||||
severity: 1,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
for (let i = 0; i < 20 && (client.diagnostics.get(file)?.length ?? 0) === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25))
|
||||
}
|
||||
const diagnostic = yield* pollWithTimeout(
|
||||
Effect.sync(() => client.diagnostics.get(file)?.[0]),
|
||||
"push diagnostic was not published",
|
||||
)
|
||||
expect(diagnostic.message).toBe("push diagnostic")
|
||||
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("push diagnostic")
|
||||
const started = Date.now()
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
}),
|
||||
)
|
||||
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
it.instance("document mode waits for pull diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
test("document mode waits for pull diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [{ identifier: "DocumentCompilerSemantic" }],
|
||||
documentDiagnosticsByIdentifier: {
|
||||
@@ -319,41 +266,31 @@ describe("LSPClient interop", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
|
||||
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("pull diagnostic")
|
||||
const diagnostics = client.diagnostics.get(file) ?? []
|
||||
expect(diagnostics).toHaveLength(1)
|
||||
expect(diagnostics[0]?.message).toBe("pull diagnostic")
|
||||
|
||||
const count = await client.connection.sendRequest("test/get-diagnostic-request-count", {})
|
||||
expect(count).toBeGreaterThan(0)
|
||||
const count = yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {}))
|
||||
expect(count).toBeGreaterThan(0)
|
||||
}),
|
||||
)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
|
||||
test("document mode does not wait for the slowest pull identifier after current-file diagnostics arrive", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registrations: [{ identifier: "fast" }, { identifier: "slow" }],
|
||||
documentDiagnosticsByIdentifier: {
|
||||
fast: [
|
||||
@@ -371,43 +308,34 @@ describe("LSPClient interop", () => {
|
||||
documentDelayMsByIdentifier: {
|
||||
slow: 2_500,
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.connection.sendRequest("test/register-configured-pull-diagnostics", {})
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "document" })
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/register-configured-pull-diagnostics", {}))
|
||||
const started = Date.now()
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "document" }))
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
|
||||
expect(await client.connection.sendRequest("test/get-diagnostic-request-count", {})).toBeGreaterThan(1)
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("fast diagnostic")
|
||||
expect(
|
||||
yield* Effect.promise(() => client.connection.sendRequest("test/get-diagnostic-request-count", {})),
|
||||
).toBeGreaterThan(1)
|
||||
}),
|
||||
)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("full mode includes workspace pull diagnostics", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
const related = path.join(test.directory, "other.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
yield* writeFile(related, "class D {}\n")
|
||||
|
||||
test("full mode includes workspace pull diagnostics", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
const related = path.join(tmp.path, "other.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
await Bun.write(related, "class D {}\n")
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [
|
||||
{ identifier: "DocumentCompilerSemantic" },
|
||||
@@ -442,52 +370,40 @@ describe("LSPClient interop", () => {
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "full" })
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
|
||||
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
|
||||
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
|
||||
expect(client.diagnostics.get(file)?.[0]?.message).toBe("current file")
|
||||
expect(client.diagnostics.get(related)?.[0]?.message).toBe("workspace file")
|
||||
}),
|
||||
)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
it.instance("full mode treats an empty workspace pull response as handled", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const file = path.join(test.directory, "client.cs")
|
||||
yield* writeFile(file, "class C {}\n")
|
||||
|
||||
test("full mode treats an empty workspace pull response as handled", async () => {
|
||||
const handle = spawnFakeServer() as any
|
||||
await using tmp = await tmpdir()
|
||||
const file = path.join(tmp.path, "client.cs")
|
||||
await Bun.write(file, "class C {}\n")
|
||||
const client = yield* createScopedClient(spawnFakeServer())
|
||||
|
||||
await withTestInstance({
|
||||
directory: tmp.path,
|
||||
fn: async (ctx) => {
|
||||
const client = await LSPClient.create({
|
||||
serverID: "fake",
|
||||
server: handle as unknown as LSPServer.Handle,
|
||||
root: tmp.path,
|
||||
directory: tmp.path,
|
||||
instance: ctx,
|
||||
})
|
||||
|
||||
await client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
yield* Effect.promise(() =>
|
||||
client.connection.sendRequest("test/configure-pull-diagnostics", {
|
||||
registerOn: "didOpen",
|
||||
registrations: [{ identifier: "WorkspaceDocumentsAndProject", workspaceDiagnostics: true }],
|
||||
workspaceDiagnosticsByIdentifier: {
|
||||
WorkspaceDocumentsAndProject: [],
|
||||
},
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const version = await client.notify.open({ path: file })
|
||||
const started = Date.now()
|
||||
await client.waitForDiagnostics({ path: file, version, mode: "full" })
|
||||
const version = yield* Effect.promise(() => client.notify.open({ path: file }))
|
||||
const started = Date.now()
|
||||
yield* Effect.promise(() => client.waitForDiagnostics({ path: file, version, mode: "full" }))
|
||||
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
|
||||
await client.shutdown()
|
||||
},
|
||||
})
|
||||
})
|
||||
expect(Date.now() - started).toBeLessThan(1_000)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -30,16 +30,9 @@ void Log.init({ print: false })
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer)
|
||||
const layer = Layer.mergeAll(Project.defaultLayer, CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer)
|
||||
const it = testEffect(layer)
|
||||
|
||||
function run<A, E>(fn: (svc: Project.Interface) => Effect.Effect<A, E>) {
|
||||
return Effect.gen(function* () {
|
||||
const svc = yield* Project.Service
|
||||
return yield* fn(svc)
|
||||
})
|
||||
}
|
||||
|
||||
function remoteProjectID(remote: string) {
|
||||
return ProjectID.make(Hash.fast(`git-remote:${remote}`))
|
||||
}
|
||||
@@ -103,10 +96,18 @@ function projectLayerWithRuntimeFlags(flags: Parameters<typeof RuntimeFlags.laye
|
||||
}
|
||||
|
||||
const failureIt = (failArg: string) =>
|
||||
testEffect(Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer))
|
||||
testEffect(
|
||||
Layer.mergeAll(projectLayerWithFailure(failArg), CrossSpawnSpawner.defaultLayer, AppFileSystem.defaultLayer),
|
||||
)
|
||||
|
||||
const iconDiscoveryIt = testEffect(
|
||||
Layer.provideMerge(projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }), CrossSpawnSpawner.defaultLayer),
|
||||
Layer.mergeAll(
|
||||
Layer.provideMerge(
|
||||
projectLayerWithRuntimeFlags({ experimentalIconDiscovery: true }),
|
||||
CrossSpawnSpawner.defaultLayer,
|
||||
),
|
||||
AppFileSystem.defaultLayer,
|
||||
),
|
||||
)
|
||||
|
||||
function waitForProjectIcon(id: ProjectID, attempts = 50): Effect.Effect<Project.Info> {
|
||||
@@ -125,7 +126,7 @@ describe("Project.fromDirectory", () => {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
@@ -133,7 +134,7 @@ describe("Project.fromDirectory", () => {
|
||||
expect(project.worktree).toBe(tmp)
|
||||
|
||||
const opencodeFile = path.join(tmp, ".git", "opencode")
|
||||
expect(yield* Effect.promise(() => Bun.file(opencodeFile).exists())).toBe(false)
|
||||
expect(yield* AppFileSystem.use.existsSafe(opencodeFile)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -141,7 +142,7 @@ describe("Project.fromDirectory", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project).toBeDefined()
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
@@ -153,7 +154,7 @@ describe("Project.fromDirectory", () => {
|
||||
it.live("returns global for non-git directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped()
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
}),
|
||||
)
|
||||
@@ -161,8 +162,8 @@ describe("Project.fromDirectory", () => {
|
||||
it.live("derives stable project ID from root commit", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: a } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project: b } = yield* Project.use.fromDirectory(tmp)
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
)
|
||||
@@ -172,7 +173,7 @@ describe("Project.fromDirectory", () => {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:Test-Org/Test-Repo.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(remoteProjectID("github.com/Test-Org/Test-Repo"))
|
||||
}),
|
||||
@@ -185,8 +186,8 @@ describe("Project.fromDirectory", () => {
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:owner/repo.git`.cwd(ssh).quiet())
|
||||
yield* Effect.promise(() => $`git remote add origin https://github.com/owner/repo.git`.cwd(https).quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(ssh))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(https))
|
||||
const { project: a } = yield* Project.use.fromDirectory(ssh)
|
||||
const { project: b } = yield* Project.use.fromDirectory(https)
|
||||
|
||||
expect(a.id).toBe(remoteProjectID("github.com/owner/repo"))
|
||||
expect(b.id).toBe(a.id)
|
||||
@@ -196,8 +197,7 @@ describe("Project.fromDirectory", () => {
|
||||
it.live("migrates cached root project data when origin becomes available", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const projects = yield* Project.Service
|
||||
const { project: rootProject } = yield* projects.fromDirectory(tmp)
|
||||
const { project: rootProject } = yield* Project.use.fromDirectory(tmp)
|
||||
const remoteID = remoteProjectID("github.com/acme/app")
|
||||
const sessionID = crypto.randomUUID() as SessionID
|
||||
const workspaceID = WorkspaceID.ascending()
|
||||
@@ -236,7 +236,7 @@ describe("Project.fromDirectory", () => {
|
||||
})
|
||||
yield* Effect.promise(() => $`git remote add origin git@github.com:acme/app.git`.cwd(tmp).quiet())
|
||||
|
||||
const { project } = yield* projects.fromDirectory(tmp)
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project.id).toBe(remoteID)
|
||||
expect(
|
||||
@@ -263,7 +263,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
yield* Effect.promise(() => $`git init`.cwd(tmp).quiet())
|
||||
|
||||
// rev-list fails because HEAD doesn't exist yet: this is the natural scenario.
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
expect(project.vcs).toBe("git")
|
||||
expect(project.id).toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(tmp)
|
||||
@@ -274,7 +274,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
}),
|
||||
@@ -284,7 +284,7 @@ describe("Project.fromDirectory git failure paths", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
}),
|
||||
@@ -296,7 +296,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project.worktree).toBe(tmp)
|
||||
expect(sandbox).toBe(tmp)
|
||||
@@ -319,7 +319,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b test-branch-${Date.now()}`.cwd(tmp).quiet())
|
||||
|
||||
const { project, sandbox } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const { project, sandbox } = yield* Project.use.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
expect(sandbox).toBe(worktreePath)
|
||||
@@ -332,7 +332,7 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
|
||||
const { project: main } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: main } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const worktreePath = path.join(tmp, "..", path.basename(tmp) + "-wt-shared")
|
||||
yield* Effect.addFinalizer(() =>
|
||||
@@ -345,12 +345,12 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
)
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} -b shared-${Date.now()}`.cwd(tmp).quiet())
|
||||
|
||||
const { project: wt } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const { project: wt } = yield* Project.use.fromDirectory(worktreePath)
|
||||
|
||||
expect(wt.id).toBe(main.id)
|
||||
|
||||
const cache = path.join(tmp, ".git", "opencode")
|
||||
const exists = yield* Effect.promise(() => Bun.file(cache).exists())
|
||||
const exists = yield* AppFileSystem.use.existsSafe(cache)
|
||||
expect(exists).toBe(true)
|
||||
}),
|
||||
)
|
||||
@@ -368,8 +368,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${bare}`.quiet())
|
||||
yield* Effect.promise(() => $`git clone ${bare} ${clone}`.quiet())
|
||||
|
||||
const { project: a } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project: b } = yield* run((svc) => svc.fromDirectory(clone))
|
||||
const { project: a } = yield* Project.use.fromDirectory(tmp)
|
||||
const { project: b } = yield* Project.use.fromDirectory(clone)
|
||||
|
||||
expect(b.id).toBe(a.id)
|
||||
}),
|
||||
@@ -400,8 +400,8 @@ describe("Project.fromDirectory with worktrees", () => {
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree1} -b branch-${Date.now()}`.cwd(tmp).quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktree2} -b branch-${Date.now() + 1}`.cwd(tmp).quiet())
|
||||
|
||||
yield* run((svc) => svc.fromDirectory(worktree1))
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktree2))
|
||||
yield* Project.use.fromDirectory(worktree1)
|
||||
const { project } = yield* Project.use.fromDirectory(worktree2)
|
||||
|
||||
expect(project.worktree).toBe(worktree1)
|
||||
expect(project.sandboxes).toContain(worktree2)
|
||||
@@ -415,9 +415,9 @@ describe("Project.discover", () => {
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const updated = yield* waitForProjectIcon(project.id)
|
||||
|
||||
expect(updated.icon?.url).toStartWith("data:")
|
||||
@@ -428,12 +428,12 @@ describe("Project.discover", () => {
|
||||
it.live("should discover favicon.png in root", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
|
||||
|
||||
yield* run((svc) => svc.discover(project))
|
||||
yield* Project.use.discover(project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -447,11 +447,11 @@ describe("Project.discover", () => {
|
||||
it.live("should not discover non-image files", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.txt"), "not an image"))
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.txt"), "not an image")
|
||||
|
||||
yield* run((svc) => svc.discover(project))
|
||||
yield* Project.use.discover(project)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -462,22 +462,20 @@ describe("Project.discover", () => {
|
||||
it.live("should not discover favicon when override is set", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,override" },
|
||||
}),
|
||||
)
|
||||
yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,override" },
|
||||
})
|
||||
|
||||
const updatedProject = yield* run((svc) => svc.get(project.id))
|
||||
const updatedProject = yield* Project.use.get(project.id)
|
||||
if (!updatedProject) throw new Error("Project not found")
|
||||
|
||||
const pngData = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
yield* Effect.promise(() => Bun.write(path.join(tmp, "favicon.png"), pngData))
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(tmp, "favicon.png"), pngData)
|
||||
|
||||
yield* run((svc) => svc.discover(updatedProject))
|
||||
yield* Project.use.discover(updatedProject)
|
||||
|
||||
const updated = Project.get(project.id)
|
||||
expect(updated).toBeDefined()
|
||||
@@ -491,14 +489,12 @@ describe("Project.update", () => {
|
||||
it.live("should update name", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "New Project Name",
|
||||
}),
|
||||
)
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
name: "New Project Name",
|
||||
})
|
||||
|
||||
expect(updated.name).toBe("New Project Name")
|
||||
|
||||
@@ -510,14 +506,12 @@ describe("Project.update", () => {
|
||||
it.live("should update icon url", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { url: "https://example.com/icon.png" },
|
||||
})
|
||||
|
||||
expect(updated.icon?.url).toBe("https://example.com/icon.png")
|
||||
|
||||
@@ -529,14 +523,12 @@ describe("Project.update", () => {
|
||||
it.live("should update icon color", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { color: "#ff0000" },
|
||||
})
|
||||
|
||||
expect(updated.icon?.color).toBe("#ff0000")
|
||||
|
||||
@@ -548,14 +540,12 @@ describe("Project.update", () => {
|
||||
it.live("should update icon override", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,abc123" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
icon: { override: "data:image/png;base64,abc123" },
|
||||
})
|
||||
|
||||
expect(updated.icon?.override).toBe("data:image/png;base64,abc123")
|
||||
|
||||
@@ -567,14 +557,12 @@ describe("Project.update", () => {
|
||||
it.live("should update commands", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
commands: { start: "npm run dev" },
|
||||
})
|
||||
|
||||
expect(updated.commands?.start).toBe("npm run dev")
|
||||
|
||||
@@ -585,12 +573,12 @@ describe("Project.update", () => {
|
||||
|
||||
it.live("should fail when project not found", () =>
|
||||
Effect.gen(function* () {
|
||||
const exit = yield* run((svc) =>
|
||||
svc.update({
|
||||
const exit = yield* Project.use
|
||||
.update({
|
||||
projectID: ProjectID.make("nonexistent-project-id"),
|
||||
name: "Should Fail",
|
||||
}),
|
||||
).pipe(Effect.exit)
|
||||
})
|
||||
.pipe(Effect.exit)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const error = Cause.squash(exit.cause)
|
||||
@@ -602,7 +590,7 @@ describe("Project.update", () => {
|
||||
it.live("should emit GlobalBus event on update", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
let eventPayload: any = null
|
||||
const on = (data: any) => {
|
||||
@@ -611,7 +599,7 @@ describe("Project.update", () => {
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
yield* run((svc) => svc.update({ projectID: project.id, name: "Updated Name" }))
|
||||
yield* Project.use.update({ projectID: project.id, name: "Updated Name" })
|
||||
|
||||
expect(eventPayload).not.toBeNull()
|
||||
expect(eventPayload.payload.type).toBe("project.updated")
|
||||
@@ -622,16 +610,14 @@ describe("Project.update", () => {
|
||||
it.live("should update multiple fields at once", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const updated = yield* run((svc) =>
|
||||
svc.update({
|
||||
projectID: project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
}),
|
||||
)
|
||||
const updated = yield* Project.use.update({
|
||||
projectID: project.id,
|
||||
name: "Multi Update",
|
||||
icon: { url: "https://example.com/favicon.ico", override: "data:image/png;base64,abc123", color: "#00ff00" },
|
||||
commands: { start: "make start" },
|
||||
})
|
||||
|
||||
expect(updated.name).toBe("Multi Update")
|
||||
expect(updated.icon?.url).toBe("https://example.com/favicon.ico")
|
||||
@@ -646,7 +632,7 @@ describe("Project.list and Project.get", () => {
|
||||
it.live("list returns all projects", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const all = Project.list()
|
||||
expect(all.length).toBeGreaterThan(0)
|
||||
@@ -657,7 +643,7 @@ describe("Project.list and Project.get", () => {
|
||||
it.live("get returns project by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
const found = Project.get(project.id)
|
||||
expect(found).toBeDefined()
|
||||
@@ -675,7 +661,7 @@ describe("Project.setInitialized", () => {
|
||||
it.live("sets time_initialized on project", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
|
||||
expect(project.time.initialized).toBeUndefined()
|
||||
|
||||
@@ -691,15 +677,15 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
it.live("addSandbox adds directory and removeSandbox removes it", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const sandboxDir = path.join(tmp, "sandbox-test")
|
||||
|
||||
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
yield* Project.use.addSandbox(project.id, sandboxDir)
|
||||
|
||||
let found = Project.get(project.id)
|
||||
expect(found?.sandboxes).toContain(sandboxDir)
|
||||
|
||||
yield* run((svc) => svc.removeSandbox(project.id, sandboxDir))
|
||||
yield* Project.use.removeSandbox(project.id, sandboxDir)
|
||||
|
||||
found = Project.get(project.id)
|
||||
expect(found?.sandboxes).not.toContain(sandboxDir)
|
||||
@@ -709,7 +695,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
it.live("addSandbox emits GlobalBus event", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ git: true })
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(tmp))
|
||||
const { project } = yield* Project.use.fromDirectory(tmp)
|
||||
const sandboxDir = path.join(tmp, "sandbox-event")
|
||||
|
||||
const events: any[] = []
|
||||
@@ -717,7 +703,7 @@ describe("Project.addSandbox and Project.removeSandbox", () => {
|
||||
GlobalBus.on("event", on)
|
||||
yield* Effect.addFinalizer(() => Effect.sync(() => GlobalBus.off("event", on)))
|
||||
|
||||
yield* run((svc) => svc.addSandbox(project.id, sandboxDir))
|
||||
yield* Project.use.addSandbox(project.id, sandboxDir)
|
||||
|
||||
expect(events.some((e) => e.payload.type === Project.Event.Updated.type)).toBe(true)
|
||||
}),
|
||||
@@ -739,7 +725,7 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const { project } = yield* Project.use.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
@@ -747,8 +733,8 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
const wrongCache = path.join(parentDir, ".git", "opencode")
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
|
||||
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
|
||||
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -773,8 +759,8 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreeA} HEAD`.cwd(bareA).quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreeB} HEAD`.cwd(bareB).quiet())
|
||||
|
||||
const { project: projA } = yield* run((svc) => svc.fromDirectory(worktreeA))
|
||||
const { project: projB } = yield* run((svc) => svc.fromDirectory(worktreeB))
|
||||
const { project: projA } = yield* Project.use.fromDirectory(worktreeA)
|
||||
const { project: projB } = yield* Project.use.fromDirectory(worktreeB)
|
||||
|
||||
expect(projA.id).not.toBe(projB.id)
|
||||
|
||||
@@ -782,9 +768,9 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
const cacheB = path.join(bareB, "opencode")
|
||||
const wrongCache = path.join(parentDir, ".git", "opencode")
|
||||
|
||||
expect(yield* Effect.promise(() => Bun.file(cacheA).exists())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(cacheB).exists())).toBe(true)
|
||||
expect(yield* Effect.promise(() => Bun.file(wrongCache).exists())).toBe(false)
|
||||
expect(yield* AppFileSystem.use.existsSafe(cacheA)).toBe(true)
|
||||
expect(yield* AppFileSystem.use.existsSafe(cacheB)).toBe(true)
|
||||
expect(yield* AppFileSystem.use.existsSafe(wrongCache)).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -802,13 +788,13 @@ describe("Project.fromDirectory with bare repos", () => {
|
||||
yield* Effect.promise(() => $`git clone --bare ${tmp} ${barePath}`.quiet())
|
||||
yield* Effect.promise(() => $`git worktree add ${worktreePath} HEAD`.cwd(barePath).quiet())
|
||||
|
||||
const { project } = yield* run((svc) => svc.fromDirectory(worktreePath))
|
||||
const { project } = yield* Project.use.fromDirectory(worktreePath)
|
||||
|
||||
expect(project.id).not.toBe(ProjectID.global)
|
||||
expect(project.worktree).toBe(worktreePath)
|
||||
|
||||
const correctCache = path.join(barePath, "opencode")
|
||||
expect(yield* Effect.promise(() => Bun.file(correctCache).exists())).toBe(true)
|
||||
expect(yield* AppFileSystem.use.existsSafe(correctCache)).toBe(true)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -271,7 +271,6 @@ describe("ProviderTransform.options - gpt-5 textVerbosity", () => {
|
||||
const model = createGpt5Model("gpt-5.2")
|
||||
const result = ProviderTransform.options({ model, sessionID, providerOptions: {} })
|
||||
expect(result.textVerbosity).toBe("low")
|
||||
expect(result.include).toEqual(["reasoning.encrypted_content"])
|
||||
})
|
||||
|
||||
test("gpt-5.1 should have textVerbosity set to low", () => {
|
||||
|
||||
@@ -336,25 +336,10 @@ const weatherTool = tool({
|
||||
})
|
||||
|
||||
const toolRoundtrip = (
|
||||
events: ReadonlyArray<LLMEvent>,
|
||||
call: { readonly id: string; readonly name: string; readonly input: unknown },
|
||||
result: JSONValue,
|
||||
): ModelMessage[] => [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
...events.filter(LLMEvent.is.reasoningEnd).map((part) => ({
|
||||
type: "reasoning" as const,
|
||||
text: events
|
||||
.filter(LLMEvent.is.reasoningDelta)
|
||||
.filter((event) => event.id === part.id)
|
||||
.map((event) => event.text)
|
||||
.join(""),
|
||||
providerMetadata: part.providerMetadata,
|
||||
})),
|
||||
{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "tool-call", toolCallId: call.id, toolName: call.name, input: call.input }] },
|
||||
{
|
||||
role: "tool",
|
||||
content: [
|
||||
@@ -410,7 +395,7 @@ const driveToolLoop = (scenario: RecordedScenario) =>
|
||||
|
||||
const turn2 = yield* collect({
|
||||
...base,
|
||||
messages: [userMessage, ...toolRoundtrip(turn1, toolCall!, WEATHER_RESULT)],
|
||||
messages: [userMessage, ...toolRoundtrip(toolCall!, WEATHER_RESULT)],
|
||||
})
|
||||
|
||||
expect(LLMResponse.text({ events: turn2 })).toMatch(/Paris is sunny/i)
|
||||
|
||||
@@ -591,7 +591,7 @@ describe("session.llm-native.request", () => {
|
||||
]),
|
||||
storedSession.user("Summarize it."),
|
||||
],
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
providerOptions: { openai: { store: false, includeEncryptedReasoning: true } },
|
||||
expectedBody: {
|
||||
input: [
|
||||
openAIResponses.user("What changed?"),
|
||||
@@ -608,45 +608,6 @@ describe("session.llm-native.request", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves empty encrypted OpenAI reasoning items before tool output", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("", {
|
||||
storedAs: "providerMetadata",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: "encrypted-state",
|
||||
}),
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: false, include: ["reasoning.encrypted_content"] } },
|
||||
expectedBody: {
|
||||
input: [{ type: "reasoning", id: "rs_1", summary: [], encrypted_content: "encrypted-state" }],
|
||||
include: ["reasoning.encrypted_content"],
|
||||
store: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored OpenAI reasoning items by id", () =>
|
||||
expectOpenAIResponsesRequest({
|
||||
history: [
|
||||
storedSession.assistant([
|
||||
storedSession.openaiReasoning("Checked the previous diff.", {
|
||||
storedAs: "providerMetadata",
|
||||
itemId: "rs_1",
|
||||
encryptedContent: null,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
providerOptions: { openai: { store: true } },
|
||||
expectedBody: {
|
||||
input: [{ type: "item_reference", id: "rs_1" }],
|
||||
store: true,
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("uses provider fetch override for native OpenAI OAuth requests", () =>
|
||||
Effect.gen(function* () {
|
||||
const captures: Array<{ url: string; body: unknown }> = []
|
||||
|
||||
@@ -1166,7 +1166,6 @@ describe("session.llm.stream", () => {
|
||||
expect(capture.body.model).toBe(model.id)
|
||||
expect(capture.body.stream).toBe(true)
|
||||
expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high")
|
||||
expect(capture.body.include).toEqual(["reasoning.encrypted_content"])
|
||||
expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.")
|
||||
expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] })
|
||||
}),
|
||||
|
||||
@@ -8,14 +8,13 @@ import { Config } from "../../src/config/config"
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Global } from "@opencode-ai/core/global"
|
||||
import { provideInstance, provideTmpdirInstance, tmpdir } from "../fixture/fixture"
|
||||
import { TestInstance } from "../fixture/fixture"
|
||||
import { testEffect } from "../lib/effect"
|
||||
import path from "path"
|
||||
import fs from "fs/promises"
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, node))
|
||||
const it = testEffect(Layer.mergeAll(Skill.defaultLayer, AppFileSystem.defaultLayer, node))
|
||||
const itWithoutClaudeCodeSkills = testEffect(
|
||||
Layer.mergeAll(
|
||||
Skill.layer.pipe(
|
||||
@@ -26,6 +25,7 @@ const itWithoutClaudeCodeSkills = testEffect(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableClaudeCodeSkills: true })),
|
||||
),
|
||||
AppFileSystem.defaultLayer,
|
||||
node,
|
||||
),
|
||||
)
|
||||
@@ -39,15 +39,18 @@ const itWithoutExternalSkills = testEffect(
|
||||
Layer.provide(Global.layer),
|
||||
Layer.provide(RuntimeFlags.layer({ disableExternalSkills: true })),
|
||||
),
|
||||
AppFileSystem.defaultLayer,
|
||||
node,
|
||||
),
|
||||
)
|
||||
|
||||
async function createGlobalSkill(homeDir: string) {
|
||||
const skillDir = path.join(homeDir, ".claude", "skills", "global-test-skill")
|
||||
await fs.mkdir(skillDir, { recursive: true })
|
||||
await Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
const writeSkill = (dir: string, parts: string[], content: string) =>
|
||||
AppFileSystem.use.writeWithDirs(path.join(dir, ...parts, "SKILL.md"), content)
|
||||
|
||||
const createGlobalSkill = (homeDir: string) =>
|
||||
writeSkill(
|
||||
homeDir,
|
||||
[".claude", "skills", "global-test-skill"],
|
||||
`---
|
||||
name: global-test-skill
|
||||
description: A global skill from ~/.claude/skills for testing.
|
||||
@@ -58,7 +61,6 @@ description: A global skill from ~/.claude/skills for testing.
|
||||
This skill is loaded from the global home directory.
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
||||
Effect.acquireUseRelease(
|
||||
@@ -75,14 +77,14 @@ const withHome = <A, E, R>(home: string, self: Effect.Effect<A, E, R>) =>
|
||||
)
|
||||
|
||||
describe("skill", () => {
|
||||
it.live("discovers skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "test-skill", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"discovers skills from .opencode/skill/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "test-skill"],
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill for verification.
|
||||
---
|
||||
@@ -91,118 +93,111 @@ description: A test skill for verification.
|
||||
|
||||
Instructions here.
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "test-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBe("A test skill for verification.")
|
||||
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "test-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBe("A test skill for verification.")
|
||||
expect(item!.location).toContain(path.join("skill", "test-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("returns skill directories from Skill.dirs", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
withHome(
|
||||
dir,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "dir-skill", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"returns skill directories from Skill.dirs",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* withHome(
|
||||
test.directory,
|
||||
Effect.gen(function* () {
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "dir-skill"],
|
||||
`---
|
||||
name: dir-skill
|
||||
description: Skill for dirs test.
|
||||
---
|
||||
|
||||
# Dir Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const dirs = yield* skill.dirs()
|
||||
expect(dirs).toContain(path.join(dir, ".opencode", "skill", "dir-skill"))
|
||||
expect(dirs.length).toBe(1)
|
||||
}),
|
||||
),
|
||||
{ git: true },
|
||||
),
|
||||
const dirs = yield* Skill.use.dirs()
|
||||
expect(dirs).toContain(path.join(test.directory, ".opencode", "skill", "dir-skill"))
|
||||
expect(dirs.length).toBe(1)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("discovers multiple skills from .opencode/skill/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "skill-one", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"discovers multiple skills from .opencode/skill/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "skill-one"],
|
||||
`---
|
||||
name: skill-one
|
||||
description: First test skill.
|
||||
---
|
||||
|
||||
# Skill One
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "skill-two", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "skill-two"],
|
||||
`---
|
||||
name: skill-two
|
||||
description: Second test skill.
|
||||
---
|
||||
|
||||
# Skill Two
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "skill-one")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "skill-two")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("skips skills with missing frontmatter", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "no-frontmatter", "SKILL.md"),
|
||||
`# No Frontmatter
|
||||
it.instance(
|
||||
"skips skills with missing frontmatter",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "no-frontmatter"],
|
||||
`# No Frontmatter
|
||||
|
||||
Just some content without YAML frontmatter.
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("discovers skills without descriptions", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "manual-skill", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"discovers skills without descriptions",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "manual-skill"],
|
||||
`---
|
||||
name: manual-skill
|
||||
---
|
||||
|
||||
@@ -210,98 +205,81 @@ name: manual-skill
|
||||
|
||||
Instructions here.
|
||||
`,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "manual-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBeUndefined()
|
||||
expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
|
||||
expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "manual-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.description).toBeUndefined()
|
||||
expect(Skill.fmt(list, { verbose: false })).toBe("No skills are currently available.")
|
||||
expect(Skill.fmt(list, { verbose: true })).toBe("No skills are currently available.")
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("discovers skills from .claude/skills/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"discovers skills from .claude/skills/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "claude-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers global skills from ~/.claude/skills/ directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "claude-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".claude", "skills", "claude-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers global skills from ~/.claude/skills/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* withHome(
|
||||
tmp.path,
|
||||
test.directory,
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() => createGlobalSkill(tmp.path))
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-test-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
|
||||
}).pipe(provideInstance(tmp.path))
|
||||
yield* createGlobalSkill(test.directory)
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-test-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.claude/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".claude", "skills", "global-test-skill", "SKILL.md"))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("returns empty array when no skills exist", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
expect((yield* skill.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
it.instance(
|
||||
"returns empty array when no skills exist",
|
||||
Effect.gen(function* () {
|
||||
expect((yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")).toEqual([])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("fails with typed error when requiring a missing skill", () =>
|
||||
provideTmpdirInstance(
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const error = yield* Effect.flip(skill.require("missing-skill"))
|
||||
expect(error).toBeInstanceOf(Skill.NotFoundError)
|
||||
expect(error._tag).toBe("Skill.NotFoundError")
|
||||
expect(error.name).toBe("missing-skill")
|
||||
expect(error.message).toContain('Skill "missing-skill" not found.')
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
it.instance(
|
||||
"fails with typed error when requiring a missing skill",
|
||||
Effect.gen(function* () {
|
||||
const error = yield* Effect.flip(Skill.use.require("missing-skill"))
|
||||
expect(error).toBeInstanceOf(Skill.NotFoundError)
|
||||
expect(error._tag).toBe("Skill.NotFoundError")
|
||||
expect(error.name).toBe("missing-skill")
|
||||
expect(error.message).toContain('Skill "missing-skill" not found.')
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.effect("exposes tagged expected skill failure classes", () =>
|
||||
@@ -320,50 +298,42 @@ description: A skill in the .claude/skills directory.
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("discovers skills from .agents/skills/ directory", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"discovers skills from .agents/skills/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "agent-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
)
|
||||
|
||||
it.live("discovers global skills from ~/.agents/skills/ directory", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* Effect.acquireRelease(
|
||||
Effect.promise(() => tmpdir({ git: true })),
|
||||
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
|
||||
)
|
||||
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
const item = list.find((x) => x.name === "agent-skill")
|
||||
expect(item).toBeDefined()
|
||||
expect(item!.location).toContain(path.join(".agents", "skills", "agent-skill", "SKILL.md"))
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.instance(
|
||||
"discovers global skills from ~/.agents/skills/ directory",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* withHome(
|
||||
tmp.path,
|
||||
test.directory,
|
||||
Effect.gen(function* () {
|
||||
const skillDir = path.join(tmp.path, ".agents", "skills", "global-agent-skill")
|
||||
yield* Effect.promise(() => fs.mkdir(skillDir, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
yield* writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "global-agent-skill"],
|
||||
`---
|
||||
name: global-agent-skill
|
||||
description: A global skill from ~/.agents/skills for testing.
|
||||
---
|
||||
@@ -372,198 +342,198 @@ description: A global skill from ~/.agents/skills for testing.
|
||||
|
||||
This skill is loaded from the global home directory.
|
||||
`,
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.gen(function* () {
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-agent-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
|
||||
}).pipe(provideInstance(tmp.path))
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(1)
|
||||
expect(list[0].name).toBe("global-agent-skill")
|
||||
expect(list[0].description).toBe("A global skill from ~/.agents/skills for testing.")
|
||||
expect(list[0].location).toContain(path.join(".agents", "skills", "global-agent-skill", "SKILL.md"))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("discovers skills from both .claude/skills/ and .agents/skills/", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"discovers skills from both .claude/skills/ and .agents/skills/",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.length).toBe(2)
|
||||
expect(list.find((x) => x.name === "claude-skill")).toBeDefined()
|
||||
expect(list.find((x) => x.name === "agent-skill")).toBeDefined()
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
itWithoutClaudeCodeSkills.live("skips Claude Code skills when disabled", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
itWithoutClaudeCodeSkills.instance(
|
||||
"skips Claude Code skills when disabled",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["agent-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["agent-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
itWithoutExternalSkills.live("skips external skill directories when disabled", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
itWithoutExternalSkills.instance(
|
||||
"skips external skill directories when disabled",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "opencode-skill", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "opencode-skill"],
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skill directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
const list = (yield* skill.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
const list = (yield* Skill.use.all()).filter((s) => s.location !== "<built-in>")
|
||||
expect(list.map((s) => s.name)).toEqual(["opencode-skill"])
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
|
||||
it.live("properly resolves directories that skills live in", () =>
|
||||
provideTmpdirInstance(
|
||||
(dir) =>
|
||||
Effect.gen(function* () {
|
||||
yield* Effect.promise(() =>
|
||||
Promise.all([
|
||||
Bun.write(
|
||||
path.join(dir, ".claude", "skills", "claude-skill", "SKILL.md"),
|
||||
`---
|
||||
it.instance(
|
||||
"properly resolves directories that skills live in",
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
yield* Effect.all(
|
||||
[
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".claude", "skills", "claude-skill"],
|
||||
`---
|
||||
name: claude-skill
|
||||
description: A skill in the .claude/skills directory.
|
||||
---
|
||||
|
||||
# Claude Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".agents", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".agents", "skills", "agent-skill"],
|
||||
`---
|
||||
name: agent-skill
|
||||
description: A skill in the .agents/skills directory.
|
||||
---
|
||||
|
||||
# Agent Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skill", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skill", "agent-skill"],
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skill directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
Bun.write(
|
||||
path.join(dir, ".opencode", "skills", "agent-skill", "SKILL.md"),
|
||||
`---
|
||||
),
|
||||
writeSkill(
|
||||
test.directory,
|
||||
[".opencode", "skills", "agent-skill"],
|
||||
`---
|
||||
name: opencode-skill
|
||||
description: A skill in the .opencode/skills directory.
|
||||
---
|
||||
|
||||
# OpenCode Skill
|
||||
`,
|
||||
),
|
||||
]),
|
||||
)
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
const skill = yield* Skill.Service
|
||||
expect((yield* skill.dirs()).length).toBe(4)
|
||||
}),
|
||||
{ git: true },
|
||||
),
|
||||
expect((yield* Skill.use.dirs()).length).toBe(4)
|
||||
}),
|
||||
{ git: true },
|
||||
)
|
||||
})
|
||||
|
||||
@@ -95,15 +95,25 @@ const brokenPluginLayer = Layer.succeed(
|
||||
}),
|
||||
)
|
||||
|
||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer))
|
||||
const it = testEffect(Layer.mergeAll(registryLayer(), node, Agent.defaultLayer, AppFileSystem.defaultLayer))
|
||||
const scout = testEffect(
|
||||
Layer.mergeAll(registryLayer({ flags: { experimentalScout: true } }), node, Agent.defaultLayer),
|
||||
Layer.mergeAll(
|
||||
registryLayer({ flags: { experimentalScout: true } }),
|
||||
node,
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
),
|
||||
)
|
||||
const background = testEffect(
|
||||
Layer.mergeAll(registryLayer({ flags: { experimentalBackgroundSubagents: true } }), node, Agent.defaultLayer),
|
||||
Layer.mergeAll(
|
||||
registryLayer({ flags: { experimentalBackgroundSubagents: true } }),
|
||||
node,
|
||||
Agent.defaultLayer,
|
||||
AppFileSystem.defaultLayer,
|
||||
),
|
||||
)
|
||||
const withBrokenPlugin = testEffect(
|
||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer),
|
||||
Layer.mergeAll(registryLayer({ plugin: brokenPluginLayer }), node, Agent.defaultLayer, AppFileSystem.defaultLayer),
|
||||
)
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -113,8 +123,7 @@ afterEach(async () => {
|
||||
describe("tool.registry", () => {
|
||||
it.instance("hides repo research tools unless experimental", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
|
||||
expect(ids).not.toContain("repo_clone")
|
||||
expect(ids).not.toContain("repo_overview")
|
||||
@@ -123,8 +132,7 @@ describe("tool.registry", () => {
|
||||
|
||||
scout.instance("shows repo research tools when experimental scout is enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
|
||||
expect(ids).toContain("repo_clone")
|
||||
expect(ids).toContain("repo_overview")
|
||||
@@ -133,8 +141,7 @@ describe("tool.registry", () => {
|
||||
|
||||
it.instance("hides task_status unless experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
|
||||
expect(ids).not.toContain("task_status")
|
||||
}),
|
||||
@@ -142,11 +149,10 @@ describe("tool.registry", () => {
|
||||
|
||||
it.instance("hides task background parameter unless experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = yield* Agent.Service
|
||||
const build = yield* agent.get("build")
|
||||
if (!build) throw new Error("build agent not found")
|
||||
const task = (yield* registry.tools({
|
||||
const task = (yield* ToolRegistry.use.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("test"),
|
||||
agent: build,
|
||||
@@ -159,8 +165,7 @@ describe("tool.registry", () => {
|
||||
|
||||
background.instance("shows task_status when experimental background subagents are enabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
|
||||
expect(ids).toContain("task_status")
|
||||
}),
|
||||
@@ -169,26 +174,20 @@ describe("tool.registry", () => {
|
||||
it.instance("loads tools from .opencode/tool (singular)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tool = path.join(opencode, "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tool", "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
)
|
||||
@@ -196,25 +195,20 @@ describe("tool.registry", () => {
|
||||
it.instance("ignores non-tool exports in .opencode/tool files", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const tool = path.join(test.directory, ".opencode", "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "mixed.ts"),
|
||||
[
|
||||
"export const helper = 'not a tool'",
|
||||
"export default {",
|
||||
" description: 'mixed tool',",
|
||||
" args: {},",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tool", "mixed.ts"),
|
||||
[
|
||||
"export const helper = 'not a tool'",
|
||||
"export default {",
|
||||
" description: 'mixed tool',",
|
||||
" args: {},",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
expect(ids).toContain("mixed")
|
||||
expect(ids).not.toContain("mixed_helper")
|
||||
}),
|
||||
@@ -229,28 +223,23 @@ describe("tool.registry", () => {
|
||||
it.instance("tolerates a custom tool exporting null/undefined args (no-args fallback)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const tool = path.join(test.directory, ".opencode", "tool")
|
||||
yield* Effect.promise(() => fs.mkdir(tool, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tool, "noargs.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'tool with no args',",
|
||||
" args: undefined,",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tool", "noargs.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'tool with no args',",
|
||||
" args: undefined,",
|
||||
" execute: async () => 'ok',",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
// Built-in tools must still load — a single malformed custom tool must
|
||||
// not poison the whole registry.
|
||||
expect(ids).toContain("read")
|
||||
const loaded = (yield* registry.all()).find((t) => t.id === "noargs")
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((t) => t.id === "noargs")
|
||||
if (!loaded) throw new Error("noargs tool was not loaded")
|
||||
expect(loaded.jsonSchema).toMatchObject({ type: "object", properties: {} })
|
||||
}),
|
||||
@@ -264,8 +253,7 @@ describe("tool.registry", () => {
|
||||
// protection.
|
||||
withBrokenPlugin.instance("tolerates a plugin tool registered with null/undefined args", () =>
|
||||
Effect.gen(function* () {
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
expect(ids).toContain("read")
|
||||
expect(ids).toContain("broken_plugin_tool")
|
||||
}),
|
||||
@@ -274,26 +262,20 @@ describe("tool.registry", () => {
|
||||
it.instance("loads tools from .opencode/tools (plural)", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "hello.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'hello tool',",
|
||||
" args: {},",
|
||||
" execute: async () => {",
|
||||
" return 'hello world'",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
expect(ids).toContain("hello")
|
||||
}),
|
||||
)
|
||||
@@ -301,26 +283,21 @@ describe("tool.registry", () => {
|
||||
it.instance("loads Zod-schema custom tools with JSON Schema and validation", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const customTools = path.join(test.directory, ".opencode", "tools")
|
||||
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "sql.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'query database',",
|
||||
" args: { query: tool.schema.string().describe('SQL query to execute') },",
|
||||
" execute: async ({ query }) => query,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "sql.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'query database',",
|
||||
" args: { query: tool.schema.string().describe('SQL query to execute') },",
|
||||
" execute: async ({ query }) => query,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "sql")
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "sql")
|
||||
if (!loaded) throw new Error("custom sql tool was not loaded")
|
||||
expect(loaded?.jsonSchema).toMatchObject({
|
||||
type: "object",
|
||||
@@ -333,7 +310,7 @@ describe("tool.registry", () => {
|
||||
expect(Result.isSuccess(Schema.decodeUnknownResult(loaded.parameters)({}))).toBe(false)
|
||||
|
||||
const agents = yield* Agent.Service
|
||||
const promptTools = yield* registry.tools({
|
||||
const promptTools = yield* ToolRegistry.use.tools({
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("test"),
|
||||
agent: yield* agents.defaultInfo(),
|
||||
@@ -357,53 +334,44 @@ describe("tool.registry", () => {
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const customTools = path.join(opencode, "tools")
|
||||
const plugin = path.join(opencode, "node_modules", "@opencode-ai", "plugin")
|
||||
yield* Effect.promise(() => fs.mkdir(path.join(plugin, "dist"), { recursive: true }))
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
fs.cp(path.dirname(fileURLToPath(import.meta.resolve("zod"))), path.join(opencode, "node_modules", "zod"), {
|
||||
dereference: true,
|
||||
recursive: true,
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(plugin, "package.json"),
|
||||
JSON.stringify({ name: "@opencode-ai/plugin", type: "module", exports: { ".": "./dist/index.js" } }),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(plugin, "dist", "index.js"),
|
||||
[
|
||||
"import { z } from 'zod'",
|
||||
"export function tool(input) {",
|
||||
" return input",
|
||||
"}",
|
||||
"tool.schema = z",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(plugin, "dist", "index.js"),
|
||||
[
|
||||
"import { z } from 'zod'",
|
||||
"export function tool(input) {",
|
||||
" return input",
|
||||
"}",
|
||||
"tool.schema = z",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "addition.ts"),
|
||||
[
|
||||
'import { tool } from "@opencode-ai/plugin"',
|
||||
"export default tool({",
|
||||
" description: 'Use this tool to add two numbers and return their sum.',",
|
||||
" args: {",
|
||||
" left: tool.schema.number().describe('The first number to add'),",
|
||||
" right: tool.schema.number().describe('The second number to add'),",
|
||||
" },",
|
||||
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(customTools, "addition.ts"),
|
||||
[
|
||||
'import { tool } from "@opencode-ai/plugin"',
|
||||
"export default tool({",
|
||||
" description: 'Use this tool to add two numbers and return their sum.',",
|
||||
" args: {",
|
||||
" left: tool.schema.number().describe('The first number to add'),",
|
||||
" right: tool.schema.number().describe('The second number to add'),",
|
||||
" },",
|
||||
" execute: async (args) => `${args.left} + ${args.right} = ${args.left + args.right}`,",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "addition")
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "addition")
|
||||
if (!loaded) throw new Error("custom addition tool was not loaded")
|
||||
|
||||
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
|
||||
@@ -419,29 +387,24 @@ describe("tool.registry", () => {
|
||||
it.instance("preserves attachments from structured custom tool results", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const customTools = path.join(test.directory, ".opencode", "tools")
|
||||
const pluginTool = pathToFileURL(path.resolve(import.meta.dir, "../../../plugin/src/tool.ts")).href
|
||||
yield* Effect.promise(() => fs.mkdir(customTools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(customTools, "image.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'image tool',",
|
||||
" args: {},",
|
||||
" execute: async () => ({",
|
||||
" output: 'here is an image',",
|
||||
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
|
||||
" }),",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "image.ts"),
|
||||
[
|
||||
`import { tool } from ${JSON.stringify(pluginTool)}`,
|
||||
"export default tool({",
|
||||
" description: 'image tool',",
|
||||
" args: {},",
|
||||
" execute: async () => ({",
|
||||
" output: 'here is an image',",
|
||||
" attachments: [{ type: 'file', mime: 'image/png', filename: 'picture.png', url: 'data:image/png;base64,AAAA' }],",
|
||||
" }),",
|
||||
"})",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "image")
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "image")
|
||||
if (!loaded) throw new Error("custom image tool was not loaded")
|
||||
const agents = yield* Agent.Service
|
||||
const result = yield* loaded.execute({}, {
|
||||
@@ -464,24 +427,19 @@ describe("tool.registry", () => {
|
||||
it.instance("loads legacy JSON-schema-shaped custom tools with wire schema", () =>
|
||||
Effect.gen(function* () {
|
||||
const test = yield* TestInstance
|
||||
const tools = path.join(test.directory, ".opencode", "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "legacy.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'legacy schema tool',",
|
||||
" args: { text: { type: 'string', description: 'Text to render' } },",
|
||||
" execute: async ({ text }) => text,",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(test.directory, ".opencode", "tools", "legacy.ts"),
|
||||
[
|
||||
"export default {",
|
||||
" description: 'legacy schema tool',",
|
||||
" args: { text: { type: 'string', description: 'Text to render' } },",
|
||||
" execute: async ({ text }) => text,",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const loaded = (yield* registry.all()).find((tool) => tool.id === "legacy")
|
||||
const loaded = (yield* ToolRegistry.use.all()).find((tool) => tool.id === "legacy")
|
||||
if (!loaded) throw new Error("legacy custom tool was not loaded")
|
||||
expect(ToolJsonSchema.fromTool(loaded)).toMatchObject({
|
||||
type: "object",
|
||||
@@ -498,73 +456,60 @@ describe("tool.registry", () => {
|
||||
const test = yield* TestInstance
|
||||
const opencode = path.join(test.directory, ".opencode")
|
||||
const tools = path.join(opencode, "tools")
|
||||
yield* Effect.promise(() => fs.mkdir(tools, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(opencode, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(opencode, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(opencode, "package-lock.json"),
|
||||
JSON.stringify({
|
||||
name: "custom-tools",
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
"": {
|
||||
dependencies: {
|
||||
"@opencode-ai/plugin": "^0.0.0",
|
||||
cowsay: "^1.6.0",
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const cowsay = path.join(opencode, "node_modules", "cowsay")
|
||||
yield* Effect.promise(() => fs.mkdir(cowsay, { recursive: true }))
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(cowsay, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "cowsay",
|
||||
type: "module",
|
||||
exports: "./index.js",
|
||||
}),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(cowsay, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(cowsay, "index.js"),
|
||||
["export function say({ text }) {", " return `moo ${text}`", "}", ""].join("\n"),
|
||||
)
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(tools, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(tools, "cowsay.ts"),
|
||||
[
|
||||
"import { say } from 'cowsay'",
|
||||
"export default {",
|
||||
" description: 'tool that imports cowsay at top level',",
|
||||
" args: { text: { type: 'string' } },",
|
||||
" execute: async ({ text }: { text: string }) => {",
|
||||
" return say({ text })",
|
||||
" },",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
)
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const ids = yield* registry.ids()
|
||||
const ids = yield* ToolRegistry.use.ids()
|
||||
expect(ids).toContain("cowsay")
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -1205,29 +1205,6 @@ describe("tool.shell truncation", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("does not truncate output when tool_output is disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const tmp = yield* tmpdirScoped({ config: { tool_output: false } })
|
||||
yield* runIn(
|
||||
tmp,
|
||||
Effect.gen(function* () {
|
||||
const bash = yield* initShell()
|
||||
expect(bash.description).not.toContain("If the output exceeds")
|
||||
const result = yield* bash.execute(
|
||||
{
|
||||
command: fill("bytes", Truncate.MAX_BYTES + 10000),
|
||||
description: "Generate bytes with truncation disabled",
|
||||
},
|
||||
ctx,
|
||||
)
|
||||
expect(result.metadata.truncated).toBe(false)
|
||||
expect(result.output).not.toContain("...output truncated...")
|
||||
expect(Buffer.byteLength(result.output, "utf-8")).toBeGreaterThan(Truncate.MAX_BYTES)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.live("full output is saved to file when truncated", () =>
|
||||
runIn(
|
||||
projectRoot,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Cause, Effect, Exit, Layer } from "effect"
|
||||
import { afterEach, describe, expect } from "bun:test"
|
||||
import path from "path"
|
||||
@@ -7,6 +8,7 @@ import type { Permission } from "../../src/permission"
|
||||
import type { Tool } from "@/tool/tool"
|
||||
import { SkillTool } from "../../src/tool/skill"
|
||||
import { ToolRegistry } from "@/tool/registry"
|
||||
import { ModelID, ProviderID } from "../../src/provider/schema"
|
||||
import { disposeAllInstances, provideTmpdirInstance } from "../fixture/fixture"
|
||||
import { SessionID, MessageID } from "../../src/session/schema"
|
||||
import { testEffect } from "../lib/effect"
|
||||
@@ -27,17 +29,16 @@ afterEach(async () => {
|
||||
|
||||
const node = CrossSpawnSpawner.defaultLayer
|
||||
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node))
|
||||
const it = testEffect(Layer.mergeAll(ToolRegistry.defaultLayer, node, AppFileSystem.defaultLayer))
|
||||
|
||||
describe("tool.skill", () => {
|
||||
it.live("execute returns skill content block with files", () =>
|
||||
provideTmpdirInstance((dir) =>
|
||||
Effect.gen(function* () {
|
||||
const skill = path.join(dir, ".opencode", "skill", "tool-skill")
|
||||
yield* Effect.promise(() =>
|
||||
Bun.write(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
yield* AppFileSystem.use.writeWithDirs(
|
||||
path.join(skill, "SKILL.md"),
|
||||
`---
|
||||
name: tool-skill
|
||||
description: Skill for tool tests.
|
||||
---
|
||||
@@ -46,9 +47,8 @@ description: Skill for tool tests.
|
||||
|
||||
Use this skill.
|
||||
`,
|
||||
),
|
||||
)
|
||||
yield* Effect.promise(() => Bun.write(path.join(skill, "scripts", "demo.txt"), "demo"))
|
||||
yield* AppFileSystem.use.writeWithDirs(path.join(skill, "scripts", "demo.txt"), "demo")
|
||||
|
||||
const home = process.env.OPENCODE_TEST_HOME
|
||||
process.env.OPENCODE_TEST_HOME = dir
|
||||
@@ -61,8 +61,8 @@ Use this skill.
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const tool = (yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("gpt-5"),
|
||||
agent,
|
||||
})).find((tool) => tool.id === SkillTool.id)
|
||||
if (!tool) throw new Error("Skill tool not found")
|
||||
@@ -105,8 +105,8 @@ Use this skill.
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const agent = { name: "build", mode: "primary" as const, permission: [], options: {} }
|
||||
const tool = (yield* registry.tools({
|
||||
providerID: "opencode" as any,
|
||||
modelID: "gpt-5" as any,
|
||||
providerID: ProviderID.opencode,
|
||||
modelID: ModelID.make("gpt-5"),
|
||||
agent,
|
||||
})).find((tool) => tool.id === SkillTool.id)
|
||||
if (!tool) throw new Error("Skill tool not found")
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { NodeFileSystem } from "@effect/platform-node"
|
||||
import { AppFileSystem } from "@opencode-ai/core/filesystem"
|
||||
import { Effect, FileSystem, Layer, Option } from "effect"
|
||||
import { Effect, FileSystem, Layer } from "effect"
|
||||
import { Truncate } from "@/tool/truncate"
|
||||
import { Config } from "@/config/config"
|
||||
import { Identifier } from "../../src/id/id"
|
||||
@@ -110,11 +110,8 @@ describe("Truncate", () => {
|
||||
Effect.gen(function* () {
|
||||
const svc = yield* Truncate.Service
|
||||
const resolved = yield* svc.limits()
|
||||
expect(Option.isSome(resolved)).toBe(true)
|
||||
if (Option.isSome(resolved)) {
|
||||
expect(resolved.value.maxLines).toBe(Truncate.MAX_LINES)
|
||||
expect(resolved.value.maxBytes).toBe(Truncate.MAX_BYTES)
|
||||
}
|
||||
expect(resolved.maxLines).toBe(Truncate.MAX_LINES)
|
||||
expect(resolved.maxBytes).toBe(Truncate.MAX_BYTES)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -123,11 +120,8 @@ describe("Truncate", () => {
|
||||
limitsIt.live("limits() reflects config overrides", () =>
|
||||
Effect.gen(function* () {
|
||||
const resolved = yield* (yield* Truncate.Service).limits()
|
||||
expect(Option.isSome(resolved)).toBe(true)
|
||||
if (Option.isSome(resolved)) {
|
||||
expect(resolved.value.maxLines).toBe(123)
|
||||
expect(resolved.value.maxBytes).toBe(456)
|
||||
}
|
||||
expect(resolved.maxLines).toBe(123)
|
||||
expect(resolved.maxBytes).toBe(456)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -165,18 +159,6 @@ describe("Truncate", () => {
|
||||
expect(result.truncated).toBe(false)
|
||||
}),
|
||||
)
|
||||
|
||||
const disabledIt = configuredIt({ tool_output: false })
|
||||
disabledIt.live("does not truncate output when disabled", () =>
|
||||
Effect.gen(function* () {
|
||||
const content = "a".repeat(Truncate.MAX_BYTES + 1)
|
||||
const svc = yield* Truncate.Service
|
||||
const resolved = yield* svc.limits()
|
||||
const result = yield* svc.output(content)
|
||||
expect(Option.isNone(resolved)).toBe(true)
|
||||
expect(result).toEqual({ content, truncated: false })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.live("large single-line file truncates with byte message", () =>
|
||||
|
||||
@@ -1294,15 +1294,10 @@ export type Config = {
|
||||
enterprise?: {
|
||||
url?: string
|
||||
}
|
||||
/**
|
||||
* Configure tool output truncation. When output exceeds either limit, the full text is written to the truncation directory and a preview is returned.
|
||||
*/
|
||||
tool_output?:
|
||||
| false
|
||||
| {
|
||||
max_lines?: number
|
||||
max_bytes?: number
|
||||
}
|
||||
tool_output?: {
|
||||
max_lines?: number
|
||||
max_bytes?: number
|
||||
}
|
||||
compaction?: {
|
||||
auto?: boolean
|
||||
prune?: boolean
|
||||
|
||||
@@ -353,36 +353,6 @@ You can manage the tools an LLM can use through the `tools` option.
|
||||
|
||||
---
|
||||
|
||||
### Tool output
|
||||
|
||||
You can control when tool output is truncated using the `tool_output` option. When output exceeds either threshold, OpenCode saves the complete output to disk and returns a truncated preview with the saved file path.
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tool_output": {
|
||||
"max_lines": 2000,
|
||||
"max_bytes": 51200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `max_lines` - Maximum number of lines before output is truncated (default: `2000`).
|
||||
- `max_bytes` - Maximum size in bytes before output is truncated (default: `51200`).
|
||||
|
||||
These thresholds apply to output handled by OpenCode's shared truncation layer, including MCP and plugin tool output. Individual tools that page or cap their own results can have separate limits.
|
||||
|
||||
To disable shared tool output truncation, set `tool_output` to `false`:
|
||||
|
||||
```json title="opencode.json"
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.json",
|
||||
"tool_output": false
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Models
|
||||
|
||||
You can configure the providers and models you want to use in your OpenCode config through the `provider`, `model` and `small_model` options.
|
||||
|
||||
Reference in New Issue
Block a user