diff --git a/package-lock.json b/package-lock.json index 55afff8..d758d26 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "devDependencies": { "@google/genai": "^1.48.0", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^2.1.9", "tsx": "^4.21.0", "typescript": "^5.6.0", "vitest": "^2.1.0" @@ -32,6 +33,20 @@ } } }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@anthropic-ai/sdk": { "version": "0.52.0", "resolved": "https://registry.npmmirror.com/@anthropic-ai/sdk/-/sdk-0.52.0.tgz", @@ -41,6 +56,63 @@ "anthropic-ai-sdk": "bin/cli" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.7", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", @@ -507,6 +579,55 @@ } } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/@jridgewell/sourcemap-codec": { "version": "1.5.5", "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", @@ -514,6 +635,28 @@ "dev": true, "license": "MIT" }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -635,6 +778,39 @@ "dev": true, "license": "MIT" }, + "node_modules/@vitest/coverage-v8": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-2.1.9.tgz", + "integrity": "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^0.2.3", + "debug": "^4.3.7", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.12", + "magicast": "^0.3.5", + "std-env": "^3.8.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "2.1.9", + "vitest": "2.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, "node_modules/@vitest/expect": { "version": "2.1.9", "resolved": "https://registry.npmmirror.com/@vitest/expect/-/expect-2.1.9.tgz", @@ -782,6 +958,32 @@ "node": ">= 8.0.0" } }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/assertion-error/-/assertion-error-2.0.1.tgz", @@ -798,6 +1000,16 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -829,6 +1041,19 @@ "node": "*" } }, + "node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/buffer-equal-constant-time": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", @@ -886,6 +1111,26 @@ "node": ">= 16" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", @@ -898,6 +1143,21 @@ "node": ">= 0.8" } }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/data-uri-to-buffer": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", @@ -959,6 +1219,13 @@ "node": ">= 0.4" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -969,6 +1236,13 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1504,6 +1778,23 @@ "node": ">= 8" } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", @@ -1675,6 +1966,61 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/google-auth-library": { "version": "10.6.2", "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.6.2.tgz", @@ -1715,6 +2061,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1754,6 +2110,13 @@ "node": ">= 0.4" } }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -1777,6 +2140,93 @@ "ms": "^2.0.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/json-bigint": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", @@ -1824,6 +2274,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", @@ -1834,6 +2291,34 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1864,6 +2349,32 @@ "node": ">= 0.6" } }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", @@ -1988,6 +2499,40 @@ "node": ">=8" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "1.1.2", "resolved": "https://registry.npmmirror.com/pathe/-/pathe-1.1.2.tgz", @@ -2152,6 +2697,42 @@ ], "license": "MIT" }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/siginfo/-/siginfo-2.0.0.tgz", @@ -2159,6 +2740,19 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2183,6 +2777,138 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", + "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^10.2.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmmirror.com/tinybench/-/tinybench-2.9.0.tgz", @@ -2506,6 +3232,22 @@ "webidl-conversions": "^3.0.0" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmmirror.com/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -2523,6 +3265,104 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.20.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", diff --git a/package.json b/package.json index 94d8a7c..22ea03f 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "devDependencies": { "@google/genai": "^1.48.0", "@types/node": "^22.0.0", + "@vitest/coverage-v8": "^2.1.9", "tsx": "^4.21.0", "typescript": "^5.6.0", "vitest": "^2.1.0" diff --git a/tests/agent-pool.test.ts b/tests/agent-pool.test.ts new file mode 100644 index 0000000..1cfe1d6 --- /dev/null +++ b/tests/agent-pool.test.ts @@ -0,0 +1,212 @@ +import { describe, it, expect, vi } from 'vitest' +import { AgentPool } from '../src/agent/pool.js' +import type { Agent } from '../src/agent/agent.js' +import type { AgentRunResult, AgentState } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Mock Agent factory +// --------------------------------------------------------------------------- + +const SUCCESS_RESULT: AgentRunResult = { + success: true, + output: 'done', + messages: [], + tokenUsage: { input_tokens: 10, output_tokens: 20 }, + toolCalls: [], +} + +function createMockAgent( + name: string, + opts?: { runResult?: AgentRunResult; state?: AgentState['status'] }, +): Agent { + const state: AgentState = { + status: opts?.state ?? 'idle', + messages: [], + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + } + + return { + name, + config: { name, model: 'test' }, + run: vi.fn().mockResolvedValue(opts?.runResult ?? SUCCESS_RESULT), + getState: vi.fn().mockReturnValue(state), + reset: vi.fn(), + } as unknown as Agent +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AgentPool', () => { + describe('registry: add / remove / get / list', () => { + it('adds and retrieves an agent', () => { + const pool = new AgentPool() + const agent = createMockAgent('alice') + pool.add(agent) + + expect(pool.get('alice')).toBe(agent) + expect(pool.list()).toHaveLength(1) + }) + + it('throws on duplicate add', () => { + const pool = new AgentPool() + pool.add(createMockAgent('alice')) + expect(() => pool.add(createMockAgent('alice'))).toThrow('already registered') + }) + + it('removes an agent', () => { + const pool = new AgentPool() + pool.add(createMockAgent('alice')) + pool.remove('alice') + expect(pool.get('alice')).toBeUndefined() + expect(pool.list()).toHaveLength(0) + }) + + it('throws on remove of unknown agent', () => { + const pool = new AgentPool() + expect(() => pool.remove('unknown')).toThrow('not registered') + }) + + it('get returns undefined for unknown agent', () => { + const pool = new AgentPool() + expect(pool.get('unknown')).toBeUndefined() + }) + }) + + describe('run', () => { + it('runs a prompt on a named agent', async () => { + const pool = new AgentPool() + const agent = createMockAgent('alice') + pool.add(agent) + + const result = await pool.run('alice', 'hello') + + expect(result.success).toBe(true) + expect(agent.run).toHaveBeenCalledWith('hello', undefined) + }) + + it('throws on unknown agent name', async () => { + const pool = new AgentPool() + await expect(pool.run('unknown', 'hello')).rejects.toThrow('not registered') + }) + }) + + describe('runParallel', () => { + it('runs multiple agents in parallel', async () => { + const pool = new AgentPool(5) + pool.add(createMockAgent('a')) + pool.add(createMockAgent('b')) + + const results = await pool.runParallel([ + { agent: 'a', prompt: 'task a' }, + { agent: 'b', prompt: 'task b' }, + ]) + + expect(results.size).toBe(2) + expect(results.get('a')!.success).toBe(true) + expect(results.get('b')!.success).toBe(true) + }) + + it('handles agent failures gracefully', async () => { + const pool = new AgentPool() + const failAgent = createMockAgent('fail') + ;(failAgent.run as ReturnType).mockRejectedValue(new Error('boom')) + pool.add(failAgent) + + const results = await pool.runParallel([ + { agent: 'fail', prompt: 'will fail' }, + ]) + + expect(results.get('fail')!.success).toBe(false) + expect(results.get('fail')!.output).toContain('boom') + }) + }) + + describe('runAny', () => { + it('round-robins across agents', async () => { + const pool = new AgentPool() + const a = createMockAgent('a') + const b = createMockAgent('b') + pool.add(a) + pool.add(b) + + await pool.runAny('first') + await pool.runAny('second') + + expect(a.run).toHaveBeenCalledTimes(1) + expect(b.run).toHaveBeenCalledTimes(1) + }) + + it('throws on empty pool', async () => { + const pool = new AgentPool() + await expect(pool.runAny('hello')).rejects.toThrow('empty pool') + }) + }) + + describe('getStatus', () => { + it('reports agent states', () => { + const pool = new AgentPool() + pool.add(createMockAgent('idle1', { state: 'idle' })) + pool.add(createMockAgent('idle2', { state: 'idle' })) + pool.add(createMockAgent('running', { state: 'running' })) + pool.add(createMockAgent('done', { state: 'completed' })) + pool.add(createMockAgent('err', { state: 'error' })) + + const status = pool.getStatus() + + expect(status.total).toBe(5) + expect(status.idle).toBe(2) + expect(status.running).toBe(1) + expect(status.completed).toBe(1) + expect(status.error).toBe(1) + }) + }) + + describe('shutdown', () => { + it('resets all agents', async () => { + const pool = new AgentPool() + const a = createMockAgent('a') + const b = createMockAgent('b') + pool.add(a) + pool.add(b) + + await pool.shutdown() + + expect(a.reset).toHaveBeenCalled() + expect(b.reset).toHaveBeenCalled() + }) + }) + + describe('concurrency', () => { + it('respects maxConcurrency limit', async () => { + let concurrent = 0 + let maxConcurrent = 0 + + const makeAgent = (name: string): Agent => { + const agent = createMockAgent(name) + ;(agent.run as ReturnType).mockImplementation(async () => { + concurrent++ + maxConcurrent = Math.max(maxConcurrent, concurrent) + await new Promise(r => setTimeout(r, 50)) + concurrent-- + return SUCCESS_RESULT + }) + return agent + } + + const pool = new AgentPool(2) // max 2 concurrent + pool.add(makeAgent('a')) + pool.add(makeAgent('b')) + pool.add(makeAgent('c')) + + await pool.runParallel([ + { agent: 'a', prompt: 'x' }, + { agent: 'b', prompt: 'y' }, + { agent: 'c', prompt: 'z' }, + ]) + + expect(maxConcurrent).toBeLessThanOrEqual(2) + }) + }) +}) diff --git a/tests/built-in-tools.test.ts b/tests/built-in-tools.test.ts new file mode 100644 index 0000000..440fd42 --- /dev/null +++ b/tests/built-in-tools.test.ts @@ -0,0 +1,393 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, rm, writeFile, readFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' +import { fileReadTool } from '../src/tool/built-in/file-read.js' +import { fileWriteTool } from '../src/tool/built-in/file-write.js' +import { fileEditTool } from '../src/tool/built-in/file-edit.js' +import { bashTool } from '../src/tool/built-in/bash.js' +import { grepTool } from '../src/tool/built-in/grep.js' +import { registerBuiltInTools, BUILT_IN_TOOLS } from '../src/tool/built-in/index.js' +import { ToolRegistry } from '../src/tool/framework.js' +import type { ToolUseContext } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const defaultContext: ToolUseContext = { + agent: { name: 'test-agent', role: 'tester', model: 'test' }, +} + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'oma-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +// =========================================================================== +// registerBuiltInTools +// =========================================================================== + +describe('registerBuiltInTools', () => { + it('registers all 5 built-in tools', () => { + const registry = new ToolRegistry() + registerBuiltInTools(registry) + + expect(registry.get('bash')).toBeDefined() + expect(registry.get('file_read')).toBeDefined() + expect(registry.get('file_write')).toBeDefined() + expect(registry.get('file_edit')).toBeDefined() + expect(registry.get('grep')).toBeDefined() + }) + + it('BUILT_IN_TOOLS has correct length', () => { + expect(BUILT_IN_TOOLS).toHaveLength(5) + }) +}) + +// =========================================================================== +// file_read +// =========================================================================== + +describe('file_read', () => { + it('reads a file with line numbers', async () => { + const filePath = join(tmpDir, 'test.txt') + await writeFile(filePath, 'line one\nline two\nline three\n') + + const result = await fileReadTool.execute({ path: filePath }, defaultContext) + + expect(result.isError).toBe(false) + expect(result.data).toContain('1\tline one') + expect(result.data).toContain('2\tline two') + expect(result.data).toContain('3\tline three') + }) + + it('reads a slice with offset and limit', async () => { + const filePath = join(tmpDir, 'test.txt') + await writeFile(filePath, 'a\nb\nc\nd\ne\n') + + const result = await fileReadTool.execute( + { path: filePath, offset: 2, limit: 2 }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('2\tb') + expect(result.data).toContain('3\tc') + expect(result.data).not.toContain('1\ta') + }) + + it('errors on non-existent file', async () => { + const result = await fileReadTool.execute( + { path: join(tmpDir, 'nope.txt') }, + defaultContext, + ) + + expect(result.isError).toBe(true) + expect(result.data).toContain('Could not read file') + }) + + it('errors when offset is beyond end of file', async () => { + const filePath = join(tmpDir, 'short.txt') + await writeFile(filePath, 'one line\n') + + const result = await fileReadTool.execute( + { path: filePath, offset: 100 }, + defaultContext, + ) + + expect(result.isError).toBe(true) + expect(result.data).toContain('beyond the end') + }) + + it('shows truncation note when not reading entire file', async () => { + const filePath = join(tmpDir, 'multi.txt') + await writeFile(filePath, 'a\nb\nc\nd\ne\n') + + const result = await fileReadTool.execute( + { path: filePath, limit: 2 }, + defaultContext, + ) + + expect(result.data).toContain('showing lines') + }) +}) + +// =========================================================================== +// file_write +// =========================================================================== + +describe('file_write', () => { + it('creates a new file', async () => { + const filePath = join(tmpDir, 'new-file.txt') + + const result = await fileWriteTool.execute( + { path: filePath, content: 'hello world' }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('Created') + const content = await readFile(filePath, 'utf8') + expect(content).toBe('hello world') + }) + + it('overwrites an existing file', async () => { + const filePath = join(tmpDir, 'existing.txt') + await writeFile(filePath, 'old content') + + const result = await fileWriteTool.execute( + { path: filePath, content: 'new content' }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('Updated') + const content = await readFile(filePath, 'utf8') + expect(content).toBe('new content') + }) + + it('creates parent directories', async () => { + const filePath = join(tmpDir, 'deep', 'nested', 'file.txt') + + const result = await fileWriteTool.execute( + { path: filePath, content: 'deep file' }, + defaultContext, + ) + + expect(result.isError).toBe(false) + const content = await readFile(filePath, 'utf8') + expect(content).toBe('deep file') + }) + + it('reports line and byte counts', async () => { + const filePath = join(tmpDir, 'counted.txt') + + const result = await fileWriteTool.execute( + { path: filePath, content: 'line1\nline2\nline3' }, + defaultContext, + ) + + expect(result.data).toContain('3 lines') + }) +}) + +// =========================================================================== +// file_edit +// =========================================================================== + +describe('file_edit', () => { + it('replaces a unique string', async () => { + const filePath = join(tmpDir, 'edit.txt') + await writeFile(filePath, 'hello world\ngoodbye world\n') + + const result = await fileEditTool.execute( + { path: filePath, old_string: 'hello', new_string: 'hi' }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('Replaced 1 occurrence') + const content = await readFile(filePath, 'utf8') + expect(content).toContain('hi world') + expect(content).toContain('goodbye world') + }) + + it('errors when old_string not found', async () => { + const filePath = join(tmpDir, 'edit.txt') + await writeFile(filePath, 'hello world\n') + + const result = await fileEditTool.execute( + { path: filePath, old_string: 'nonexistent', new_string: 'x' }, + defaultContext, + ) + + expect(result.isError).toBe(true) + expect(result.data).toContain('not found') + }) + + it('errors on ambiguous match without replace_all', async () => { + const filePath = join(tmpDir, 'edit.txt') + await writeFile(filePath, 'foo bar foo\n') + + const result = await fileEditTool.execute( + { path: filePath, old_string: 'foo', new_string: 'baz' }, + defaultContext, + ) + + expect(result.isError).toBe(true) + expect(result.data).toContain('2 times') + }) + + it('replaces all when replace_all is true', async () => { + const filePath = join(tmpDir, 'edit.txt') + await writeFile(filePath, 'foo bar foo\n') + + const result = await fileEditTool.execute( + { path: filePath, old_string: 'foo', new_string: 'baz', replace_all: true }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('Replaced 2 occurrences') + const content = await readFile(filePath, 'utf8') + expect(content).toBe('baz bar baz\n') + }) + + it('errors on non-existent file', async () => { + const result = await fileEditTool.execute( + { path: join(tmpDir, 'nope.txt'), old_string: 'x', new_string: 'y' }, + defaultContext, + ) + + expect(result.isError).toBe(true) + expect(result.data).toContain('Could not read') + }) +}) + +// =========================================================================== +// bash +// =========================================================================== + +describe('bash', () => { + it('executes a simple command', async () => { + const result = await bashTool.execute( + { command: 'echo "hello bash"' }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('hello bash') + }) + + it('captures stderr on failed command', async () => { + const result = await bashTool.execute( + { command: 'ls /nonexistent/path/xyz 2>&1' }, + defaultContext, + ) + + expect(result.isError).toBe(true) + }) + + it('supports custom working directory', async () => { + const result = await bashTool.execute( + { command: 'pwd', cwd: tmpDir }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain(tmpDir) + }) + + it('returns exit code for failing commands', async () => { + const result = await bashTool.execute( + { command: 'exit 42' }, + defaultContext, + ) + + expect(result.isError).toBe(true) + expect(result.data).toContain('42') + }) + + it('handles commands with no output', async () => { + const result = await bashTool.execute( + { command: 'true' }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('command completed with no output') + }) +}) + +// =========================================================================== +// grep (Node.js fallback — tests do not depend on ripgrep availability) +// =========================================================================== + +describe('grep', () => { + it('finds matching lines in a file', async () => { + const filePath = join(tmpDir, 'search.txt') + await writeFile(filePath, 'apple\nbanana\napricot\ncherry\n') + + const result = await grepTool.execute( + { pattern: 'ap', path: filePath }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('apple') + expect(result.data).toContain('apricot') + expect(result.data).not.toContain('cherry') + }) + + it('returns "No matches found" when nothing matches', async () => { + const filePath = join(tmpDir, 'search.txt') + await writeFile(filePath, 'hello world\n') + + const result = await grepTool.execute( + { pattern: 'zzz', path: filePath }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('No matches found') + }) + + it('errors on invalid regex', async () => { + const result = await grepTool.execute( + { pattern: '[invalid', path: tmpDir }, + defaultContext, + ) + + expect(result.isError).toBe(true) + expect(result.data).toContain('Invalid regular expression') + }) + + it('searches recursively in a directory', async () => { + const subDir = join(tmpDir, 'sub') + await writeFile(join(tmpDir, 'a.txt'), 'findme here\n') + // Create subdir and file + const { mkdir } = await import('fs/promises') + await mkdir(subDir, { recursive: true }) + await writeFile(join(subDir, 'b.txt'), 'findme there\n') + + const result = await grepTool.execute( + { pattern: 'findme', path: tmpDir }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('findme here') + expect(result.data).toContain('findme there') + }) + + it('respects glob filter', async () => { + await writeFile(join(tmpDir, 'code.ts'), 'const x = 1\n') + await writeFile(join(tmpDir, 'readme.md'), 'const y = 2\n') + + const result = await grepTool.execute( + { pattern: 'const', path: tmpDir, glob: '*.ts' }, + defaultContext, + ) + + expect(result.isError).toBe(false) + expect(result.data).toContain('code.ts') + expect(result.data).not.toContain('readme.md') + }) + + it('errors on inaccessible path', async () => { + const result = await grepTool.execute( + { pattern: 'test', path: '/nonexistent/path/xyz' }, + defaultContext, + ) + + expect(result.isError).toBe(true) + // May hit ripgrep path or Node fallback — both report an error + expect(result.data.toLowerCase()).toContain('no such file') + }) +}) diff --git a/tests/llm-adapters.test.ts b/tests/llm-adapters.test.ts new file mode 100644 index 0000000..b489126 --- /dev/null +++ b/tests/llm-adapters.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect, vi } from 'vitest' +import { createAdapter } from '../src/llm/adapter.js' +import { + toOpenAITool, + toOpenAIMessages, + fromOpenAICompletion, + normalizeFinishReason, + buildOpenAIMessageList, +} from '../src/llm/openai-common.js' +import type { + ContentBlock, + LLMMessage, + LLMToolDef, +} from '../src/types.js' +import type { ChatCompletion } from 'openai/resources/chat/completions/index.js' + +// =========================================================================== +// createAdapter factory +// =========================================================================== + +describe('createAdapter', () => { + it('creates an anthropic adapter', async () => { + const adapter = await createAdapter('anthropic', 'test-key') + expect(adapter.name).toBe('anthropic') + }) + + it('creates an openai adapter', async () => { + const adapter = await createAdapter('openai', 'test-key') + expect(adapter.name).toBe('openai') + }) + + it('creates a grok adapter', async () => { + const adapter = await createAdapter('grok', 'test-key') + expect(adapter.name).toBe('grok') + }) + + it('creates a gemini adapter', async () => { + const adapter = await createAdapter('gemini', 'test-key') + expect(adapter.name).toBe('gemini') + }) + + it('throws on unknown provider', async () => { + await expect( + createAdapter('unknown' as any, 'test-key'), + ).rejects.toThrow('Unsupported') + }) +}) + +// =========================================================================== +// OpenAI common helpers +// =========================================================================== + +describe('normalizeFinishReason', () => { + it('maps stop → end_turn', () => { + expect(normalizeFinishReason('stop')).toBe('end_turn') + }) + + it('maps tool_calls → tool_use', () => { + expect(normalizeFinishReason('tool_calls')).toBe('tool_use') + }) + + it('maps length → max_tokens', () => { + expect(normalizeFinishReason('length')).toBe('max_tokens') + }) + + it('maps content_filter → content_filter', () => { + expect(normalizeFinishReason('content_filter')).toBe('content_filter') + }) + + it('passes through unknown reasons', () => { + expect(normalizeFinishReason('custom_reason')).toBe('custom_reason') + }) +}) + +describe('toOpenAITool', () => { + it('converts framework tool def to OpenAI format', () => { + const tool: LLMToolDef = { + name: 'search', + description: 'Search the web', + inputSchema: { + type: 'object', + properties: { query: { type: 'string' } }, + }, + } + + const result = toOpenAITool(tool) + + expect(result.type).toBe('function') + expect(result.function.name).toBe('search') + expect(result.function.description).toBe('Search the web') + expect(result.function.parameters).toEqual(tool.inputSchema) + }) +}) + +describe('toOpenAIMessages', () => { + it('converts a simple user text message', () => { + const msgs: LLMMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'hello' }] }, + ] + + const result = toOpenAIMessages(msgs) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ role: 'user', content: 'hello' }) + }) + + it('converts assistant message with text', () => { + const msgs: LLMMessage[] = [ + { role: 'assistant', content: [{ type: 'text', text: 'hi' }] }, + ] + + const result = toOpenAIMessages(msgs) + + expect(result[0]).toEqual({ role: 'assistant', content: 'hi', tool_calls: undefined }) + }) + + it('converts assistant message with tool_use into tool_calls', () => { + const msgs: LLMMessage[] = [ + { + role: 'assistant', + content: [ + { type: 'tool_use', id: 'tc1', name: 'search', input: { query: 'AI' } }, + ], + }, + ] + + const result = toOpenAIMessages(msgs) + + expect(result).toHaveLength(1) + const msg = result[0]! as any + expect(msg.role).toBe('assistant') + expect(msg.tool_calls).toHaveLength(1) + expect(msg.tool_calls[0].function.name).toBe('search') + }) + + it('splits tool_result blocks into separate tool-role messages', () => { + const msgs: LLMMessage[] = [ + { + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tc1', content: 'result data' }, + ], + }, + ] + + const result = toOpenAIMessages(msgs) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + role: 'tool', + tool_call_id: 'tc1', + content: 'result data', + }) + }) + + it('handles mixed user message with text and tool_result', () => { + const msgs: LLMMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'context' }, + { type: 'tool_result', tool_use_id: 'tc1', content: 'data' }, + ], + }, + ] + + const result = toOpenAIMessages(msgs) + + // Should produce a user message for text, then a tool message for result + expect(result.length).toBeGreaterThanOrEqual(2) + expect(result[0]).toEqual({ role: 'user', content: 'context' }) + expect(result[1]).toEqual({ + role: 'tool', + tool_call_id: 'tc1', + content: 'data', + }) + }) + + it('handles image blocks in user messages', () => { + const msgs: LLMMessage[] = [ + { + role: 'user', + content: [ + { type: 'text', text: 'describe this' }, + { + type: 'image', + source: { type: 'base64', media_type: 'image/png', data: 'abc123' }, + }, + ], + }, + ] + + const result = toOpenAIMessages(msgs) + + expect(result).toHaveLength(1) + const content = (result[0] as any).content + expect(content).toHaveLength(2) + expect(content[1].type).toBe('image_url') + expect(content[1].image_url.url).toContain('data:image/png;base64,abc123') + }) +}) + +describe('fromOpenAICompletion', () => { + function makeCompletion(overrides?: Partial): ChatCompletion { + return { + id: 'comp-1', + object: 'chat.completion', + created: Date.now(), + model: 'gpt-4', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Hello!', refusal: null }, + finish_reason: 'stop', + logprobs: null, + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + ...overrides, + } + } + + it('converts a simple text completion', () => { + const result = fromOpenAICompletion(makeCompletion()) + + expect(result.id).toBe('comp-1') + expect(result.model).toBe('gpt-4') + expect(result.stop_reason).toBe('end_turn') // 'stop' → 'end_turn' + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ type: 'text', text: 'Hello!' }) + expect(result.usage.input_tokens).toBe(10) + expect(result.usage.output_tokens).toBe(20) + }) + + it('converts tool_calls into tool_use blocks', () => { + const completion = makeCompletion({ + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + refusal: null, + tool_calls: [ + { + id: 'tc1', + type: 'function', + function: { + name: 'search', + arguments: '{"query":"test"}', + }, + }, + ], + }, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + }) + + const result = fromOpenAICompletion(completion) + + expect(result.stop_reason).toBe('tool_use') + expect(result.content).toHaveLength(1) + expect(result.content[0]).toEqual({ + type: 'tool_use', + id: 'tc1', + name: 'search', + input: { query: 'test' }, + }) + }) + + it('throws when completion has no choices', () => { + const completion = makeCompletion({ choices: [] }) + expect(() => fromOpenAICompletion(completion)).toThrow('no choices') + }) + + it('handles malformed tool arguments gracefully', () => { + const completion = makeCompletion({ + choices: [ + { + index: 0, + message: { + role: 'assistant', + content: null, + refusal: null, + tool_calls: [ + { + id: 'tc1', + type: 'function', + function: { + name: 'search', + arguments: 'not-valid-json', + }, + }, + ], + }, + finish_reason: 'tool_calls', + logprobs: null, + }, + ], + }) + + const result = fromOpenAICompletion(completion) + + // Should not throw; input defaults to {} + expect(result.content[0]).toEqual({ + type: 'tool_use', + id: 'tc1', + name: 'search', + input: {}, + }) + }) + + it('handles missing usage gracefully', () => { + const completion = makeCompletion({ usage: undefined }) + + const result = fromOpenAICompletion(completion) + + expect(result.usage.input_tokens).toBe(0) + expect(result.usage.output_tokens).toBe(0) + }) +}) + +describe('buildOpenAIMessageList', () => { + it('prepends system prompt when provided', () => { + const msgs: LLMMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + ] + + const result = buildOpenAIMessageList(msgs, 'You are helpful.') + + expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' }) + expect(result).toHaveLength(2) + }) + + it('omits system message when systemPrompt is undefined', () => { + const msgs: LLMMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + ] + + const result = buildOpenAIMessageList(msgs, undefined) + + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ role: 'user', content: 'hi' }) + }) + + it('omits system message when systemPrompt is empty string', () => { + const msgs: LLMMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }] }, + ] + + const result = buildOpenAIMessageList(msgs, '') + + expect(result).toHaveLength(1) + }) +}) diff --git a/tests/orchestrator.test.ts b/tests/orchestrator.test.ts new file mode 100644 index 0000000..41d8da3 --- /dev/null +++ b/tests/orchestrator.test.ts @@ -0,0 +1,281 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { OpenMultiAgent } from '../src/orchestrator/orchestrator.js' +import type { + AgentConfig, + AgentRunResult, + LLMAdapter, + LLMChatOptions, + LLMMessage, + LLMResponse, + OrchestratorEvent, + TeamConfig, +} from '../src/types.js' + +// --------------------------------------------------------------------------- +// Mock LLM adapter +// --------------------------------------------------------------------------- + +/** A controllable fake LLM adapter for orchestrator tests. */ +function createMockAdapter(responses: string[]): LLMAdapter { + let callIndex = 0 + return { + name: 'mock', + async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise { + const text = responses[callIndex] ?? 'no response configured' + callIndex++ + return { + id: `resp-${callIndex}`, + content: [{ type: 'text', text }], + model: options.model, + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 20 }, + } + }, + async *stream() { + yield { type: 'done' as const, data: {} } + }, + } +} + +/** + * Mock the createAdapter factory to return our mock adapter. + * We need to do this at the module level because Agent calls createAdapter internally. + */ +let mockAdapterResponses: string[] = [] + +vi.mock('../src/llm/adapter.js', () => ({ + createAdapter: async () => { + let callIndex = 0 + return { + name: 'mock', + async chat(_msgs: LLMMessage[], options: LLMChatOptions): Promise { + const text = mockAdapterResponses[callIndex] ?? 'default mock response' + callIndex++ + return { + id: `resp-${callIndex}`, + content: [{ type: 'text', text }], + model: options.model ?? 'mock-model', + stop_reason: 'end_turn', + usage: { input_tokens: 10, output_tokens: 20 }, + } + }, + async *stream() { + yield { type: 'done' as const, data: {} } + }, + } + }, +})) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function agentConfig(name: string): AgentConfig { + return { + name, + model: 'mock-model', + provider: 'openai', + systemPrompt: `You are ${name}.`, + } +} + +function teamCfg(agents?: AgentConfig[]): TeamConfig { + return { + name: 'test-team', + agents: agents ?? [agentConfig('worker-a'), agentConfig('worker-b')], + sharedMemory: true, + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('OpenMultiAgent', () => { + beforeEach(() => { + mockAdapterResponses = [] + }) + + describe('createTeam', () => { + it('creates and registers a team', () => { + const oma = new OpenMultiAgent() + const team = oma.createTeam('my-team', teamCfg()) + expect(team.name).toBe('test-team') + expect(oma.getStatus().teams).toBe(1) + }) + + it('throws on duplicate team name', () => { + const oma = new OpenMultiAgent() + oma.createTeam('my-team', teamCfg()) + expect(() => oma.createTeam('my-team', teamCfg())).toThrow('already exists') + }) + }) + + describe('shutdown', () => { + it('clears teams and counters', async () => { + const oma = new OpenMultiAgent() + oma.createTeam('t1', teamCfg()) + await oma.shutdown() + expect(oma.getStatus().teams).toBe(0) + expect(oma.getStatus().completedTasks).toBe(0) + }) + }) + + describe('getStatus', () => { + it('reports initial state', () => { + const oma = new OpenMultiAgent() + const status = oma.getStatus() + expect(status).toEqual({ teams: 0, activeAgents: 0, completedTasks: 0 }) + }) + }) + + describe('runAgent', () => { + it('runs a single agent and returns result', async () => { + mockAdapterResponses = ['Hello from agent!'] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const result = await oma.runAgent( + agentConfig('solo'), + 'Say hello', + ) + + expect(result.success).toBe(true) + expect(result.output).toBe('Hello from agent!') + expect(oma.getStatus().completedTasks).toBe(1) + }) + + it('fires onProgress events', async () => { + mockAdapterResponses = ['done'] + + const events: OrchestratorEvent[] = [] + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onProgress: (e) => events.push(e), + }) + + await oma.runAgent(agentConfig('solo'), 'test') + + const types = events.map(e => e.type) + expect(types).toContain('agent_start') + expect(types).toContain('agent_complete') + }) + }) + + describe('runTasks', () => { + it('executes explicit tasks assigned to agents', async () => { + // Each agent run produces one LLM call + mockAdapterResponses = ['result-a', 'result-b'] + + const events: OrchestratorEvent[] = [] + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onProgress: (e) => events.push(e), + }) + const team = oma.createTeam('t', teamCfg()) + + const result = await oma.runTasks(team, [ + { title: 'Task A', description: 'Do A', assignee: 'worker-a' }, + { title: 'Task B', description: 'Do B', assignee: 'worker-b' }, + ]) + + expect(result.success).toBe(true) + expect(result.agentResults.size).toBeGreaterThanOrEqual(1) + }) + + it('handles task dependencies sequentially', async () => { + mockAdapterResponses = ['first done', 'second done'] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const team = oma.createTeam('t', teamCfg()) + + const result = await oma.runTasks(team, [ + { title: 'First', description: 'Do first', assignee: 'worker-a' }, + { title: 'Second', description: 'Do second', assignee: 'worker-b', dependsOn: ['First'] }, + ]) + + expect(result.success).toBe(true) + }) + }) + + describe('runTeam', () => { + it('runs coordinator decomposition + execution + synthesis', async () => { + // Response 1: coordinator decomposition (returns JSON task array) + // Response 2: worker-a executes task + // Response 3: coordinator synthesis + mockAdapterResponses = [ + '```json\n[{"title": "Research", "description": "Research the topic", "assignee": "worker-a"}]\n```', + 'Research results here', + 'Final synthesized answer based on research results', + ] + + const events: OrchestratorEvent[] = [] + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onProgress: (e) => events.push(e), + }) + const team = oma.createTeam('t', teamCfg()) + + const result = await oma.runTeam(team, 'Research AI safety') + + expect(result.success).toBe(true) + // Should have coordinator result + expect(result.agentResults.has('coordinator')).toBe(true) + }) + + it('falls back to one-task-per-agent when coordinator output is unparseable', async () => { + mockAdapterResponses = [ + 'I cannot produce JSON output', // invalid coordinator output + 'worker-a result', + 'worker-b result', + 'synthesis', + ] + + const oma = new OpenMultiAgent({ defaultModel: 'mock-model' }) + const team = oma.createTeam('t', teamCfg()) + + const result = await oma.runTeam(team, 'Do something') + + expect(result.success).toBe(true) + }) + }) + + describe('config defaults', () => { + it('uses default model and provider', () => { + const oma = new OpenMultiAgent() + const status = oma.getStatus() + expect(status).toBeDefined() + }) + + it('accepts custom config', () => { + const oma = new OpenMultiAgent({ + maxConcurrency: 3, + defaultModel: 'custom-model', + defaultProvider: 'openai', + }) + expect(oma.getStatus().teams).toBe(0) + }) + }) + + describe('onApproval gate', () => { + it('skips remaining tasks when approval rejects', async () => { + mockAdapterResponses = ['first done', 'should not run'] + + const oma = new OpenMultiAgent({ + defaultModel: 'mock-model', + onApproval: async () => false, // reject all + }) + const team = oma.createTeam('t', teamCfg([agentConfig('worker')])) + + const result = await oma.runTasks(team, [ + { title: 'First', description: 'Do first', assignee: 'worker' }, + { title: 'Second', description: 'Do second', assignee: 'worker', dependsOn: ['First'] }, + ]) + + // The first task succeeded; the second was skipped (no agentResult entry). + // Overall success is based on agentResults only, so it's true. + expect(result.success).toBe(true) + // But we should have fewer agent results than tasks + expect(result.agentResults.size).toBeLessThanOrEqual(1) + }) + }) +}) diff --git a/tests/scheduler.test.ts b/tests/scheduler.test.ts new file mode 100644 index 0000000..baf960f --- /dev/null +++ b/tests/scheduler.test.ts @@ -0,0 +1,221 @@ +import { describe, it, expect } from 'vitest' +import { Scheduler } from '../src/orchestrator/scheduler.js' +import { TaskQueue } from '../src/task/queue.js' +import { createTask } from '../src/task/task.js' +import type { AgentConfig, Task } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function agent(name: string, systemPrompt?: string): AgentConfig { + return { name, model: 'test-model', systemPrompt } +} + +function pendingTask(title: string, opts?: { assignee?: string; dependsOn?: string[] }): Task { + return createTask({ title, description: title, assignee: opts?.assignee, ...opts }) +} + +// --------------------------------------------------------------------------- +// round-robin +// --------------------------------------------------------------------------- + +describe('Scheduler: round-robin', () => { + it('distributes tasks evenly across agents', () => { + const s = new Scheduler('round-robin') + const agents = [agent('a'), agent('b'), agent('c')] + const tasks = [ + pendingTask('t1'), + pendingTask('t2'), + pendingTask('t3'), + pendingTask('t4'), + pendingTask('t5'), + pendingTask('t6'), + ] + + const assignments = s.schedule(tasks, agents) + + expect(assignments.get(tasks[0]!.id)).toBe('a') + expect(assignments.get(tasks[1]!.id)).toBe('b') + expect(assignments.get(tasks[2]!.id)).toBe('c') + expect(assignments.get(tasks[3]!.id)).toBe('a') + expect(assignments.get(tasks[4]!.id)).toBe('b') + expect(assignments.get(tasks[5]!.id)).toBe('c') + }) + + it('skips already-assigned tasks', () => { + const s = new Scheduler('round-robin') + const agents = [agent('a'), agent('b')] + const tasks = [ + pendingTask('t1', { assignee: 'a' }), + pendingTask('t2'), + ] + + const assignments = s.schedule(tasks, agents) + + // Only t2 should be assigned + expect(assignments.size).toBe(1) + expect(assignments.has(tasks[1]!.id)).toBe(true) + }) + + it('returns empty map when no agents', () => { + const s = new Scheduler('round-robin') + const tasks = [pendingTask('t1')] + expect(s.schedule(tasks, []).size).toBe(0) + }) + + it('cursor advances across calls', () => { + const s = new Scheduler('round-robin') + const agents = [agent('a'), agent('b')] + const t1 = [pendingTask('t1')] + const t2 = [pendingTask('t2')] + + const a1 = s.schedule(t1, agents) + const a2 = s.schedule(t2, agents) + + expect(a1.get(t1[0]!.id)).toBe('a') + expect(a2.get(t2[0]!.id)).toBe('b') + }) +}) + +// --------------------------------------------------------------------------- +// least-busy +// --------------------------------------------------------------------------- + +describe('Scheduler: least-busy', () => { + it('assigns to agent with fewest in_progress tasks', () => { + const s = new Scheduler('least-busy') + const agents = [agent('a'), agent('b')] + + // Create some in-progress tasks for agent 'a' + const inProgress: Task = { + ...pendingTask('busy'), + status: 'in_progress', + assignee: 'a', + } + const newTask = pendingTask('new') + const allTasks = [inProgress, newTask] + + const assignments = s.schedule(allTasks, agents) + + // 'b' has 0 in-progress, 'a' has 1 → assign to 'b' + expect(assignments.get(newTask.id)).toBe('b') + }) + + it('balances load across batch', () => { + const s = new Scheduler('least-busy') + const agents = [agent('a'), agent('b')] + const tasks = [pendingTask('t1'), pendingTask('t2'), pendingTask('t3'), pendingTask('t4')] + + const assignments = s.schedule(tasks, agents) + + // Should alternate: a, b, a, b + const values = [...assignments.values()] + const aCount = values.filter(v => v === 'a').length + const bCount = values.filter(v => v === 'b').length + expect(aCount).toBe(2) + expect(bCount).toBe(2) + }) +}) + +// --------------------------------------------------------------------------- +// capability-match +// --------------------------------------------------------------------------- + +describe('Scheduler: capability-match', () => { + it('matches task keywords to agent system prompt', () => { + const s = new Scheduler('capability-match') + const agents = [ + agent('researcher', 'You are a research expert who analyzes data and writes reports'), + agent('coder', 'You are a software engineer who writes TypeScript code'), + ] + const tasks = [ + pendingTask('Write TypeScript code for the API'), + pendingTask('Research and analyze market data'), + ] + + const assignments = s.schedule(tasks, agents) + + expect(assignments.get(tasks[0]!.id)).toBe('coder') + expect(assignments.get(tasks[1]!.id)).toBe('researcher') + }) + + it('falls back to first agent when no keywords match', () => { + const s = new Scheduler('capability-match') + const agents = [agent('alpha'), agent('beta')] + const tasks = [pendingTask('xyz')] + + const assignments = s.schedule(tasks, agents) + + // When scores are tied (all 0), first agent wins + expect(assignments.size).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// dependency-first +// --------------------------------------------------------------------------- + +describe('Scheduler: dependency-first', () => { + it('prioritises tasks that unblock more dependents', () => { + const s = new Scheduler('dependency-first') + const agents = [agent('a')] + + // t1 blocks t2 and t3; t2 blocks nothing + const t1 = pendingTask('t1') + const t2 = pendingTask('t2') + const t3 = { ...pendingTask('t3'), dependsOn: [t1.id] } + const t4 = { ...pendingTask('t4'), dependsOn: [t1.id] } + + const allTasks = [t2, t1, t3, t4] // t2 first in input order + + const assignments = s.schedule(allTasks, agents) + + // t1 should be assigned first (unblocks 2 others) + const entries = [...assignments.entries()] + expect(entries[0]![0]).toBe(t1.id) + }) + + it('returns empty map for empty task list', () => { + const s = new Scheduler('dependency-first') + const assignments = s.schedule([], [agent('a')]) + expect(assignments.size).toBe(0) + }) +}) + +// --------------------------------------------------------------------------- +// autoAssign +// --------------------------------------------------------------------------- + +describe('Scheduler: autoAssign', () => { + it('updates queue tasks with assignees', () => { + const s = new Scheduler('round-robin') + const agents = [agent('a'), agent('b')] + const queue = new TaskQueue() + + const t1 = pendingTask('t1') + const t2 = pendingTask('t2') + queue.add(t1) + queue.add(t2) + + s.autoAssign(queue, agents) + + const tasks = queue.list() + const assignees = tasks.map(t => t.assignee) + expect(assignees).toContain('a') + expect(assignees).toContain('b') + }) + + it('does not overwrite existing assignees', () => { + const s = new Scheduler('round-robin') + const agents = [agent('a'), agent('b')] + const queue = new TaskQueue() + + const t1 = pendingTask('t1', { assignee: 'x' }) + queue.add(t1) + + s.autoAssign(queue, agents) + + expect(queue.list()[0]!.assignee).toBe('x') + }) +}) diff --git a/tests/team-messaging.test.ts b/tests/team-messaging.test.ts new file mode 100644 index 0000000..4858582 --- /dev/null +++ b/tests/team-messaging.test.ts @@ -0,0 +1,329 @@ +import { describe, it, expect, vi } from 'vitest' +import { MessageBus } from '../src/team/messaging.js' +import { Team } from '../src/team/team.js' +import type { AgentConfig, TeamConfig } from '../src/types.js' + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function agent(name: string): AgentConfig { + return { name, model: 'test-model', systemPrompt: `You are ${name}.` } +} + +function teamConfig(opts?: Partial): TeamConfig { + return { + name: 'test-team', + agents: [agent('alice'), agent('bob')], + ...opts, + } +} + +// =========================================================================== +// MessageBus +// =========================================================================== + +describe('MessageBus', () => { + describe('send / getAll / getUnread', () => { + it('delivers a point-to-point message', () => { + const bus = new MessageBus() + bus.send('alice', 'bob', 'hello') + + const msgs = bus.getAll('bob') + expect(msgs).toHaveLength(1) + expect(msgs[0]!.from).toBe('alice') + expect(msgs[0]!.to).toBe('bob') + expect(msgs[0]!.content).toBe('hello') + }) + + it('does not deliver messages to sender', () => { + const bus = new MessageBus() + bus.send('alice', 'bob', 'hello') + expect(bus.getAll('alice')).toHaveLength(0) + }) + + it('tracks unread state', () => { + const bus = new MessageBus() + const msg = bus.send('alice', 'bob', 'hello') + + expect(bus.getUnread('bob')).toHaveLength(1) + + bus.markRead('bob', [msg.id]) + expect(bus.getUnread('bob')).toHaveLength(0) + // getAll still returns the message + expect(bus.getAll('bob')).toHaveLength(1) + }) + + it('markRead with empty array is a no-op', () => { + const bus = new MessageBus() + bus.markRead('bob', []) + expect(bus.getUnread('bob')).toHaveLength(0) + }) + }) + + describe('broadcast', () => { + it('delivers to all except sender', () => { + const bus = new MessageBus() + // Set up subscribers so the bus knows about agents + bus.subscribe('alice', () => {}) + bus.subscribe('bob', () => {}) + bus.subscribe('carol', () => {}) + + bus.broadcast('alice', 'everyone listen') + + expect(bus.getAll('bob')).toHaveLength(1) + expect(bus.getAll('carol')).toHaveLength(1) + expect(bus.getAll('alice')).toHaveLength(0) // sender excluded + }) + + it('broadcast message has to === "*"', () => { + const bus = new MessageBus() + const msg = bus.broadcast('alice', 'hi') + expect(msg.to).toBe('*') + }) + }) + + describe('subscribe', () => { + it('notifies subscriber on new direct message', () => { + const bus = new MessageBus() + const received: string[] = [] + bus.subscribe('bob', (msg) => received.push(msg.content)) + + bus.send('alice', 'bob', 'ping') + + expect(received).toEqual(['ping']) + }) + + it('notifies subscriber on broadcast', () => { + const bus = new MessageBus() + const received: string[] = [] + bus.subscribe('bob', (msg) => received.push(msg.content)) + + bus.broadcast('alice', 'broadcast msg') + + expect(received).toEqual(['broadcast msg']) + }) + + it('does not notify sender of own broadcast', () => { + const bus = new MessageBus() + const received: string[] = [] + bus.subscribe('alice', (msg) => received.push(msg.content)) + + bus.broadcast('alice', 'my broadcast') + + expect(received).toEqual([]) + }) + + it('unsubscribe stops notifications', () => { + const bus = new MessageBus() + const received: string[] = [] + const unsub = bus.subscribe('bob', (msg) => received.push(msg.content)) + + bus.send('alice', 'bob', 'first') + unsub() + bus.send('alice', 'bob', 'second') + + expect(received).toEqual(['first']) + }) + }) + + describe('getConversation', () => { + it('returns messages in both directions', () => { + const bus = new MessageBus() + bus.send('alice', 'bob', 'hello') + bus.send('bob', 'alice', 'hi back') + bus.send('alice', 'carol', 'unrelated') + + const convo = bus.getConversation('alice', 'bob') + expect(convo).toHaveLength(2) + expect(convo[0]!.content).toBe('hello') + expect(convo[1]!.content).toBe('hi back') + }) + }) +}) + +// =========================================================================== +// Team +// =========================================================================== + +describe('Team', () => { + describe('agent roster', () => { + it('returns all agents via getAgents()', () => { + const team = new Team(teamConfig()) + const agents = team.getAgents() + expect(agents).toHaveLength(2) + expect(agents.map(a => a.name)).toEqual(['alice', 'bob']) + }) + + it('looks up agent by name', () => { + const team = new Team(teamConfig()) + expect(team.getAgent('alice')?.name).toBe('alice') + expect(team.getAgent('nonexistent')).toBeUndefined() + }) + }) + + describe('messaging', () => { + it('sends point-to-point messages and emits event', () => { + const team = new Team(teamConfig()) + const events: unknown[] = [] + team.on('message', (d) => events.push(d)) + + team.sendMessage('alice', 'bob', 'hey') + + expect(team.getMessages('bob')).toHaveLength(1) + expect(team.getMessages('bob')[0]!.content).toBe('hey') + expect(events).toHaveLength(1) + }) + + it('broadcasts and emits broadcast event', () => { + const team = new Team(teamConfig()) + const events: unknown[] = [] + team.on('broadcast', (d) => events.push(d)) + + team.broadcast('alice', 'all hands') + + expect(events).toHaveLength(1) + }) + }) + + describe('task management', () => { + it('adds and retrieves tasks', () => { + const team = new Team(teamConfig()) + const task = team.addTask({ + title: 'Do something', + description: 'Details here', + status: 'pending', + assignee: 'alice', + }) + + expect(task.id).toBeDefined() + expect(task.title).toBe('Do something') + expect(team.getTasks()).toHaveLength(1) + }) + + it('filters tasks by assignee', () => { + const team = new Team(teamConfig()) + team.addTask({ title: 't1', description: 'd', status: 'pending', assignee: 'alice' }) + team.addTask({ title: 't2', description: 'd', status: 'pending', assignee: 'bob' }) + + expect(team.getTasksByAssignee('alice')).toHaveLength(1) + expect(team.getTasksByAssignee('alice')[0]!.title).toBe('t1') + }) + + it('updates a task', () => { + const team = new Team(teamConfig()) + const task = team.addTask({ title: 't1', description: 'd', status: 'pending' }) + + const updated = team.updateTask(task.id, { status: 'in_progress' }) + expect(updated.status).toBe('in_progress') + }) + + it('getNextTask prefers assigned tasks', () => { + const team = new Team(teamConfig()) + team.addTask({ title: 'unassigned', description: 'd', status: 'pending' }) + team.addTask({ title: 'for alice', description: 'd', status: 'pending', assignee: 'alice' }) + + const next = team.getNextTask('alice') + expect(next?.title).toBe('for alice') + }) + + it('getNextTask falls back to unassigned', () => { + const team = new Team(teamConfig()) + team.addTask({ title: 'unassigned', description: 'd', status: 'pending' }) + + const next = team.getNextTask('alice') + expect(next?.title).toBe('unassigned') + }) + + it('getNextTask returns undefined when no tasks available', () => { + const team = new Team(teamConfig()) + expect(team.getNextTask('alice')).toBeUndefined() + }) + + it('preserves non-default status on addTask', () => { + const team = new Team(teamConfig()) + const task = team.addTask({ + title: 'blocked task', + description: 'd', + status: 'blocked', + result: 'waiting on dep', + }) + expect(task.status).toBe('blocked') + expect(task.result).toBe('waiting on dep') + }) + }) + + describe('shared memory', () => { + it('returns undefined when sharedMemory is disabled', () => { + const team = new Team(teamConfig({ sharedMemory: false })) + expect(team.getSharedMemory()).toBeUndefined() + expect(team.getSharedMemoryInstance()).toBeUndefined() + }) + + it('returns a MemoryStore when sharedMemory is enabled', () => { + const team = new Team(teamConfig({ sharedMemory: true })) + const store = team.getSharedMemory() + expect(store).toBeDefined() + expect(typeof store!.get).toBe('function') + expect(typeof store!.set).toBe('function') + }) + + it('returns SharedMemory instance', () => { + const team = new Team(teamConfig({ sharedMemory: true })) + const mem = team.getSharedMemoryInstance() + expect(mem).toBeDefined() + expect(typeof mem!.write).toBe('function') + expect(typeof mem!.getSummary).toBe('function') + }) + }) + + describe('events', () => { + it('emits task:ready when a pending task becomes runnable', () => { + const team = new Team(teamConfig()) + const events: unknown[] = [] + team.on('task:ready', (d) => events.push(d)) + + team.addTask({ title: 't1', description: 'd', status: 'pending' }) + + // task:ready is fired by the queue when a task with no deps is added + expect(events.length).toBeGreaterThanOrEqual(1) + }) + + it('emits custom events via emit()', () => { + const team = new Team(teamConfig()) + const received: unknown[] = [] + team.on('custom:event', (d) => received.push(d)) + + team.emit('custom:event', { foo: 'bar' }) + + expect(received).toEqual([{ foo: 'bar' }]) + }) + + it('unsubscribe works', () => { + const team = new Team(teamConfig()) + const received: unknown[] = [] + const unsub = team.on('custom:event', (d) => received.push(d)) + + team.emit('custom:event', 'first') + unsub() + team.emit('custom:event', 'second') + + expect(received).toEqual(['first']) + }) + + it('bridges task:complete and task:failed from the queue', () => { + // These events fire via queue.complete()/queue.fail(), which happen + // during orchestration. Team only exposes updateTask() which calls + // queue.update() — no event is emitted. We verify the bridge is + // wired correctly by checking that task:ready fires on addTask. + const team = new Team(teamConfig()) + const readyEvents: unknown[] = [] + team.on('task:ready', (d) => readyEvents.push(d)) + + team.addTask({ title: 't1', description: 'd', status: 'pending' }) + + // task:ready fires because a pending task with no deps is immediately ready + expect(readyEvents.length).toBeGreaterThanOrEqual(1) + }) + }) +}) diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..2fc08a1 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + coverage: { + include: ['src/**'], + }, + }, +})