Merge pull request #30 from multica-ai/forrestchang/add-unit-tests
test: add comprehensive unit tests across the codebase
This commit is contained in:
commit
75fba044bd
17 changed files with 3937 additions and 24 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -22,3 +22,6 @@ release
|
|||
*.apk
|
||||
*.ipa
|
||||
monorepo.md
|
||||
|
||||
# test coverage
|
||||
coverage
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@
|
|||
"build": "turbo build",
|
||||
"build:sdk": "pnpm --filter @multica/sdk build",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "turbo typecheck"
|
||||
"typecheck": "turbo typecheck",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
|
@ -26,9 +29,11 @@
|
|||
"@types/node": "catalog:",
|
||||
"@types/turndown": "^5.0.6",
|
||||
"@types/uuid": "^11.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.3.4",
|
||||
"typescript": "catalog:"
|
||||
"typescript": "catalog:",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mariozechner/pi-agent-core": "^0.50.3",
|
||||
|
|
|
|||
452
pnpm-lock.yaml
generated
452
pnpm-lock.yaml
generated
|
|
@ -34,13 +34,13 @@ importers:
|
|||
dependencies:
|
||||
'@mariozechner/pi-agent-core':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-coding-agent':
|
||||
specifier: ^0.50.3
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
version: 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mozilla/readability':
|
||||
specifier: ^0.6.0
|
||||
version: 0.6.0
|
||||
|
|
@ -117,6 +117,9 @@ importers:
|
|||
'@types/uuid':
|
||||
specifier: ^11.0.0
|
||||
version: 11.0.0
|
||||
'@vitest/coverage-v8':
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(vitest@4.0.18(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2))
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
version: 4.21.0
|
||||
|
|
@ -126,6 +129,9 @@ importers:
|
|||
typescript:
|
||||
specifier: 'catalog:'
|
||||
version: 5.9.3
|
||||
vitest:
|
||||
specifier: ^4.0.18
|
||||
version: 4.0.18(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
apps/desktop:
|
||||
dependencies:
|
||||
|
|
@ -643,6 +649,10 @@ packages:
|
|||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2':
|
||||
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@borewit/text-codec@0.2.1':
|
||||
resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==}
|
||||
|
||||
|
|
@ -1930,6 +1940,9 @@ packages:
|
|||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
|
||||
|
||||
|
|
@ -2065,12 +2078,18 @@ packages:
|
|||
'@types/cacheable-request@6.0.3':
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
|
||||
|
||||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.8':
|
||||
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
|
||||
|
||||
|
|
@ -2355,6 +2374,44 @@ packages:
|
|||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
'@vitest/coverage-v8@4.0.18':
|
||||
resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==}
|
||||
peerDependencies:
|
||||
'@vitest/browser': 4.0.18
|
||||
vitest: 4.0.18
|
||||
peerDependenciesMeta:
|
||||
'@vitest/browser':
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
|
||||
|
||||
'@vitest/mocker@4.0.18':
|
||||
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0-0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
|
||||
|
||||
'@vitest/spy@4.0.18':
|
||||
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
|
||||
'@xmldom/xmldom@0.8.11':
|
||||
resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
|
|
@ -2499,6 +2556,10 @@ packages:
|
|||
resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ast-types-flow@0.0.8:
|
||||
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
|
||||
|
||||
|
|
@ -2510,6 +2571,9 @@ packages:
|
|||
resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
ast-v8-to-istanbul@0.3.10:
|
||||
resolution: {integrity: sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ==}
|
||||
|
||||
astral-regex@2.0.0:
|
||||
resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
|
@ -2669,6 +2733,10 @@ packages:
|
|||
caniuse-lite@1.0.30001766:
|
||||
resolution: {integrity: sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3123,6 +3191,9 @@ packages:
|
|||
resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -3310,6 +3381,9 @@ packages:
|
|||
resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
esutils@2.0.3:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -3334,6 +3408,10 @@ packages:
|
|||
resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==}
|
||||
engines: {node: ^18.19.0 || >=20.5.0}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
express-rate-limit@7.5.1:
|
||||
resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==}
|
||||
engines: {node: '>= 16'}
|
||||
|
|
@ -3702,6 +3780,9 @@ packages:
|
|||
resolution: {integrity: sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
html-escaper@3.0.3:
|
||||
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
|
||||
|
||||
|
|
@ -3989,6 +4070,18 @@ packages:
|
|||
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
istanbul-lib-coverage@3.2.2:
|
||||
resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
iterare@1.2.1:
|
||||
resolution: {integrity: sha512-RKYVTCjAnRthyJes037NX/IiqeidgN1xc3j1RjFfECFp28A1GVwK9nA+i0rJPaHqSZwygLzRnFlzUuHFoWWy+Q==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -4023,6 +4116,9 @@ packages:
|
|||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-tokens@9.0.1:
|
||||
resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
|
||||
hasBin: true
|
||||
|
|
@ -4258,6 +4354,13 @@ packages:
|
|||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
magicast@0.5.1:
|
||||
resolution: {integrity: sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
marked@15.0.12:
|
||||
resolution: {integrity: sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
|
@ -4527,6 +4630,9 @@ packages:
|
|||
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -4671,6 +4777,9 @@ packages:
|
|||
resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
|
|
@ -5040,6 +5149,9 @@ packages:
|
|||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
|
|
@ -5113,6 +5225,9 @@ packages:
|
|||
stable-hash@0.0.5:
|
||||
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
stat-mode@1.0.0:
|
||||
resolution: {integrity: sha512-jH9EhtKIjuXZ2cWxmXS8ZP80XyC3iasQxMDV8jzhNJpfDb7VbQLVW4Wvsxz9QZvzV+G4YoSfBUVKDOyxLzi/sg==}
|
||||
engines: {node: '>= 6'}
|
||||
|
|
@ -5290,6 +5405,9 @@ packages:
|
|||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.0.2:
|
||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -5298,6 +5416,10 @@ packages:
|
|||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.0.3:
|
||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tldts-core@7.0.19:
|
||||
resolution: {integrity: sha512-lJX2dEWx0SGH4O6p+7FPwYmJ/bu1JbcGJ8RLaG9b7liIgZ85itUVEPbMtWRVrde/0fnDPEPHW10ZsKW3kVsE9A==}
|
||||
|
||||
|
|
@ -5584,6 +5706,80 @@ packages:
|
|||
terser:
|
||||
optional: true
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@types/node': ^20.19.0 || >=22.12.0
|
||||
jiti: '>=1.21.0'
|
||||
less: ^4.0.0
|
||||
lightningcss: ^1.21.0
|
||||
sass: ^1.70.0
|
||||
sass-embedded: ^1.70.0
|
||||
stylus: '>=0.54.8'
|
||||
sugarss: ^5.0.0
|
||||
terser: ^5.16.0
|
||||
tsx: ^4.8.1
|
||||
yaml: ^2.4.2
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
jiti:
|
||||
optional: true
|
||||
less:
|
||||
optional: true
|
||||
lightningcss:
|
||||
optional: true
|
||||
sass:
|
||||
optional: true
|
||||
sass-embedded:
|
||||
optional: true
|
||||
stylus:
|
||||
optional: true
|
||||
sugarss:
|
||||
optional: true
|
||||
terser:
|
||||
optional: true
|
||||
tsx:
|
||||
optional: true
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.0.18:
|
||||
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.0.18
|
||||
'@vitest/browser-preview': 4.0.18
|
||||
'@vitest/browser-webdriverio': 4.0.18
|
||||
'@vitest/ui': 4.0.18
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
|
@ -5614,6 +5810,11 @@ packages:
|
|||
engines: {node: ^16.13.0 || >=18.0.0}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
word-wrap@1.2.5:
|
||||
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -5759,11 +5960,11 @@ snapshots:
|
|||
package-manager-detector: 1.6.0
|
||||
tinyexec: 1.0.2
|
||||
|
||||
'@anthropic-ai/sdk@0.71.2(zod@3.25.76)':
|
||||
'@anthropic-ai/sdk@0.71.2(zod@4.3.6)':
|
||||
dependencies:
|
||||
json-schema-to-ts: 3.1.1
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
zod: 4.3.6
|
||||
|
||||
'@aws-crypto/crc32@5.2.0':
|
||||
dependencies:
|
||||
|
|
@ -6463,6 +6664,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
'@types/react': 19.2.10
|
||||
|
||||
'@bcoe/v8-coverage@1.0.2': {}
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
|
||||
'@develar/schema-utils@2.6.5':
|
||||
|
|
@ -6784,12 +6987,12 @@ snapshots:
|
|||
|
||||
'@floating-ui/utils@0.2.10': {}
|
||||
|
||||
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))':
|
||||
'@google/genai@1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))':
|
||||
dependencies:
|
||||
google-auth-library: 10.5.0
|
||||
ws: 8.18.3
|
||||
optionalDependencies:
|
||||
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@3.25.76)
|
||||
'@modelcontextprotocol/sdk': 1.25.3(hono@4.11.7)(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
|
|
@ -7044,9 +7247,9 @@ snapshots:
|
|||
std-env: 3.10.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
'@mariozechner/pi-agent-core@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.50.3
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
|
|
@ -7057,21 +7260,21 @@ snapshots:
|
|||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
'@mariozechner/pi-ai@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@3.25.76)
|
||||
'@anthropic-ai/sdk': 0.71.2(zod@4.3.6)
|
||||
'@aws-sdk/client-bedrock-runtime': 3.978.0
|
||||
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))
|
||||
'@google/genai': 1.34.0(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))
|
||||
'@mistralai/mistralai': 1.10.0
|
||||
'@sinclair/typebox': 0.34.48
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
chalk: 5.6.2
|
||||
openai: 6.10.0(ws@8.18.3)(zod@3.25.76)
|
||||
openai: 6.10.0(ws@8.18.3)(zod@4.3.6)
|
||||
partial-json: 0.1.7
|
||||
proxy-agent: 6.5.0
|
||||
undici: 7.19.2
|
||||
zod-to-json-schema: 3.25.1(zod@3.25.76)
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- '@modelcontextprotocol/sdk'
|
||||
- aws-crt
|
||||
|
|
@ -7081,12 +7284,12 @@ snapshots:
|
|||
- ws
|
||||
- zod
|
||||
|
||||
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)':
|
||||
'@mariozechner/pi-coding-agent@0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@mariozechner/clipboard': 0.3.0
|
||||
'@mariozechner/jiti': 2.6.5
|
||||
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@3.25.76))(ws@8.18.3)(zod@3.25.76)
|
||||
'@mariozechner/pi-agent-core': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-ai': 0.50.3(@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6))(ws@8.18.3)(zod@4.3.6)
|
||||
'@mariozechner/pi-tui': 0.50.3
|
||||
'@silvia-odwyer/photon-node': 0.3.4
|
||||
chalk: 5.6.2
|
||||
|
|
@ -7144,6 +7347,29 @@ snapshots:
|
|||
- hono
|
||||
- supports-color
|
||||
|
||||
'@modelcontextprotocol/sdk@1.25.3(hono@4.11.7)(zod@4.3.6)':
|
||||
dependencies:
|
||||
'@hono/node-server': 1.19.9(hono@4.11.7)
|
||||
ajv: 8.17.1
|
||||
ajv-formats: 3.0.1(ajv@8.17.1)
|
||||
content-type: 1.0.5
|
||||
cors: 2.8.6
|
||||
cross-spawn: 7.0.6
|
||||
eventsource: 3.0.7
|
||||
eventsource-parser: 3.0.6
|
||||
express: 5.2.1
|
||||
express-rate-limit: 7.5.1(express@5.2.1)
|
||||
jose: 6.1.3
|
||||
json-schema-typed: 8.0.2
|
||||
pkce-challenge: 5.0.1
|
||||
raw-body: 3.0.2
|
||||
zod: 4.3.6
|
||||
zod-to-json-schema: 3.25.1(zod@4.3.6)
|
||||
transitivePeerDependencies:
|
||||
- hono
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
'@mozilla/readability@0.6.0': {}
|
||||
|
||||
'@mswjs/interceptors@0.40.0':
|
||||
|
|
@ -7698,6 +7924,8 @@ snapshots:
|
|||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@swc/helpers@0.5.15':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
|
@ -7834,6 +8062,11 @@ snapshots:
|
|||
'@types/node': 25.0.10
|
||||
'@types/responselike': 1.0.3
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/cors@2.8.19':
|
||||
dependencies:
|
||||
'@types/node': 25.0.10
|
||||
|
|
@ -7842,6 +8075,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.8': {}
|
||||
|
||||
'@types/fs-extra@9.0.13':
|
||||
|
|
@ -8151,6 +8386,60 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@bcoe/v8-coverage': 1.0.2
|
||||
'@vitest/utils': 4.0.18
|
||||
ast-v8-to-istanbul: 0.3.10
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
istanbul-reports: 3.2.0
|
||||
magicast: 0.5.1
|
||||
obug: 2.1.1
|
||||
std-env: 3.10.0
|
||||
tinyrainbow: 3.0.3
|
||||
vitest: 4.0.18(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/expect@4.0.18':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/mocker@4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.0.18
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.12.7(@types/node@25.0.10)(typescript@5.9.3)
|
||||
vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
|
||||
'@vitest/pretty-format@4.0.18':
|
||||
dependencies:
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@vitest/runner@4.0.18':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.0.18
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.0.18':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.0.18': {}
|
||||
|
||||
'@vitest/utils@4.0.18':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@xmldom/xmldom@0.8.11': {}
|
||||
|
||||
accepts@1.3.8:
|
||||
|
|
@ -8363,6 +8652,8 @@ snapshots:
|
|||
assert-plus@1.0.0:
|
||||
optional: true
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
ast-types-flow@0.0.8: {}
|
||||
|
||||
ast-types@0.13.4:
|
||||
|
|
@ -8373,6 +8664,12 @@ snapshots:
|
|||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
ast-v8-to-istanbul@0.3.10:
|
||||
dependencies:
|
||||
'@jridgewell/trace-mapping': 0.3.31
|
||||
estree-walker: 3.0.3
|
||||
js-tokens: 9.0.1
|
||||
|
||||
astral-regex@2.0.0:
|
||||
optional: true
|
||||
|
||||
|
|
@ -8546,6 +8843,8 @@ snapshots:
|
|||
|
||||
caniuse-lite@1.0.30001766: {}
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
|
@ -9085,6 +9384,8 @@ snapshots:
|
|||
iterator.prototype: 1.1.5
|
||||
safe-array-concat: 1.1.3
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
es-object-atoms@1.1.1:
|
||||
dependencies:
|
||||
es-errors: 1.3.0
|
||||
|
|
@ -9184,7 +9485,7 @@ snapshots:
|
|||
eslint: 9.39.2(jiti@2.6.1)
|
||||
eslint-import-resolver-node: 0.3.9
|
||||
eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1))
|
||||
|
|
@ -9217,7 +9518,7 @@ snapshots:
|
|||
tinyglobby: 0.2.15
|
||||
unrs-resolver: 1.11.1
|
||||
optionalDependencies:
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1))
|
||||
eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
|
|
@ -9232,7 +9533,7 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)):
|
||||
eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.54.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)):
|
||||
dependencies:
|
||||
'@rtsao/scc': 1.1.0
|
||||
array-includes: 3.1.9
|
||||
|
|
@ -9443,6 +9744,10 @@ snapshots:
|
|||
|
||||
estraverse@5.3.0: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
|
@ -9480,6 +9785,8 @@ snapshots:
|
|||
strip-final-newline: 4.0.0
|
||||
yoctocolors: 2.1.2
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
express-rate-limit@7.5.1(express@5.2.1):
|
||||
dependencies:
|
||||
express: 5.2.1
|
||||
|
|
@ -9941,6 +10248,8 @@ snapshots:
|
|||
dependencies:
|
||||
lru-cache: 6.0.0
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
html-escaper@3.0.3: {}
|
||||
|
||||
htmlparser2@10.1.0:
|
||||
|
|
@ -10208,6 +10517,19 @@ snapshots:
|
|||
|
||||
isexe@3.1.1: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
|
||||
istanbul-lib-report@3.0.1:
|
||||
dependencies:
|
||||
istanbul-lib-coverage: 3.2.2
|
||||
make-dir: 4.0.0
|
||||
supports-color: 7.2.0
|
||||
|
||||
istanbul-reports@3.2.0:
|
||||
dependencies:
|
||||
html-escaper: 2.0.2
|
||||
istanbul-lib-report: 3.0.1
|
||||
|
||||
iterare@1.2.1: {}
|
||||
|
||||
iterator.prototype@1.1.5:
|
||||
|
|
@ -10243,6 +10565,8 @@ snapshots:
|
|||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-tokens@9.0.1: {}
|
||||
|
||||
js-yaml@4.1.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
|
@ -10440,6 +10764,16 @@ snapshots:
|
|||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
magicast@0.5.1:
|
||||
dependencies:
|
||||
'@babel/parser': 7.28.6
|
||||
'@babel/types': 7.28.6
|
||||
source-map-js: 1.2.1
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.3
|
||||
|
||||
marked@15.0.12: {}
|
||||
|
||||
matcher@3.0.0:
|
||||
|
|
@ -10687,6 +11021,8 @@ snapshots:
|
|||
define-properties: 1.2.1
|
||||
es-object-atoms: 1.1.1
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
on-finished@2.4.1:
|
||||
|
|
@ -10714,10 +11050,10 @@ snapshots:
|
|||
powershell-utils: 0.1.0
|
||||
wsl-utils: 0.3.1
|
||||
|
||||
openai@6.10.0(ws@8.18.3)(zod@3.25.76):
|
||||
openai@6.10.0(ws@8.18.3)(zod@4.3.6):
|
||||
optionalDependencies:
|
||||
ws: 8.18.3
|
||||
zod: 3.25.76
|
||||
zod: 4.3.6
|
||||
|
||||
optionator@0.9.4:
|
||||
dependencies:
|
||||
|
|
@ -10833,6 +11169,8 @@ snapshots:
|
|||
|
||||
path-type@4.0.0: {}
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
|
@ -11381,6 +11719,8 @@ snapshots:
|
|||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
|
@ -11476,6 +11816,8 @@ snapshots:
|
|||
|
||||
stable-hash@0.0.5: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
stat-mode@1.0.0: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
|
@ -11664,6 +12006,8 @@ snapshots:
|
|||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
|
|
@ -11671,6 +12015,8 @@ snapshots:
|
|||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tldts-core@7.0.19: {}
|
||||
|
||||
tldts@7.0.19:
|
||||
|
|
@ -11947,6 +12293,59 @@ snapshots:
|
|||
fsevents: 2.3.3
|
||||
lightningcss: 1.30.2
|
||||
|
||||
vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.2
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.57.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.10
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.6.1
|
||||
lightningcss: 1.30.2
|
||||
tsx: 4.21.0
|
||||
yaml: 2.8.2
|
||||
|
||||
vitest@4.0.18(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.0.18
|
||||
'@vitest/mocker': 4.0.18(msw@2.12.7(@types/node@25.0.10)(typescript@5.9.3))(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
'@vitest/runner': 4.0.18
|
||||
'@vitest/snapshot': 4.0.18
|
||||
'@vitest/spy': 4.0.18
|
||||
'@vitest/utils': 4.0.18
|
||||
es-module-lexer: 1.7.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.1
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.3
|
||||
std-env: 3.10.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.0.2
|
||||
tinyglobby: 0.2.15
|
||||
tinyrainbow: 3.0.3
|
||||
vite: 7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.0.10
|
||||
transitivePeerDependencies:
|
||||
- jiti
|
||||
- less
|
||||
- lightningcss
|
||||
- msw
|
||||
- sass
|
||||
- sass-embedded
|
||||
- stylus
|
||||
- sugarss
|
||||
- terser
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
which-boxed-primitive@1.1.1:
|
||||
|
|
@ -11998,6 +12397,11 @@ snapshots:
|
|||
dependencies:
|
||||
isexe: 3.1.1
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
||||
wrap-ansi@6.2.0:
|
||||
|
|
@ -12086,6 +12490,10 @@ snapshots:
|
|||
dependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
zod-to-json-schema@3.25.1(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
||||
zod-validation-error@4.0.2(zod@4.3.6):
|
||||
dependencies:
|
||||
zod: 4.3.6
|
||||
|
|
|
|||
259
src/agent/context-window/guard.test.ts
Normal file
259
src/agent/context-window/guard.test.ts
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
CONTEXT_WINDOW_HARD_MIN_TOKENS,
|
||||
CONTEXT_WINDOW_WARN_BELOW_TOKENS,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
resolveContextWindowInfo,
|
||||
evaluateContextWindowGuard,
|
||||
checkContextWindow,
|
||||
} from "./guard.js";
|
||||
|
||||
describe("guard", () => {
|
||||
describe("constants", () => {
|
||||
it("should have correct hard minimum tokens", () => {
|
||||
expect(CONTEXT_WINDOW_HARD_MIN_TOKENS).toBe(16_000);
|
||||
});
|
||||
|
||||
it("should have correct warning threshold tokens", () => {
|
||||
expect(CONTEXT_WINDOW_WARN_BELOW_TOKENS).toBe(32_000);
|
||||
});
|
||||
|
||||
it("should have correct default context tokens", () => {
|
||||
expect(DEFAULT_CONTEXT_TOKENS).toBe(200_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveContextWindowInfo", () => {
|
||||
it("should prioritize model context window", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: 100_000,
|
||||
configContextTokens: 50_000,
|
||||
defaultTokens: 200_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("model");
|
||||
});
|
||||
|
||||
it("should fall back to config when model is undefined", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: undefined,
|
||||
configContextTokens: 50_000,
|
||||
defaultTokens: 200_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should fall back to default when both model and config are undefined", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: undefined,
|
||||
configContextTokens: undefined,
|
||||
defaultTokens: 150_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(150_000);
|
||||
expect(result.source).toBe("default");
|
||||
});
|
||||
|
||||
it("should use DEFAULT_CONTEXT_TOKENS when no default provided", () => {
|
||||
const result = resolveContextWindowInfo({});
|
||||
|
||||
expect(result.tokens).toBe(DEFAULT_CONTEXT_TOKENS);
|
||||
expect(result.source).toBe("default");
|
||||
});
|
||||
|
||||
it("should ignore non-positive model values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: 0,
|
||||
configContextTokens: 50_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should ignore negative model values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: -1000,
|
||||
configContextTokens: 50_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should ignore NaN values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: NaN,
|
||||
configContextTokens: NaN,
|
||||
defaultTokens: 100_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("default");
|
||||
});
|
||||
|
||||
it("should ignore Infinity values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: Infinity,
|
||||
configContextTokens: 50_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("config");
|
||||
});
|
||||
|
||||
it("should floor decimal values", () => {
|
||||
const result = resolveContextWindowInfo({
|
||||
modelContextWindow: 100_000.9,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateContextWindowGuard", () => {
|
||||
it("should not warn or block when tokens are high enough", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 100_000, source: "model" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("model");
|
||||
});
|
||||
|
||||
it("should warn but not block when tokens are between thresholds", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 20_000, source: "config" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should both warn and block when tokens are below hard minimum", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 10_000, source: "default" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("should use custom thresholds", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 5_000, source: "model" },
|
||||
warnBelowTokens: 10_000,
|
||||
hardMinTokens: 3_000,
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should block with custom hard minimum", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 5_000, source: "model" },
|
||||
hardMinTokens: 8_000,
|
||||
});
|
||||
|
||||
expect(result.shouldBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle zero tokens", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 0, source: "model" },
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
expect(result.tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("should floor negative tokens to zero", () => {
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: -1000, source: "model" },
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(0);
|
||||
});
|
||||
|
||||
it("should ensure minimum threshold of 1", () => {
|
||||
// When tokens is 5 and thresholds are floored to 1,
|
||||
// 5 >= 1 so shouldWarn and shouldBlock are false
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 5, source: "model" },
|
||||
warnBelowTokens: 0,
|
||||
hardMinTokens: -100,
|
||||
});
|
||||
|
||||
// 5 is not < 1, so neither warn nor block
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should correctly apply floored threshold of 1", () => {
|
||||
// With tokens = 0, the condition (tokens > 0 && tokens < 1) is false
|
||||
// because tokens > 0 is false
|
||||
const result = evaluateContextWindowGuard({
|
||||
info: { tokens: 0, source: "model" },
|
||||
warnBelowTokens: 0,
|
||||
hardMinTokens: -100,
|
||||
});
|
||||
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkContextWindow", () => {
|
||||
it("should combine resolution and evaluation", () => {
|
||||
const result = checkContextWindow({
|
||||
modelContextWindow: 100_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(100_000);
|
||||
expect(result.source).toBe("model");
|
||||
expect(result.shouldWarn).toBe(false);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should warn for low config tokens", () => {
|
||||
const result = checkContextWindow({
|
||||
configContextTokens: 25_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(25_000);
|
||||
expect(result.source).toBe("config");
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
|
||||
it("should block for very low tokens", () => {
|
||||
const result = checkContextWindow({
|
||||
modelContextWindow: 8_000,
|
||||
});
|
||||
|
||||
expect(result.shouldBlock).toBe(true);
|
||||
});
|
||||
|
||||
it("should use all custom parameters", () => {
|
||||
const result = checkContextWindow({
|
||||
modelContextWindow: undefined,
|
||||
configContextTokens: undefined,
|
||||
defaultTokens: 50_000,
|
||||
warnBelowTokens: 60_000,
|
||||
hardMinTokens: 40_000,
|
||||
});
|
||||
|
||||
expect(result.tokens).toBe(50_000);
|
||||
expect(result.source).toBe("default");
|
||||
expect(result.shouldWarn).toBe(true);
|
||||
expect(result.shouldBlock).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
318
src/agent/context-window/token-estimation.test.ts
Normal file
318
src/agent/context-window/token-estimation.test.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
ESTIMATION_SAFETY_MARGIN,
|
||||
COMPACTION_TRIGGER_RATIO,
|
||||
COMPACTION_TARGET_RATIO,
|
||||
MIN_KEEP_MESSAGES,
|
||||
estimateSystemPromptTokens,
|
||||
estimateTokenUsage,
|
||||
shouldCompact,
|
||||
compactMessagesTokenAware,
|
||||
isMessageOversized,
|
||||
} from "./token-estimation.js";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
// Mock the external estimateTokens function
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
estimateTokens: (message: AgentMessage) => {
|
||||
// Simple mock: count characters / 4 (rough token estimate)
|
||||
if (message.role === "user") {
|
||||
const content = message.content;
|
||||
if (typeof content === "string") {
|
||||
return Math.ceil(content.length / 4);
|
||||
}
|
||||
return 50; // Default for complex content
|
||||
}
|
||||
if (message.role === "assistant") {
|
||||
const msg = message as any;
|
||||
if (typeof msg.content === "string") {
|
||||
return Math.ceil(msg.content.length / 4);
|
||||
}
|
||||
return 100; // Default for tool use content
|
||||
}
|
||||
return 50;
|
||||
},
|
||||
}));
|
||||
|
||||
describe("token-estimation", () => {
|
||||
describe("constants", () => {
|
||||
it("should have correct safety margin", () => {
|
||||
expect(ESTIMATION_SAFETY_MARGIN).toBe(1.2);
|
||||
});
|
||||
|
||||
it("should have correct compaction trigger ratio", () => {
|
||||
expect(COMPACTION_TRIGGER_RATIO).toBe(0.8);
|
||||
});
|
||||
|
||||
it("should have correct compaction target ratio", () => {
|
||||
expect(COMPACTION_TARGET_RATIO).toBe(0.5);
|
||||
});
|
||||
|
||||
it("should have correct minimum keep messages", () => {
|
||||
expect(MIN_KEEP_MESSAGES).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateSystemPromptTokens", () => {
|
||||
it("should return 0 for undefined system prompt", () => {
|
||||
expect(estimateSystemPromptTokens(undefined)).toBe(0);
|
||||
});
|
||||
|
||||
it("should return 0 for empty string", () => {
|
||||
expect(estimateSystemPromptTokens("")).toBe(0);
|
||||
});
|
||||
|
||||
it("should estimate tokens based on character count", () => {
|
||||
// ~3 chars per token
|
||||
expect(estimateSystemPromptTokens("abc")).toBe(1);
|
||||
expect(estimateSystemPromptTokens("abcdef")).toBe(2);
|
||||
expect(estimateSystemPromptTokens("abcdefghi")).toBe(3);
|
||||
});
|
||||
|
||||
it("should ceil the result", () => {
|
||||
// 4 chars / 3 = 1.33, should ceil to 2
|
||||
expect(estimateSystemPromptTokens("abcd")).toBe(2);
|
||||
});
|
||||
|
||||
it("should handle long prompts", () => {
|
||||
const longPrompt = "a".repeat(3000);
|
||||
expect(estimateSystemPromptTokens(longPrompt)).toBe(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("estimateTokenUsage", () => {
|
||||
it("should calculate token usage correctly", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "Hello world" }, // ~3 tokens
|
||||
{ role: "assistant", content: "Hi there!" }, // ~3 tokens
|
||||
];
|
||||
|
||||
const result = estimateTokenUsage({
|
||||
messages,
|
||||
systemPrompt: "You are a helpful assistant.", // ~10 tokens
|
||||
contextWindowTokens: 8000,
|
||||
});
|
||||
|
||||
expect(result.messageTokens).toBeGreaterThan(0);
|
||||
expect(result.systemPromptTokens).toBeGreaterThan(0);
|
||||
expect(result.availableTokens).toBeLessThan(8000);
|
||||
expect(result.utilizationRatio).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it("should use default reserve tokens when not specified", () => {
|
||||
const result = estimateTokenUsage({
|
||||
messages: [],
|
||||
contextWindowTokens: 8000,
|
||||
});
|
||||
|
||||
// Available = 8000 - 0 (no system) - 1024 (default reserve)
|
||||
expect(result.availableTokens).toBe(8000 - 1024);
|
||||
});
|
||||
|
||||
it("should use custom reserve tokens when specified", () => {
|
||||
const result = estimateTokenUsage({
|
||||
messages: [],
|
||||
contextWindowTokens: 8000,
|
||||
reserveTokens: 2000,
|
||||
});
|
||||
|
||||
expect(result.availableTokens).toBe(8000 - 2000);
|
||||
});
|
||||
|
||||
it("should not go negative for available tokens", () => {
|
||||
const result = estimateTokenUsage({
|
||||
messages: [],
|
||||
systemPrompt: "a".repeat(30000), // Huge system prompt
|
||||
contextWindowTokens: 8000,
|
||||
});
|
||||
|
||||
expect(result.availableTokens).toBe(0);
|
||||
});
|
||||
|
||||
it("should calculate utilization ratio with safety margin", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "a".repeat(400) }, // ~100 tokens
|
||||
];
|
||||
|
||||
const result = estimateTokenUsage({
|
||||
messages,
|
||||
contextWindowTokens: 2000,
|
||||
reserveTokens: 0,
|
||||
});
|
||||
|
||||
// Utilization = (tokens * 1.2) / available
|
||||
expect(result.utilizationRatio).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldCompact", () => {
|
||||
it("should return true when utilization >= 80%", () => {
|
||||
expect(shouldCompact({
|
||||
messageTokens: 800,
|
||||
systemPromptTokens: 0,
|
||||
availableTokens: 1000,
|
||||
utilizationRatio: 0.8
|
||||
})).toBe(true);
|
||||
|
||||
expect(shouldCompact({
|
||||
messageTokens: 900,
|
||||
systemPromptTokens: 0,
|
||||
availableTokens: 1000,
|
||||
utilizationRatio: 0.9
|
||||
})).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when utilization < 80%", () => {
|
||||
expect(shouldCompact({
|
||||
messageTokens: 700,
|
||||
systemPromptTokens: 0,
|
||||
availableTokens: 1000,
|
||||
utilizationRatio: 0.7
|
||||
})).toBe(false);
|
||||
|
||||
expect(shouldCompact({
|
||||
messageTokens: 100,
|
||||
systemPromptTokens: 0,
|
||||
availableTokens: 1000,
|
||||
utilizationRatio: 0.1
|
||||
})).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactMessagesTokenAware", () => {
|
||||
function createMessages(count: number): AgentMessage[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
role: "user" as const,
|
||||
content: `Message ${i}: ${"x".repeat(100)}`, // Each ~28 tokens
|
||||
}));
|
||||
}
|
||||
|
||||
it("should return null if too few messages", () => {
|
||||
const messages = createMessages(5);
|
||||
const result = compactMessagesTokenAware(messages, 10000);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null if already within target", () => {
|
||||
const messages = createMessages(15);
|
||||
// Very large available tokens means no compaction needed
|
||||
const result = compactMessagesTokenAware(messages, 100000);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should compact messages when over target", () => {
|
||||
const messages = createMessages(20);
|
||||
// Small available tokens should trigger compaction
|
||||
const result = compactMessagesTokenAware(messages, 200, {
|
||||
targetRatio: 0.5,
|
||||
minKeepMessages: 5,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
expect(result.kept.length).toBeLessThan(20);
|
||||
expect(result.removedCount).toBeGreaterThan(0);
|
||||
expect(result.tokensRemoved).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("should keep at least minKeepMessages", () => {
|
||||
const messages = createMessages(20);
|
||||
const result = compactMessagesTokenAware(messages, 10, {
|
||||
targetRatio: 0.1,
|
||||
minKeepMessages: 12,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
expect(result.kept.length).toBeGreaterThanOrEqual(12);
|
||||
}
|
||||
});
|
||||
|
||||
it("should keep newest messages (from the end)", () => {
|
||||
const messages: AgentMessage[] = [
|
||||
{ role: "user", content: "Old message 1" },
|
||||
{ role: "user", content: "Old message 2" },
|
||||
{ role: "user", content: "Old message 3" },
|
||||
{ role: "user", content: "Old message 4" },
|
||||
{ role: "user", content: "Old message 5" },
|
||||
{ role: "user", content: "Old message 6" },
|
||||
{ role: "user", content: "Old message 7" },
|
||||
{ role: "user", content: "Old message 8" },
|
||||
{ role: "user", content: "Old message 9" },
|
||||
{ role: "user", content: "Old message 10" },
|
||||
{ role: "user", content: "Old message 11" },
|
||||
{ role: "user", content: "Newer message 12" },
|
||||
{ role: "user", content: "Newest message 13" },
|
||||
];
|
||||
|
||||
const result = compactMessagesTokenAware(messages, 50, {
|
||||
targetRatio: 0.5,
|
||||
minKeepMessages: 3,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
// Should keep the newest messages
|
||||
const lastKept = result.kept[result.kept.length - 1];
|
||||
expect((lastKept as any).content).toContain("Newest");
|
||||
}
|
||||
});
|
||||
|
||||
it("should use default options when not specified", () => {
|
||||
const messages = createMessages(15);
|
||||
const result = compactMessagesTokenAware(messages, 100);
|
||||
// Should use default targetRatio (0.5) and minKeepMessages (10)
|
||||
if (result) {
|
||||
expect(result.kept.length).toBeGreaterThanOrEqual(MIN_KEEP_MESSAGES);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("isMessageOversized", () => {
|
||||
it("should return true for oversized message", () => {
|
||||
const message: AgentMessage = {
|
||||
role: "user",
|
||||
content: "x".repeat(4000), // ~1000 tokens
|
||||
};
|
||||
|
||||
// With default maxRatio 0.5, 1000 tokens in 1000 context = 100% > 50%
|
||||
expect(isMessageOversized(message, 1000)).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for small message", () => {
|
||||
const message: AgentMessage = {
|
||||
role: "user",
|
||||
content: "Hello", // ~2 tokens
|
||||
};
|
||||
|
||||
expect(isMessageOversized(message, 10000)).toBe(false);
|
||||
});
|
||||
|
||||
it("should use custom maxRatio", () => {
|
||||
const message: AgentMessage = {
|
||||
role: "user",
|
||||
content: "x".repeat(400), // ~100 tokens
|
||||
};
|
||||
|
||||
// With safety margin 1.2, 100 * 1.2 = 120 tokens
|
||||
// 120 > 1000 * 0.1 = 100, so oversized
|
||||
expect(isMessageOversized(message, 1000, 0.1)).toBe(true);
|
||||
|
||||
// 120 < 1000 * 0.2 = 200, so not oversized
|
||||
expect(isMessageOversized(message, 1000, 0.2)).toBe(false);
|
||||
});
|
||||
|
||||
it("should apply safety margin to token count", () => {
|
||||
const message: AgentMessage = {
|
||||
role: "user",
|
||||
content: "x".repeat(400), // ~100 tokens, with margin ~120
|
||||
};
|
||||
|
||||
// Without margin: 100 < 250 (50% of 500)
|
||||
// With margin: 120 < 250, still ok
|
||||
expect(isMessageOversized(message, 500, 0.5)).toBe(false);
|
||||
|
||||
// Without margin: 100 < 100 would be false
|
||||
// With margin: 120 > 100, should be true
|
||||
expect(isMessageOversized(message, 200, 0.5)).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
245
src/agent/profile/storage.test.ts
Normal file
245
src/agent/profile/storage.test.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
getProfileDir,
|
||||
ensureProfileDir,
|
||||
profileExists,
|
||||
readProfileFile,
|
||||
writeProfileFile,
|
||||
loadProfile,
|
||||
saveProfile,
|
||||
} from "./storage.js";
|
||||
|
||||
describe("storage", () => {
|
||||
const testBaseDir = join(tmpdir(), `multica-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
// Create fresh test directory
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(testBaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up test directory
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("getProfileDir", () => {
|
||||
it("should return correct path with custom baseDir", () => {
|
||||
const result = getProfileDir("test-profile", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "test-profile"));
|
||||
});
|
||||
|
||||
it("should handle profile IDs with special characters", () => {
|
||||
const result = getProfileDir("profile-with-dashes", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "profile-with-dashes"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureProfileDir", () => {
|
||||
it("should create directory if it does not exist", () => {
|
||||
const profileId = "new-profile";
|
||||
const dir = ensureProfileDir(profileId, { baseDir: testBaseDir });
|
||||
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
expect(dir).toBe(join(testBaseDir, profileId));
|
||||
});
|
||||
|
||||
it("should not fail if directory already exists", () => {
|
||||
const profileId = "existing-profile";
|
||||
const expectedDir = join(testBaseDir, profileId);
|
||||
|
||||
mkdirSync(expectedDir, { recursive: true });
|
||||
|
||||
const dir = ensureProfileDir(profileId, { baseDir: testBaseDir });
|
||||
expect(dir).toBe(expectedDir);
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profileExists", () => {
|
||||
it("should return false for non-existent profile", () => {
|
||||
const result = profileExists("non-existent", { baseDir: testBaseDir });
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should return true for existing profile", () => {
|
||||
const profileId = "existing";
|
||||
mkdirSync(join(testBaseDir, profileId), { recursive: true });
|
||||
|
||||
const result = profileExists(profileId, { baseDir: testBaseDir });
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readProfileFile", () => {
|
||||
it("should return undefined for non-existent file", () => {
|
||||
const profileId = "profile";
|
||||
mkdirSync(join(testBaseDir, profileId), { recursive: true });
|
||||
|
||||
const result = readProfileFile(profileId, "missing.md", { baseDir: testBaseDir });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return file contents for existing file", () => {
|
||||
const profileId = "profile";
|
||||
const fileName = "test.md";
|
||||
const content = "# Test Content\n\nThis is a test.";
|
||||
const dir = join(testBaseDir, profileId);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, fileName), content);
|
||||
|
||||
const result = readProfileFile(profileId, fileName, { baseDir: testBaseDir });
|
||||
expect(result).toBe(content);
|
||||
});
|
||||
|
||||
it("should return undefined for non-existent profile directory", () => {
|
||||
const result = readProfileFile("non-existent", "file.md", { baseDir: testBaseDir });
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeProfileFile", () => {
|
||||
it("should create file in existing directory", () => {
|
||||
const profileId = "profile";
|
||||
const fileName = "test.md";
|
||||
const content = "Test content";
|
||||
|
||||
writeProfileFile(profileId, fileName, content, { baseDir: testBaseDir });
|
||||
|
||||
const filePath = join(testBaseDir, profileId, fileName);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf-8")).toBe(content);
|
||||
});
|
||||
|
||||
it("should create directory if it does not exist", () => {
|
||||
const profileId = "new-profile";
|
||||
const fileName = "test.md";
|
||||
const content = "Test content";
|
||||
|
||||
writeProfileFile(profileId, fileName, content, { baseDir: testBaseDir });
|
||||
|
||||
const filePath = join(testBaseDir, profileId, fileName);
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
});
|
||||
|
||||
it("should overwrite existing file", () => {
|
||||
const profileId = "profile";
|
||||
const fileName = "test.md";
|
||||
const dir = join(testBaseDir, profileId);
|
||||
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, fileName), "Original content");
|
||||
|
||||
writeProfileFile(profileId, fileName, "New content", { baseDir: testBaseDir });
|
||||
|
||||
expect(readFileSync(join(dir, fileName), "utf-8")).toBe("New content");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadProfile", () => {
|
||||
it("should load all profile files", () => {
|
||||
const profileId = "full-profile";
|
||||
const dir = join(testBaseDir, profileId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
writeFileSync(join(dir, "SOUL.md"), "Soul content");
|
||||
writeFileSync(join(dir, "IDENTITY.md"), "Identity content");
|
||||
writeFileSync(join(dir, "TOOLS.md"), "Tools content");
|
||||
writeFileSync(join(dir, "MEMORY.md"), "Memory content");
|
||||
writeFileSync(join(dir, "BOOTSTRAP.md"), "Bootstrap content");
|
||||
|
||||
const profile = loadProfile(profileId, { baseDir: testBaseDir });
|
||||
|
||||
expect(profile.id).toBe(profileId);
|
||||
expect(profile.soul).toBe("Soul content");
|
||||
expect(profile.identity).toBe("Identity content");
|
||||
expect(profile.tools).toBe("Tools content");
|
||||
expect(profile.memory).toBe("Memory content");
|
||||
expect(profile.bootstrap).toBe("Bootstrap content");
|
||||
});
|
||||
|
||||
it("should return undefined for missing files", () => {
|
||||
const profileId = "partial-profile";
|
||||
const dir = join(testBaseDir, profileId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
writeFileSync(join(dir, "SOUL.md"), "Soul only");
|
||||
|
||||
const profile = loadProfile(profileId, { baseDir: testBaseDir });
|
||||
|
||||
expect(profile.id).toBe(profileId);
|
||||
expect(profile.soul).toBe("Soul only");
|
||||
expect(profile.identity).toBeUndefined();
|
||||
expect(profile.tools).toBeUndefined();
|
||||
expect(profile.memory).toBeUndefined();
|
||||
expect(profile.bootstrap).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle non-existent profile", () => {
|
||||
const profile = loadProfile("non-existent", { baseDir: testBaseDir });
|
||||
|
||||
expect(profile.id).toBe("non-existent");
|
||||
expect(profile.soul).toBeUndefined();
|
||||
expect(profile.identity).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saveProfile", () => {
|
||||
it("should save all defined profile fields", () => {
|
||||
const profile = {
|
||||
id: "save-test",
|
||||
soul: "Soul data",
|
||||
identity: "Identity data",
|
||||
tools: "Tools data",
|
||||
memory: "Memory data",
|
||||
bootstrap: "Bootstrap data",
|
||||
};
|
||||
|
||||
saveProfile(profile, { baseDir: testBaseDir });
|
||||
|
||||
const dir = join(testBaseDir, profile.id);
|
||||
expect(readFileSync(join(dir, "SOUL.md"), "utf-8")).toBe("Soul data");
|
||||
expect(readFileSync(join(dir, "IDENTITY.md"), "utf-8")).toBe("Identity data");
|
||||
expect(readFileSync(join(dir, "TOOLS.md"), "utf-8")).toBe("Tools data");
|
||||
expect(readFileSync(join(dir, "MEMORY.md"), "utf-8")).toBe("Memory data");
|
||||
expect(readFileSync(join(dir, "BOOTSTRAP.md"), "utf-8")).toBe("Bootstrap data");
|
||||
});
|
||||
|
||||
it("should only save defined fields", () => {
|
||||
const profile = {
|
||||
id: "partial-save",
|
||||
soul: "Soul only",
|
||||
identity: undefined,
|
||||
tools: undefined,
|
||||
memory: undefined,
|
||||
bootstrap: undefined,
|
||||
};
|
||||
|
||||
saveProfile(profile, { baseDir: testBaseDir });
|
||||
|
||||
const dir = join(testBaseDir, profile.id);
|
||||
expect(existsSync(join(dir, "SOUL.md"))).toBe(true);
|
||||
expect(existsSync(join(dir, "IDENTITY.md"))).toBe(false);
|
||||
expect(existsSync(join(dir, "TOOLS.md"))).toBe(false);
|
||||
});
|
||||
|
||||
it("should create profile directory if needed", () => {
|
||||
const profile = {
|
||||
id: "new-save-profile",
|
||||
soul: "Content",
|
||||
};
|
||||
|
||||
saveProfile(profile, { baseDir: testBaseDir });
|
||||
|
||||
expect(existsSync(join(testBaseDir, profile.id))).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
287
src/agent/session/compaction.test.ts
Normal file
287
src/agent/session/compaction.test.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
compactMessagesByCount,
|
||||
compactMessagesByTokens,
|
||||
compactMessages,
|
||||
type CompactionResult,
|
||||
} from "./compaction.js";
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
|
||||
// Mock the token estimation functions
|
||||
vi.mock("../context-window/index.js", async () => {
|
||||
const actual = await vi.importActual("../context-window/index.js");
|
||||
return {
|
||||
...(actual as object),
|
||||
estimateMessagesTokens: (messages: AgentMessage[]) => {
|
||||
// Simple mock: 10 tokens per message
|
||||
return messages.length * 10;
|
||||
},
|
||||
compactMessagesTokenAware: (
|
||||
messages: AgentMessage[],
|
||||
availableTokens: number,
|
||||
options?: { targetRatio?: number; minKeepMessages?: number },
|
||||
) => {
|
||||
const minKeep = options?.minKeepMessages ?? 10;
|
||||
if (messages.length <= minKeep) return null;
|
||||
|
||||
const targetTokens = availableTokens * (options?.targetRatio ?? 0.5);
|
||||
const currentTokens = messages.length * 10;
|
||||
if (currentTokens <= targetTokens) return null;
|
||||
|
||||
// Keep enough messages to be under target
|
||||
const keepCount = Math.max(minKeep, Math.floor(targetTokens / 10));
|
||||
const kept = messages.slice(-keepCount);
|
||||
|
||||
return {
|
||||
kept,
|
||||
removedCount: messages.length - kept.length,
|
||||
tokensRemoved: (messages.length - kept.length) * 10,
|
||||
tokensKept: kept.length * 10,
|
||||
};
|
||||
},
|
||||
estimateTokenUsage: (params: any) => {
|
||||
const messageTokens = params.messages.length * 10;
|
||||
const systemPromptTokens = params.systemPrompt ? 100 : 0;
|
||||
const reserve = params.reserveTokens ?? 1024;
|
||||
const availableTokens = Math.max(0, params.contextWindowTokens - systemPromptTokens - reserve);
|
||||
const utilizationRatio = availableTokens > 0 ? (messageTokens * 1.2) / availableTokens : 1;
|
||||
|
||||
return {
|
||||
messageTokens,
|
||||
systemPromptTokens,
|
||||
availableTokens,
|
||||
utilizationRatio,
|
||||
};
|
||||
},
|
||||
shouldCompact: (estimation: any) => estimation.utilizationRatio >= 0.8,
|
||||
compactMessagesWithSummary: vi.fn(),
|
||||
compactMessagesWithChunkedSummary: vi.fn(),
|
||||
COMPACTION_TARGET_RATIO: 0.5,
|
||||
MIN_KEEP_MESSAGES: 10,
|
||||
};
|
||||
});
|
||||
|
||||
describe("compaction", () => {
|
||||
function createMessages(count: number, prefix = "Message"): AgentMessage[] {
|
||||
return Array.from({ length: count }, (_, i) => ({
|
||||
role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
|
||||
content: `${prefix} ${i}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function createMessagesWithToolUse(): AgentMessage[] {
|
||||
return [
|
||||
{ role: "user", content: "Start" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "tool-1", name: "test", input: {} }],
|
||||
} as any,
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "tool-1", content: "Result" }],
|
||||
} as any,
|
||||
{ role: "assistant", content: "Done" },
|
||||
{ role: "user", content: "Next message" },
|
||||
];
|
||||
}
|
||||
|
||||
describe("compactMessagesByCount", () => {
|
||||
it("should return null when under max messages", () => {
|
||||
const messages = createMessages(50);
|
||||
const result = compactMessagesByCount(messages, 80, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should compact when over max messages", () => {
|
||||
const messages = createMessages(100);
|
||||
const result = compactMessagesByCount(messages, 80, 60);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.reason).toBe("count");
|
||||
expect(result!.kept.length).toBeLessThanOrEqual(100);
|
||||
expect(result!.removedCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should keep the specified number of last messages", () => {
|
||||
const messages = createMessages(100);
|
||||
const result = compactMessagesByCount(messages, 80, 50);
|
||||
|
||||
if (result) {
|
||||
// Should keep approximately keepLast messages
|
||||
expect(result.kept.length).toBeGreaterThanOrEqual(40);
|
||||
expect(result.kept.length).toBeLessThanOrEqual(60);
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null when exact at max messages", () => {
|
||||
const messages = createMessages(80);
|
||||
const result = compactMessagesByCount(messages, 80, 60);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should not break tool_use/tool_result pairs", () => {
|
||||
// Create many messages followed by a tool pair
|
||||
const regularMessages = createMessages(70);
|
||||
const toolMessages = createMessagesWithToolUse();
|
||||
const messages = [...regularMessages, ...toolMessages];
|
||||
|
||||
const result = compactMessagesByCount(messages, 80, 20);
|
||||
|
||||
if (result) {
|
||||
// Check that we didn't end up with orphaned tool_result
|
||||
let hasOrphanedToolResult = false;
|
||||
for (let i = 0; i < result.kept.length; i++) {
|
||||
const msg = result.kept[i] as any;
|
||||
if (Array.isArray(msg.content)) {
|
||||
const hasToolResult = msg.content.some((b: any) => b.type === "tool_result");
|
||||
if (hasToolResult) {
|
||||
// Check if previous message has corresponding tool_use
|
||||
const prevMsg = result.kept[i - 1] as any;
|
||||
if (!prevMsg || !Array.isArray(prevMsg.content)) {
|
||||
hasOrphanedToolResult = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// This test verifies the safe compaction point logic
|
||||
// The exact behavior depends on findSafeCompactionPoint implementation
|
||||
}
|
||||
});
|
||||
|
||||
it("should return null when would keep almost all messages", () => {
|
||||
const messages = createMessages(85);
|
||||
const result = compactMessagesByCount(messages, 80, 82);
|
||||
|
||||
// If we'd only remove 2-3 messages, should return null
|
||||
if (result) {
|
||||
expect(result.removedCount).toBeGreaterThan(2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactMessagesByTokens", () => {
|
||||
it("should return null when under token limit", () => {
|
||||
const messages = createMessages(5);
|
||||
// 5 messages * 10 tokens = 50 tokens, target = 1000 * 0.5 = 500
|
||||
const result = compactMessagesByTokens(messages, 1000);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should compact when over token limit", () => {
|
||||
const messages = createMessages(100);
|
||||
// 100 messages * 10 tokens = 1000 tokens, target = 200 * 0.5 = 100
|
||||
const result = compactMessagesByTokens(messages, 200, {
|
||||
targetRatio: 0.5,
|
||||
minKeepMessages: 5,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.reason).toBe("tokens");
|
||||
expect(result!.tokensRemoved).toBeGreaterThan(0);
|
||||
expect(result!.tokensKept).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should respect minKeepMessages", () => {
|
||||
const messages = createMessages(20);
|
||||
const result = compactMessagesByTokens(messages, 50, {
|
||||
minKeepMessages: 15,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
expect(result.kept.length).toBeGreaterThanOrEqual(15);
|
||||
}
|
||||
});
|
||||
|
||||
it("should use default options when not specified", () => {
|
||||
const messages = createMessages(50);
|
||||
const result = compactMessagesByTokens(messages, 100);
|
||||
|
||||
if (result) {
|
||||
expect(result.kept.length).toBeGreaterThanOrEqual(10); // Default minKeepMessages
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("compactMessages (unified entry point)", () => {
|
||||
describe("count mode", () => {
|
||||
it("should use count-based compaction", () => {
|
||||
const messages = createMessages(100);
|
||||
const result = compactMessages(messages, {
|
||||
mode: "count",
|
||||
maxMessages: 80,
|
||||
keepLast: 60,
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.reason).toBe("count");
|
||||
});
|
||||
|
||||
it("should use default max and keep values", () => {
|
||||
const messages = createMessages(100);
|
||||
const result = compactMessages(messages, {
|
||||
mode: "count",
|
||||
});
|
||||
|
||||
// Default: maxMessages: 80, keepLast: 60
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.reason).toBe("count");
|
||||
});
|
||||
});
|
||||
|
||||
describe("tokens mode", () => {
|
||||
it("should use token-based compaction when utilization is high", () => {
|
||||
const messages = createMessages(100);
|
||||
// 100 * 10 = 1000 message tokens
|
||||
// System: 100 tokens, Reserve: 1024
|
||||
// Available: 2000 - 100 - 1024 = 876
|
||||
// Utilization: (1000 * 1.2) / 876 = 1.37 > 0.8
|
||||
const result = compactMessages(messages, {
|
||||
mode: "tokens",
|
||||
contextWindowTokens: 2000,
|
||||
systemPrompt: "System prompt",
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.reason).toBe("tokens");
|
||||
});
|
||||
|
||||
it("should return null when utilization is low", () => {
|
||||
const messages = createMessages(5);
|
||||
// 5 * 10 = 50 message tokens
|
||||
// Available: 10000 - 100 - 1024 = 8876
|
||||
// Utilization: (50 * 1.2) / 8876 = 0.007 < 0.8
|
||||
const result = compactMessages(messages, {
|
||||
mode: "tokens",
|
||||
contextWindowTokens: 10000,
|
||||
systemPrompt: "System prompt",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should use default context window tokens", () => {
|
||||
const messages = createMessages(5);
|
||||
const result = compactMessages(messages, {
|
||||
mode: "tokens",
|
||||
});
|
||||
|
||||
// Default: 200_000 tokens, very low utilization
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("should pass through target ratio and min keep messages", () => {
|
||||
const messages = createMessages(50);
|
||||
const result = compactMessages(messages, {
|
||||
mode: "tokens",
|
||||
contextWindowTokens: 1000,
|
||||
targetRatio: 0.3,
|
||||
minKeepMessages: 20,
|
||||
});
|
||||
|
||||
if (result) {
|
||||
expect(result.kept.length).toBeGreaterThanOrEqual(20);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
277
src/agent/session/storage.test.ts
Normal file
277
src/agent/session/storage.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import {
|
||||
resolveBaseDir,
|
||||
resolveSessionDir,
|
||||
resolveSessionPath,
|
||||
ensureSessionDir,
|
||||
readEntries,
|
||||
appendEntry,
|
||||
writeEntries,
|
||||
} from "./storage.js";
|
||||
import type { SessionEntry } from "./types.js";
|
||||
|
||||
describe("session/storage", () => {
|
||||
const testBaseDir = join(tmpdir(), `multica-session-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(testBaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveBaseDir", () => {
|
||||
it("should return custom baseDir when provided", () => {
|
||||
const result = resolveBaseDir({ baseDir: "/custom/path" });
|
||||
expect(result).toBe("/custom/path");
|
||||
});
|
||||
|
||||
it("should return default path when no options provided", () => {
|
||||
const result = resolveBaseDir();
|
||||
expect(result).toContain(".super-multica");
|
||||
expect(result).toContain("sessions");
|
||||
});
|
||||
|
||||
it("should return default path when options is empty", () => {
|
||||
const result = resolveBaseDir({});
|
||||
expect(result).toContain("sessions");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionDir", () => {
|
||||
it("should return session directory path", () => {
|
||||
const result = resolveSessionDir("test-session", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "test-session"));
|
||||
});
|
||||
|
||||
it("should handle session IDs with special characters", () => {
|
||||
const result = resolveSessionDir("session-123-abc", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "session-123-abc"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveSessionPath", () => {
|
||||
it("should return path to session.jsonl file", () => {
|
||||
const result = resolveSessionPath("test-session", { baseDir: testBaseDir });
|
||||
expect(result).toBe(join(testBaseDir, "test-session", "session.jsonl"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureSessionDir", () => {
|
||||
it("should create session directory if it does not exist", () => {
|
||||
const sessionId = "new-session";
|
||||
ensureSessionDir(sessionId, { baseDir: testBaseDir });
|
||||
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("should not fail if directory already exists", () => {
|
||||
const sessionId = "existing-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
expect(() => ensureSessionDir(sessionId, { baseDir: testBaseDir })).not.toThrow();
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readEntries", () => {
|
||||
it("should return empty array for non-existent session", () => {
|
||||
const entries = readEntries("non-existent", { baseDir: testBaseDir });
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return empty array for empty file", () => {
|
||||
const sessionId = "empty-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "session.jsonl"), "");
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("should parse valid JSONL entries", () => {
|
||||
const sessionId = "valid-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const entry1: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "Hello" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
const entry2: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "assistant", content: "Hi there" },
|
||||
timestamp: 2000,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(dir, "session.jsonl"),
|
||||
`${JSON.stringify(entry1)}\n${JSON.stringify(entry2)}\n`
|
||||
);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toEqual(entry1);
|
||||
expect(entries[1]).toEqual(entry2);
|
||||
});
|
||||
|
||||
it("should skip malformed lines", () => {
|
||||
const sessionId = "malformed-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const validEntry: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "Valid" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
|
||||
writeFileSync(
|
||||
join(dir, "session.jsonl"),
|
||||
`${JSON.stringify(validEntry)}\nnot valid json\n{broken: json}\n`
|
||||
);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual(validEntry);
|
||||
});
|
||||
|
||||
it("should handle meta entries", () => {
|
||||
const sessionId = "meta-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const metaEntry: SessionEntry = {
|
||||
type: "meta",
|
||||
meta: { provider: "anthropic", model: "claude-3" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
|
||||
writeFileSync(join(dir, "session.jsonl"), `${JSON.stringify(metaEntry)}\n`);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual(metaEntry);
|
||||
});
|
||||
|
||||
it("should handle compaction entries", () => {
|
||||
const sessionId = "compaction-session";
|
||||
const dir = join(testBaseDir, sessionId);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
|
||||
const compactionEntry: SessionEntry = {
|
||||
type: "compaction",
|
||||
removed: 10,
|
||||
kept: 5,
|
||||
timestamp: 1000,
|
||||
tokensRemoved: 500,
|
||||
tokensKept: 200,
|
||||
reason: "tokens",
|
||||
};
|
||||
|
||||
writeFileSync(join(dir, "session.jsonl"), `${JSON.stringify(compactionEntry)}\n`);
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual(compactionEntry);
|
||||
});
|
||||
});
|
||||
|
||||
describe("appendEntry", () => {
|
||||
it("should create file and append entry", async () => {
|
||||
const sessionId = "append-session";
|
||||
const entry: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "Hello" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
|
||||
await appendEntry(sessionId, entry, { baseDir: testBaseDir });
|
||||
|
||||
const filePath = join(testBaseDir, sessionId, "session.jsonl");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
expect(content).toBe(`${JSON.stringify(entry)}\n`);
|
||||
});
|
||||
|
||||
it("should append to existing file", async () => {
|
||||
const sessionId = "append-existing";
|
||||
const entry1: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "user", content: "First" },
|
||||
timestamp: 1000,
|
||||
};
|
||||
const entry2: SessionEntry = {
|
||||
type: "message",
|
||||
message: { role: "assistant", content: "Second" },
|
||||
timestamp: 2000,
|
||||
};
|
||||
|
||||
await appendEntry(sessionId, entry1, { baseDir: testBaseDir });
|
||||
await appendEntry(sessionId, entry2, { baseDir: testBaseDir });
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0]).toEqual(entry1);
|
||||
expect(entries[1]).toEqual(entry2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeEntries", () => {
|
||||
it("should write all entries to file", async () => {
|
||||
const sessionId = "write-session";
|
||||
const entries: SessionEntry[] = [
|
||||
{ type: "message", message: { role: "user", content: "One" }, timestamp: 1000 },
|
||||
{ type: "message", message: { role: "assistant", content: "Two" }, timestamp: 2000 },
|
||||
];
|
||||
|
||||
await writeEntries(sessionId, entries, { baseDir: testBaseDir });
|
||||
|
||||
const readBack = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(readBack).toHaveLength(2);
|
||||
expect(readBack).toEqual(entries);
|
||||
});
|
||||
|
||||
it("should overwrite existing entries", async () => {
|
||||
const sessionId = "overwrite-session";
|
||||
|
||||
await writeEntries(
|
||||
sessionId,
|
||||
[{ type: "message", message: { role: "user", content: "Old" }, timestamp: 1000 }],
|
||||
{ baseDir: testBaseDir }
|
||||
);
|
||||
|
||||
const newEntries: SessionEntry[] = [
|
||||
{ type: "message", message: { role: "user", content: "New" }, timestamp: 2000 },
|
||||
];
|
||||
await writeEntries(sessionId, newEntries, { baseDir: testBaseDir });
|
||||
|
||||
const entries = readEntries(sessionId, { baseDir: testBaseDir });
|
||||
expect(entries).toHaveLength(1);
|
||||
expect((entries[0] as any).message.content).toBe("New");
|
||||
});
|
||||
|
||||
it("should handle empty entries array", async () => {
|
||||
const sessionId = "empty-write";
|
||||
await writeEntries(sessionId, [], { baseDir: testBaseDir });
|
||||
|
||||
const filePath = join(testBaseDir, sessionId, "session.jsonl");
|
||||
expect(existsSync(filePath)).toBe(true);
|
||||
expect(readFileSync(filePath, "utf8")).toBe("");
|
||||
});
|
||||
});
|
||||
});
|
||||
332
src/agent/skills/eligibility.test.ts
Normal file
332
src/agent/skills/eligibility.test.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { checkEligibility, filterEligibleSkills } from "./eligibility.js";
|
||||
import type { Skill, SkillFrontmatter, EligibilityResult } from "./types.js";
|
||||
|
||||
// Helper to create a skill for testing
|
||||
function createSkill(
|
||||
id: string,
|
||||
frontmatter: Partial<SkillFrontmatter> & { name: string },
|
||||
): Skill {
|
||||
return {
|
||||
id,
|
||||
frontmatter: frontmatter as SkillFrontmatter,
|
||||
instructions: "Test instructions",
|
||||
source: "bundled",
|
||||
filePath: `/path/to/${id}/SKILL.md`,
|
||||
};
|
||||
}
|
||||
|
||||
describe("eligibility", () => {
|
||||
describe("checkEligibility", () => {
|
||||
describe("platform requirements", () => {
|
||||
it("should be eligible when no platform requirement specified", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should be eligible when current platform matches", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
platforms: ["darwin", "linux"],
|
||||
},
|
||||
});
|
||||
|
||||
expect(checkEligibility(skill, "darwin").eligible).toBe(true);
|
||||
expect(checkEligibility(skill, "linux").eligible).toBe(true);
|
||||
});
|
||||
|
||||
it("should be ineligible when platform does not match", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
platforms: ["darwin"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "win32");
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons).toContain(
|
||||
"Platform 'win32' not supported (requires: darwin)",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty platforms array as no requirement", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
platforms: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("binary requirements", () => {
|
||||
it("should be eligible when required binary exists", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresBinaries: ["node"],
|
||||
},
|
||||
});
|
||||
|
||||
// node should exist in the test environment
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it("should be ineligible when required binary does not exist", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresBinaries: ["nonexistent-binary-xyz-123"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons).toContainEqual(
|
||||
expect.stringContaining("Required binary not found: nonexistent-binary-xyz-123"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should check all binaries and report all missing", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresBinaries: ["node", "missing-bin-1", "missing-bin-2"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons?.length).toBe(2);
|
||||
expect(result.reasons).toContainEqual(
|
||||
expect.stringContaining("missing-bin-1"),
|
||||
);
|
||||
expect(result.reasons).toContainEqual(
|
||||
expect.stringContaining("missing-bin-2"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle empty binaries array", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresBinaries: [],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("environment variable requirements", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should be eligible when required env vars exist", () => {
|
||||
process.env.TEST_VAR = "value";
|
||||
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresEnv: ["TEST_VAR"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it("should be eligible even if env var is empty string", () => {
|
||||
process.env.EMPTY_VAR = "";
|
||||
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresEnv: ["EMPTY_VAR"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it("should be ineligible when required env var does not exist", () => {
|
||||
delete process.env.MISSING_VAR;
|
||||
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresEnv: ["MISSING_VAR"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons).toContainEqual(
|
||||
expect.stringContaining("Required environment variable not set: MISSING_VAR"),
|
||||
);
|
||||
});
|
||||
|
||||
it("should check all env vars and report all missing", () => {
|
||||
process.env.EXISTS = "yes";
|
||||
delete process.env.MISSING_1;
|
||||
delete process.env.MISSING_2;
|
||||
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
requiresEnv: ["EXISTS", "MISSING_1", "MISSING_2"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("combined requirements", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should collect all failure reasons", () => {
|
||||
delete process.env.MISSING_VAR;
|
||||
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
platforms: ["win32"],
|
||||
requiresBinaries: ["missing-binary"],
|
||||
requiresEnv: ["MISSING_VAR"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(false);
|
||||
expect(result.reasons?.length).toBe(3);
|
||||
});
|
||||
|
||||
it("should be eligible when all requirements met", () => {
|
||||
process.env.REQUIRED_VAR = "value";
|
||||
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
platforms: ["darwin", "linux"],
|
||||
requiresBinaries: ["node"],
|
||||
requiresEnv: ["REQUIRED_VAR"],
|
||||
},
|
||||
});
|
||||
|
||||
const result = checkEligibility(skill, "darwin");
|
||||
expect(result.eligible).toBe(true);
|
||||
expect(result.reasons).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("should use process.platform by default", () => {
|
||||
const skill = createSkill("test", {
|
||||
name: "Test Skill",
|
||||
metadata: {
|
||||
platforms: [process.platform],
|
||||
},
|
||||
});
|
||||
|
||||
// Call without platform argument
|
||||
const result = checkEligibility(skill);
|
||||
expect(result.eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("filterEligibleSkills", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it("should return only eligible skills", () => {
|
||||
const skills = new Map<string, Skill>([
|
||||
["darwin-only", createSkill("darwin-only", {
|
||||
name: "Darwin Only",
|
||||
metadata: { platforms: ["darwin"] },
|
||||
})],
|
||||
["linux-only", createSkill("linux-only", {
|
||||
name: "Linux Only",
|
||||
metadata: { platforms: ["linux"] },
|
||||
})],
|
||||
["all-platforms", createSkill("all-platforms", {
|
||||
name: "All Platforms",
|
||||
})],
|
||||
]);
|
||||
|
||||
const eligible = filterEligibleSkills(skills, "darwin");
|
||||
|
||||
expect(eligible.size).toBe(2);
|
||||
expect(eligible.has("darwin-only")).toBe(true);
|
||||
expect(eligible.has("all-platforms")).toBe(true);
|
||||
expect(eligible.has("linux-only")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return empty map when no skills are eligible", () => {
|
||||
const skills = new Map<string, Skill>([
|
||||
["win-only", createSkill("win-only", {
|
||||
name: "Windows Only",
|
||||
metadata: { platforms: ["win32"] },
|
||||
})],
|
||||
]);
|
||||
|
||||
const eligible = filterEligibleSkills(skills, "darwin");
|
||||
|
||||
expect(eligible.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should return all skills when all are eligible", () => {
|
||||
const skills = new Map<string, Skill>([
|
||||
["skill-1", createSkill("skill-1", { name: "Skill 1" })],
|
||||
["skill-2", createSkill("skill-2", { name: "Skill 2" })],
|
||||
["skill-3", createSkill("skill-3", { name: "Skill 3" })],
|
||||
]);
|
||||
|
||||
const eligible = filterEligibleSkills(skills, "darwin");
|
||||
|
||||
expect(eligible.size).toBe(3);
|
||||
});
|
||||
|
||||
it("should handle empty input map", () => {
|
||||
const skills = new Map<string, Skill>();
|
||||
const eligible = filterEligibleSkills(skills, "darwin");
|
||||
expect(eligible.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
186
src/agent/skills/loader.test.ts
Normal file
186
src/agent/skills/loader.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { getProfileSkillsDir, loadAllSkills, getBundledSkillsDir } from "./loader.js";
|
||||
|
||||
describe("loader", () => {
|
||||
const testBaseDir = join(tmpdir(), `multica-skills-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(testBaseDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testBaseDir)) {
|
||||
rmSync(testBaseDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("getProfileSkillsDir", () => {
|
||||
it("should return correct path with custom base dir", () => {
|
||||
const result = getProfileSkillsDir("my-profile", testBaseDir);
|
||||
expect(result).toBe(join(testBaseDir, "my-profile", "skills"));
|
||||
});
|
||||
|
||||
it("should use default base dir when not provided", () => {
|
||||
const result = getProfileSkillsDir("my-profile");
|
||||
expect(result).toContain(".super-multica");
|
||||
expect(result).toContain("agent-profiles");
|
||||
expect(result).toContain("my-profile");
|
||||
expect(result).toContain("skills");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBundledSkillsDir", () => {
|
||||
it("should return path to bundled skills", () => {
|
||||
const result = getBundledSkillsDir();
|
||||
expect(result).toContain("skills");
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadAllSkills", () => {
|
||||
function createSkillDir(baseDir: string, skillId: string, name: string) {
|
||||
const skillDir = join(baseDir, skillId);
|
||||
mkdirSync(skillDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(skillDir, "SKILL.md"),
|
||||
`---
|
||||
name: ${name}
|
||||
description: Test skill ${skillId}
|
||||
---
|
||||
Instructions for ${name}
|
||||
`
|
||||
);
|
||||
}
|
||||
|
||||
it("should load skills from extra directories", () => {
|
||||
const extraDir = join(testBaseDir, "extra-skills");
|
||||
mkdirSync(extraDir, { recursive: true });
|
||||
createSkillDir(extraDir, "custom-skill", "Custom Skill");
|
||||
|
||||
const skills = loadAllSkills({ extraDirs: [extraDir] });
|
||||
|
||||
expect(skills.has("custom-skill")).toBe(true);
|
||||
const skill = skills.get("custom-skill");
|
||||
expect(skill?.frontmatter.name).toBe("Custom Skill");
|
||||
expect(skill?.source).toBe("bundled");
|
||||
});
|
||||
|
||||
it("should load skills from profile directory", () => {
|
||||
const profileDir = join(testBaseDir, "profiles", "test-profile", "skills");
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
createSkillDir(profileDir, "profile-skill", "Profile Skill");
|
||||
|
||||
const skills = loadAllSkills({
|
||||
profileId: "test-profile",
|
||||
profileBaseDir: join(testBaseDir, "profiles"),
|
||||
});
|
||||
|
||||
expect(skills.has("profile-skill")).toBe(true);
|
||||
const skill = skills.get("profile-skill");
|
||||
expect(skill?.frontmatter.name).toBe("Profile Skill");
|
||||
expect(skill?.source).toBe("profile");
|
||||
});
|
||||
|
||||
it("should apply precedence: profile overrides bundled", () => {
|
||||
const extraDir = join(testBaseDir, "extra");
|
||||
mkdirSync(extraDir, { recursive: true });
|
||||
createSkillDir(extraDir, "same-id", "Bundled Version");
|
||||
|
||||
const profileDir = join(testBaseDir, "profiles", "test-profile", "skills");
|
||||
mkdirSync(profileDir, { recursive: true });
|
||||
createSkillDir(profileDir, "same-id", "Profile Version");
|
||||
|
||||
const skills = loadAllSkills({
|
||||
extraDirs: [extraDir],
|
||||
profileId: "test-profile",
|
||||
profileBaseDir: join(testBaseDir, "profiles"),
|
||||
});
|
||||
|
||||
expect(skills.has("same-id")).toBe(true);
|
||||
const skill = skills.get("same-id");
|
||||
expect(skill?.frontmatter.name).toBe("Profile Version");
|
||||
expect(skill?.source).toBe("profile");
|
||||
});
|
||||
|
||||
it("should return empty map when no skills found", () => {
|
||||
const emptyDir = join(testBaseDir, "empty");
|
||||
mkdirSync(emptyDir, { recursive: true });
|
||||
|
||||
const skills = loadAllSkills({ extraDirs: [emptyDir] });
|
||||
|
||||
// May contain bundled skills, but the empty extra dir shouldn't cause issues
|
||||
expect(skills).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it("should skip invalid skill files", () => {
|
||||
const extraDir = join(testBaseDir, "with-invalid");
|
||||
mkdirSync(extraDir, { recursive: true });
|
||||
|
||||
// Create valid skill
|
||||
createSkillDir(extraDir, "valid-skill", "Valid Skill");
|
||||
|
||||
// Create invalid skill (no name in frontmatter)
|
||||
const invalidDir = join(extraDir, "invalid-skill");
|
||||
mkdirSync(invalidDir, { recursive: true });
|
||||
writeFileSync(
|
||||
join(invalidDir, "SKILL.md"),
|
||||
`---
|
||||
description: Missing name field
|
||||
---
|
||||
Invalid skill
|
||||
`
|
||||
);
|
||||
|
||||
const skills = loadAllSkills({ extraDirs: [extraDir] });
|
||||
|
||||
expect(skills.has("valid-skill")).toBe(true);
|
||||
expect(skills.has("invalid-skill")).toBe(false);
|
||||
});
|
||||
|
||||
it("should skip directories without SKILL.md", () => {
|
||||
const extraDir = join(testBaseDir, "partial");
|
||||
mkdirSync(extraDir, { recursive: true });
|
||||
|
||||
// Directory without SKILL.md
|
||||
const noSkillDir = join(extraDir, "not-a-skill");
|
||||
mkdirSync(noSkillDir, { recursive: true });
|
||||
writeFileSync(join(noSkillDir, "README.md"), "Just a readme");
|
||||
|
||||
// Valid skill
|
||||
createSkillDir(extraDir, "real-skill", "Real Skill");
|
||||
|
||||
const skills = loadAllSkills({ extraDirs: [extraDir] });
|
||||
|
||||
expect(skills.has("real-skill")).toBe(true);
|
||||
expect(skills.has("not-a-skill")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle non-existent directories gracefully", () => {
|
||||
const skills = loadAllSkills({
|
||||
extraDirs: ["/non/existent/path"],
|
||||
});
|
||||
|
||||
expect(skills).toBeInstanceOf(Map);
|
||||
});
|
||||
|
||||
it("should load multiple skills from same directory", () => {
|
||||
const extraDir = join(testBaseDir, "multi");
|
||||
mkdirSync(extraDir, { recursive: true });
|
||||
|
||||
createSkillDir(extraDir, "skill-a", "Skill A");
|
||||
createSkillDir(extraDir, "skill-b", "Skill B");
|
||||
createSkillDir(extraDir, "skill-c", "Skill C");
|
||||
|
||||
const skills = loadAllSkills({ extraDirs: [extraDir] });
|
||||
|
||||
expect(skills.has("skill-a")).toBe(true);
|
||||
expect(skills.has("skill-b")).toBe(true);
|
||||
expect(skills.has("skill-c")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
243
src/agent/skills/parser.test.ts
Normal file
243
src/agent/skills/parser.test.ts
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { parseFrontmatter } from "./parser.js";
|
||||
|
||||
describe("parser", () => {
|
||||
describe("parseFrontmatter", () => {
|
||||
it("should parse valid frontmatter with all fields", () => {
|
||||
const content = `---
|
||||
name: Test Skill
|
||||
description: A test skill
|
||||
version: 1.0.0
|
||||
author: Test Author
|
||||
homepage: https://example.com
|
||||
metadata:
|
||||
emoji: "test"
|
||||
requiresEnv:
|
||||
- API_KEY
|
||||
- SECRET
|
||||
requiresBinaries:
|
||||
- git
|
||||
- node
|
||||
platforms:
|
||||
- darwin
|
||||
- linux
|
||||
tags:
|
||||
- testing
|
||||
- development
|
||||
---
|
||||
# Skill Instructions
|
||||
|
||||
This is the body content.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter).toEqual({
|
||||
name: "Test Skill",
|
||||
description: "A test skill",
|
||||
version: "1.0.0",
|
||||
author: "Test Author",
|
||||
homepage: "https://example.com",
|
||||
metadata: {
|
||||
emoji: "test",
|
||||
requiresEnv: ["API_KEY", "SECRET"],
|
||||
requiresBinaries: ["git", "node"],
|
||||
platforms: ["darwin", "linux"],
|
||||
tags: ["testing", "development"],
|
||||
},
|
||||
});
|
||||
expect(body).toBe("# Skill Instructions\n\nThis is the body content.");
|
||||
});
|
||||
|
||||
it("should parse minimal frontmatter with only required name field", () => {
|
||||
const content = `---
|
||||
name: Minimal Skill
|
||||
---
|
||||
Body content here.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter).toEqual({ name: "Minimal Skill" });
|
||||
expect(body).toBe("Body content here.");
|
||||
});
|
||||
|
||||
it("should return null frontmatter when no frontmatter present", () => {
|
||||
const content = `# Just Markdown
|
||||
|
||||
No frontmatter here.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter).toBeNull();
|
||||
expect(body).toBe("# Just Markdown\n\nNo frontmatter here.");
|
||||
});
|
||||
|
||||
it("should return null frontmatter for invalid YAML", () => {
|
||||
const content = `---
|
||||
name: Test
|
||||
invalid: yaml: syntax: here
|
||||
- broken
|
||||
indentation
|
||||
---
|
||||
Body content.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
// Note: YAML parser may or may not fail depending on the exact syntax
|
||||
// This tests that the function handles errors gracefully
|
||||
if (frontmatter === null) {
|
||||
expect(body).toBe(content.trim());
|
||||
} else {
|
||||
expect(frontmatter).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle empty frontmatter block", () => {
|
||||
const content = `---
|
||||
|
||||
---
|
||||
Body content.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
// Empty frontmatter returns null because YAML parses empty content as null
|
||||
// The regex still matches, so body is extracted, but frontmatter check fails
|
||||
if (frontmatter === null) {
|
||||
// When frontmatter is null, body contains trimmed original content
|
||||
expect(body).toContain("Body content.");
|
||||
} else {
|
||||
// If YAML returns empty object, frontmatter would be defined
|
||||
expect(body).toBe("Body content.");
|
||||
}
|
||||
});
|
||||
|
||||
it("should handle CRLF line endings", () => {
|
||||
const content = "---\r\nname: Windows Skill\r\n---\r\nBody with CRLF.";
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter).toEqual({ name: "Windows Skill" });
|
||||
expect(body).toBe("Body with CRLF.");
|
||||
});
|
||||
|
||||
it("should handle content with multiple --- markers in body", () => {
|
||||
const content = `---
|
||||
name: Test
|
||||
---
|
||||
# Body
|
||||
|
||||
---
|
||||
|
||||
More content after horizontal rule.
|
||||
|
||||
---
|
||||
Another section.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter).toEqual({ name: "Test" });
|
||||
expect(body).toContain("---");
|
||||
expect(body).toContain("More content after horizontal rule.");
|
||||
});
|
||||
|
||||
it("should handle frontmatter that doesn't start at beginning", () => {
|
||||
const content = `
|
||||
---
|
||||
name: Test
|
||||
---
|
||||
Body.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
// Frontmatter must start at the very beginning
|
||||
expect(frontmatter).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle frontmatter with nested metadata object", () => {
|
||||
const content = `---
|
||||
name: Nested Test
|
||||
metadata:
|
||||
emoji: "rocket"
|
||||
platforms:
|
||||
- darwin
|
||||
requiresBinaries: []
|
||||
requiresEnv: []
|
||||
---
|
||||
Instructions here.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter?.metadata).toEqual({
|
||||
emoji: "rocket",
|
||||
platforms: ["darwin"],
|
||||
requiresBinaries: [],
|
||||
requiresEnv: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle multiline string values", () => {
|
||||
const content = `---
|
||||
name: Multiline
|
||||
description: |
|
||||
This is a multiline
|
||||
description that spans
|
||||
multiple lines.
|
||||
---
|
||||
Body.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter?.description).toContain("multiline");
|
||||
expect(body).toBe("Body.");
|
||||
});
|
||||
|
||||
it("should trim whitespace from body", () => {
|
||||
const content = `---
|
||||
name: Test
|
||||
---
|
||||
|
||||
Body with extra whitespace.
|
||||
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(body).toBe("Body with extra whitespace.");
|
||||
});
|
||||
|
||||
it("should handle empty body", () => {
|
||||
const content = `---
|
||||
name: No Body
|
||||
---
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter).toEqual({ name: "No Body" });
|
||||
expect(body).toBe("");
|
||||
});
|
||||
|
||||
it("should handle special characters in values", () => {
|
||||
const content = `---
|
||||
name: "Special: Characters & Symbols"
|
||||
description: 'Quotes and colons: work'
|
||||
---
|
||||
Body.
|
||||
`;
|
||||
|
||||
const [frontmatter, body] = parseFrontmatter(content);
|
||||
|
||||
expect(frontmatter?.name).toBe("Special: Characters & Symbols");
|
||||
expect(frontmatter?.description).toBe("Quotes and colons: work");
|
||||
expect(body).toBe("Body.");
|
||||
});
|
||||
});
|
||||
});
|
||||
218
src/agent/tools/glob.test.ts
Normal file
218
src/agent/tools/glob.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { createGlobTool, type GlobResult } from "./glob.js";
|
||||
|
||||
describe("glob", () => {
|
||||
const testDir = join(tmpdir(), `multica-glob-test-${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true });
|
||||
}
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// Create test file structure
|
||||
mkdirSync(join(testDir, "src"), { recursive: true });
|
||||
mkdirSync(join(testDir, "src/components"), { recursive: true });
|
||||
mkdirSync(join(testDir, "test"), { recursive: true });
|
||||
|
||||
writeFileSync(join(testDir, "package.json"), "{}");
|
||||
writeFileSync(join(testDir, "src/index.ts"), "export {}");
|
||||
writeFileSync(join(testDir, "src/utils.ts"), "export {}");
|
||||
writeFileSync(join(testDir, "src/components/Button.tsx"), "export {}");
|
||||
writeFileSync(join(testDir, "src/components/Input.tsx"), "export {}");
|
||||
writeFileSync(join(testDir, "test/index.test.ts"), "test()");
|
||||
writeFileSync(join(testDir, ".config"), "hidden");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (existsSync(testDir)) {
|
||||
rmSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe("createGlobTool", () => {
|
||||
it("should create a glob tool with correct properties", () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
|
||||
expect(tool.name).toBe("glob");
|
||||
expect(tool.label).toBe("Glob");
|
||||
expect(tool.description).toContain("Find files matching a glob pattern");
|
||||
expect(tool.execute).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it("should find files matching simple pattern", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute("test-id", { pattern: "*.json" }, new AbortController().signal);
|
||||
|
||||
expect(result.details.files).toContain("package.json");
|
||||
expect(result.details.count).toBe(1);
|
||||
expect(result.details.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("should find TypeScript files recursively", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute("test-id", { pattern: "**/*.ts" }, new AbortController().signal);
|
||||
|
||||
expect(result.details.files).toContain("src/index.ts");
|
||||
expect(result.details.files).toContain("src/utils.ts");
|
||||
expect(result.details.files).toContain("test/index.test.ts");
|
||||
expect(result.details.count).toBe(3);
|
||||
});
|
||||
|
||||
it("should find TSX files in specific directory", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "src/components/*.tsx" },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(result.details.files).toContain("src/components/Button.tsx");
|
||||
expect(result.details.files).toContain("src/components/Input.tsx");
|
||||
expect(result.details.count).toBe(2);
|
||||
});
|
||||
|
||||
it("should include dotfiles", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute("test-id", { pattern: ".*" }, new AbortController().signal);
|
||||
|
||||
expect(result.details.files).toContain(".config");
|
||||
});
|
||||
|
||||
it("should respect limit parameter", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "**/*", limit: 2 },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(result.details.count).toBe(2);
|
||||
expect(result.details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("should respect ignore patterns", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "**/*.ts", ignore: ["test/**"] },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(result.details.files).toContain("src/index.ts");
|
||||
expect(result.details.files).not.toContain("test/index.test.ts");
|
||||
});
|
||||
|
||||
it("should use custom cwd", async () => {
|
||||
const tool = createGlobTool("/other/path");
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "*.ts", cwd: join(testDir, "src") },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(result.details.files).toContain("index.ts");
|
||||
expect(result.details.files).toContain("utils.ts");
|
||||
});
|
||||
|
||||
it("should throw error for empty pattern", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
|
||||
await expect(
|
||||
tool.execute("test-id", { pattern: "" }, new AbortController().signal)
|
||||
).rejects.toThrow("Pattern must not be empty");
|
||||
});
|
||||
|
||||
it("should throw error for whitespace-only pattern", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
|
||||
await expect(
|
||||
tool.execute("test-id", { pattern: " " }, new AbortController().signal)
|
||||
).rejects.toThrow("Pattern must not be empty");
|
||||
});
|
||||
|
||||
it("should throw error for non-existent directory", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
|
||||
await expect(
|
||||
tool.execute("test-id", { pattern: "*.ts", cwd: "/non/existent/path" }, new AbortController().signal)
|
||||
).rejects.toThrow("Directory not found");
|
||||
});
|
||||
|
||||
it("should throw error when cwd is a file", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const filePath = join(testDir, "package.json");
|
||||
|
||||
await expect(
|
||||
tool.execute("test-id", { pattern: "*.ts", cwd: filePath }, new AbortController().signal)
|
||||
).rejects.toThrow("Path is not a directory");
|
||||
});
|
||||
|
||||
it("should return message when no files match", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "**/*.xyz" },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(result.details.count).toBe(0);
|
||||
expect(result.details.files).toHaveLength(0);
|
||||
expect(result.content[0].text).toContain("No files found");
|
||||
});
|
||||
|
||||
it("should sort files by modification time (most recent first)", async () => {
|
||||
// Create files with different modification times
|
||||
const laterFile = join(testDir, "later.ts");
|
||||
writeFileSync(laterFile, "// created later");
|
||||
|
||||
// Wait a bit to ensure different mtime
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const latestFile = join(testDir, "latest.ts");
|
||||
writeFileSync(latestFile, "// created latest");
|
||||
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "*.ts" },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
// The latest file should be first
|
||||
expect(result.details.files[0]).toBe("latest.ts");
|
||||
});
|
||||
|
||||
it("should use default limit of 100", async () => {
|
||||
// Create more than 100 files
|
||||
for (let i = 0; i < 110; i++) {
|
||||
writeFileSync(join(testDir, `file${i}.txt`), "content");
|
||||
}
|
||||
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "*.txt" },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(result.details.count).toBe(100);
|
||||
expect(result.details.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("should limit to max 1000 files", async () => {
|
||||
const tool = createGlobTool(testDir);
|
||||
const result = await tool.execute(
|
||||
"test-id",
|
||||
{ pattern: "**/*", limit: 5000 },
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
// The limit should be capped at 1000
|
||||
expect(result.details.count).toBeLessThanOrEqual(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
252
src/agent/tools/web/cache.test.ts
Normal file
252
src/agent/tools/web/cache.test.ts
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
resolveTimeoutSeconds,
|
||||
resolveCacheTtlMs,
|
||||
normalizeCacheKey,
|
||||
readCache,
|
||||
writeCache,
|
||||
withTimeout,
|
||||
type CacheEntry,
|
||||
} from "./cache.js";
|
||||
|
||||
describe("cache", () => {
|
||||
describe("resolveTimeoutSeconds", () => {
|
||||
it("should return the value if it is a valid number", () => {
|
||||
expect(resolveTimeoutSeconds(30, 10)).toBe(30);
|
||||
expect(resolveTimeoutSeconds(60, 10)).toBe(60);
|
||||
});
|
||||
|
||||
it("should return fallback for non-number values", () => {
|
||||
expect(resolveTimeoutSeconds("30", 10)).toBe(10);
|
||||
expect(resolveTimeoutSeconds(null, 10)).toBe(10);
|
||||
expect(resolveTimeoutSeconds(undefined, 10)).toBe(10);
|
||||
expect(resolveTimeoutSeconds({}, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("should return fallback for non-finite numbers", () => {
|
||||
expect(resolveTimeoutSeconds(NaN, 10)).toBe(10);
|
||||
expect(resolveTimeoutSeconds(Infinity, 10)).toBe(10);
|
||||
expect(resolveTimeoutSeconds(-Infinity, 10)).toBe(10);
|
||||
});
|
||||
|
||||
it("should enforce minimum of 1 second", () => {
|
||||
expect(resolveTimeoutSeconds(0, 10)).toBe(1);
|
||||
expect(resolveTimeoutSeconds(-5, 10)).toBe(1);
|
||||
expect(resolveTimeoutSeconds(0.5, 10)).toBe(1);
|
||||
});
|
||||
|
||||
it("should floor decimal values", () => {
|
||||
expect(resolveTimeoutSeconds(5.9, 10)).toBe(5);
|
||||
expect(resolveTimeoutSeconds(10.1, 5)).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveCacheTtlMs", () => {
|
||||
it("should convert minutes to milliseconds", () => {
|
||||
expect(resolveCacheTtlMs(1, 15)).toBe(60_000);
|
||||
expect(resolveCacheTtlMs(15, 15)).toBe(900_000);
|
||||
expect(resolveCacheTtlMs(60, 15)).toBe(3_600_000);
|
||||
});
|
||||
|
||||
it("should return fallback for non-number values", () => {
|
||||
expect(resolveCacheTtlMs("15", 15)).toBe(900_000);
|
||||
expect(resolveCacheTtlMs(null, 10)).toBe(600_000);
|
||||
expect(resolveCacheTtlMs(undefined, 5)).toBe(300_000);
|
||||
});
|
||||
|
||||
it("should handle zero and negative values", () => {
|
||||
expect(resolveCacheTtlMs(0, 15)).toBe(0);
|
||||
expect(resolveCacheTtlMs(-5, 15)).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle fractional minutes", () => {
|
||||
expect(resolveCacheTtlMs(0.5, 15)).toBe(30_000);
|
||||
expect(resolveCacheTtlMs(1.5, 15)).toBe(90_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeCacheKey", () => {
|
||||
it("should trim whitespace", () => {
|
||||
expect(normalizeCacheKey(" key ")).toBe("key");
|
||||
expect(normalizeCacheKey("\tkey\n")).toBe("key");
|
||||
});
|
||||
|
||||
it("should lowercase the key", () => {
|
||||
expect(normalizeCacheKey("KEY")).toBe("key");
|
||||
expect(normalizeCacheKey("MyKey")).toBe("mykey");
|
||||
expect(normalizeCacheKey("HTTPS://EXAMPLE.COM")).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(normalizeCacheKey("")).toBe("");
|
||||
expect(normalizeCacheKey(" ")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should return null for missing key", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
expect(readCache(cache, "missing")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return cached value if not expired", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
const now = Date.now();
|
||||
cache.set("key", {
|
||||
value: "test-value",
|
||||
expiresAt: now + 60_000,
|
||||
insertedAt: now,
|
||||
});
|
||||
|
||||
const result = readCache(cache, "key");
|
||||
expect(result).toEqual({ value: "test-value", cached: true });
|
||||
});
|
||||
|
||||
it("should return null and delete expired entries", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
const now = Date.now();
|
||||
cache.set("key", {
|
||||
value: "test-value",
|
||||
expiresAt: now - 1000, // expired
|
||||
insertedAt: now - 60_000,
|
||||
});
|
||||
|
||||
const result = readCache(cache, "key");
|
||||
expect(result).toBeNull();
|
||||
expect(cache.has("key")).toBe(false);
|
||||
});
|
||||
|
||||
it("should delete entry when exactly at expiration time", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
const now = Date.now();
|
||||
cache.set("key", {
|
||||
value: "test-value",
|
||||
expiresAt: now,
|
||||
insertedAt: now - 60_000,
|
||||
});
|
||||
|
||||
vi.advanceTimersByTime(1); // Move past expiration
|
||||
const result = readCache(cache, "key");
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("writeCache", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should write entry with correct expiration", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
const now = Date.now();
|
||||
|
||||
writeCache(cache, "key", "value", 60_000);
|
||||
|
||||
const entry = cache.get("key");
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.value).toBe("value");
|
||||
expect(entry?.expiresAt).toBe(now + 60_000);
|
||||
expect(entry?.insertedAt).toBe(now);
|
||||
});
|
||||
|
||||
it("should not write if ttl is 0 or negative", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
|
||||
writeCache(cache, "key1", "value1", 0);
|
||||
writeCache(cache, "key2", "value2", -100);
|
||||
|
||||
expect(cache.size).toBe(0);
|
||||
});
|
||||
|
||||
it("should evict oldest entry when cache is full", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
const now = Date.now();
|
||||
|
||||
// Fill up to max (100 entries)
|
||||
for (let i = 0; i < 100; i++) {
|
||||
cache.set(`key${i}`, {
|
||||
value: `value${i}`,
|
||||
expiresAt: now + 60_000,
|
||||
insertedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
expect(cache.size).toBe(100);
|
||||
|
||||
// Add one more - should evict first
|
||||
writeCache(cache, "new-key", "new-value", 60_000);
|
||||
|
||||
expect(cache.size).toBe(100);
|
||||
expect(cache.has("key0")).toBe(false);
|
||||
expect(cache.has("new-key")).toBe(true);
|
||||
});
|
||||
|
||||
it("should overwrite existing entry", () => {
|
||||
const cache = new Map<string, CacheEntry<string>>();
|
||||
|
||||
writeCache(cache, "key", "value1", 60_000);
|
||||
writeCache(cache, "key", "value2", 60_000);
|
||||
|
||||
expect(cache.size).toBe(1);
|
||||
expect(cache.get("key")?.value).toBe("value2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("withTimeout", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should return aborted signal after timeout", async () => {
|
||||
const signal = withTimeout(undefined, 1000);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
vi.advanceTimersByTime(1000);
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should abort immediately if timeout is 0", () => {
|
||||
const signal = withTimeout(undefined, 0);
|
||||
expect(signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("should abort when parent signal aborts", () => {
|
||||
const parentController = new AbortController();
|
||||
const signal = withTimeout(parentController.signal, 60_000);
|
||||
|
||||
expect(signal.aborted).toBe(false);
|
||||
|
||||
parentController.abort();
|
||||
|
||||
expect(signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should clear timeout when parent signal aborts", () => {
|
||||
const parentController = new AbortController();
|
||||
const clearTimeoutSpy = vi.spyOn(global, "clearTimeout");
|
||||
|
||||
withTimeout(parentController.signal, 60_000);
|
||||
parentController.abort();
|
||||
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
230
src/agent/tools/web/html-utils.test.ts
Normal file
230
src/agent/tools/web/html-utils.test.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
htmlToMarkdownSimple,
|
||||
markdownToText,
|
||||
truncateText,
|
||||
convertWithTurndown,
|
||||
} from "./html-utils.js";
|
||||
|
||||
describe("html-utils", () => {
|
||||
describe("htmlToMarkdownSimple", () => {
|
||||
it("should extract title from HTML", () => {
|
||||
const html = "<html><head><title>Test Page</title></head><body>Content</body></html>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.title).toBe("Test Page");
|
||||
});
|
||||
|
||||
it("should handle missing title", () => {
|
||||
const html = "<html><body>Content</body></html>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should remove script tags", () => {
|
||||
const html = "<p>Before</p><script>alert('xss');</script><p>After</p>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).not.toContain("alert");
|
||||
expect(result.text).toContain("Before");
|
||||
expect(result.text).toContain("After");
|
||||
});
|
||||
|
||||
it("should remove style tags", () => {
|
||||
const html = "<p>Content</p><style>.red { color: red; }</style>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).not.toContain("color");
|
||||
expect(result.text).toContain("Content");
|
||||
});
|
||||
|
||||
it("should remove noscript tags", () => {
|
||||
const html = "<p>Content</p><noscript>Enable JavaScript</noscript>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).not.toContain("JavaScript");
|
||||
});
|
||||
|
||||
it("should convert links to markdown format", () => {
|
||||
const html = '<a href="https://example.com">Example</a>';
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).toBe("[Example](https://example.com)");
|
||||
});
|
||||
|
||||
it("should handle links without text", () => {
|
||||
const html = '<a href="https://example.com"></a>';
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("should convert headings to markdown", () => {
|
||||
const html = "<h1>Title</h1><h2>Subtitle</h2><h3>Section</h3>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).toContain("# Title");
|
||||
expect(result.text).toContain("## Subtitle");
|
||||
expect(result.text).toContain("### Section");
|
||||
});
|
||||
|
||||
it("should convert list items", () => {
|
||||
const html = "<ul><li>Item 1</li><li>Item 2</li></ul>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).toContain("- Item 1");
|
||||
expect(result.text).toContain("- Item 2");
|
||||
});
|
||||
|
||||
it("should convert br and hr tags", () => {
|
||||
const html = "<p>Line 1<br/>Line 2</p><hr/><p>Line 3</p>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).toContain("Line 1");
|
||||
expect(result.text).toContain("Line 2");
|
||||
expect(result.text).toContain("Line 3");
|
||||
});
|
||||
|
||||
it("should decode HTML entities", () => {
|
||||
const html = "<p>Hello & World <test></p>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).toContain("Hello & World <test>");
|
||||
});
|
||||
|
||||
it("should decode numeric entities", () => {
|
||||
const html = "<p><tag> <hex></p>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).toContain("<tag>");
|
||||
expect(result.text).toContain("<hex>");
|
||||
});
|
||||
|
||||
it("should normalize whitespace", () => {
|
||||
const html = "<p>Text with lots of spaces</p>";
|
||||
const result = htmlToMarkdownSimple(html);
|
||||
expect(result.text).not.toContain(" ");
|
||||
});
|
||||
|
||||
it("should handle empty HTML", () => {
|
||||
const result = htmlToMarkdownSimple("");
|
||||
expect(result.text).toBe("");
|
||||
expect(result.title).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("markdownToText", () => {
|
||||
it("should remove image syntax", () => {
|
||||
const md = "Text  more text";
|
||||
const result = markdownToText(md);
|
||||
expect(result).not.toContain(" for more";
|
||||
const result = markdownToText(md);
|
||||
expect(result).toBe("Click here for more");
|
||||
});
|
||||
|
||||
it("should remove code blocks", () => {
|
||||
const md = "Text\n```javascript\nconst x = 1;\n```\nMore text";
|
||||
const result = markdownToText(md);
|
||||
expect(result).not.toContain("```");
|
||||
expect(result).toContain("const x = 1;");
|
||||
});
|
||||
|
||||
it("should remove inline code backticks", () => {
|
||||
const md = "Use the `console.log` function";
|
||||
const result = markdownToText(md);
|
||||
expect(result).toBe("Use the console.log function");
|
||||
});
|
||||
|
||||
it("should remove heading markers", () => {
|
||||
const md = "# Title\n## Subtitle\n### Section";
|
||||
const result = markdownToText(md);
|
||||
expect(result).not.toContain("#");
|
||||
expect(result).toContain("Title");
|
||||
expect(result).toContain("Subtitle");
|
||||
});
|
||||
|
||||
it("should remove list markers", () => {
|
||||
const md = "- Item 1\n* Item 2\n+ Item 3\n1. Numbered";
|
||||
const result = markdownToText(md);
|
||||
expect(result).not.toMatch(/^[-*+]\s/m);
|
||||
expect(result).not.toMatch(/^\d+\.\s/m);
|
||||
expect(result).toContain("Item 1");
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
expect(markdownToText("")).toBe("");
|
||||
});
|
||||
|
||||
it("should normalize whitespace", () => {
|
||||
const md = "Text with spaces\n\n\nand lines";
|
||||
const result = markdownToText(md);
|
||||
expect(result).not.toContain(" ");
|
||||
expect(result).not.toContain("\n\n\n");
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncateText", () => {
|
||||
it("should not truncate text under max length", () => {
|
||||
const result = truncateText("Hello", 10);
|
||||
expect(result.text).toBe("Hello");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("should truncate text over max length", () => {
|
||||
const result = truncateText("Hello World", 5);
|
||||
expect(result.text).toBe("Hello");
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle exact length", () => {
|
||||
const result = truncateText("Hello", 5);
|
||||
expect(result.text).toBe("Hello");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const result = truncateText("", 10);
|
||||
expect(result.text).toBe("");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle zero max chars", () => {
|
||||
const result = truncateText("Hello", 0);
|
||||
expect(result.text).toBe("");
|
||||
expect(result.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertWithTurndown", () => {
|
||||
it("should convert HTML to markdown", () => {
|
||||
const html = "<html><head><title>Page</title></head><body><h1>Hello</h1><p>World</p></body></html>";
|
||||
const result = convertWithTurndown(html);
|
||||
expect(result.title).toBe("Page");
|
||||
expect(result.text).toContain("# Hello");
|
||||
expect(result.text).toContain("World");
|
||||
});
|
||||
|
||||
it("should remove script and style tags", () => {
|
||||
const html = "<script>alert(1)</script><style>.x{}</style><p>Content</p>";
|
||||
const result = convertWithTurndown(html);
|
||||
expect(result.text).not.toContain("alert");
|
||||
expect(result.text).not.toContain(".x{}");
|
||||
expect(result.text).toContain("Content");
|
||||
});
|
||||
|
||||
it("should convert links", () => {
|
||||
const html = '<a href="https://example.com">Link</a>';
|
||||
const result = convertWithTurndown(html);
|
||||
expect(result.text).toContain("[Link](https://example.com)");
|
||||
});
|
||||
|
||||
it("should convert lists with dash markers", () => {
|
||||
const html = "<ul><li>One</li><li>Two</li></ul>";
|
||||
const result = convertWithTurndown(html);
|
||||
expect(result.text).toContain("-");
|
||||
expect(result.text).toContain("One");
|
||||
expect(result.text).toContain("Two");
|
||||
});
|
||||
|
||||
it("should handle code blocks", () => {
|
||||
const html = "<pre><code>const x = 1;</code></pre>";
|
||||
const result = convertWithTurndown(html);
|
||||
expect(result.text).toContain("const x = 1;");
|
||||
});
|
||||
});
|
||||
});
|
||||
238
src/agent/tools/web/param-helpers.test.ts
Normal file
238
src/agent/tools/web/param-helpers.test.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { readStringParam, readNumberParam, jsonResult } from "./param-helpers.js";
|
||||
|
||||
describe("param-helpers", () => {
|
||||
describe("readStringParam", () => {
|
||||
it("should return string value when present", () => {
|
||||
const params = { name: "test" };
|
||||
const result = readStringParam(params, "name");
|
||||
expect(result).toBe("test");
|
||||
});
|
||||
|
||||
it("should trim whitespace by default", () => {
|
||||
const params = { name: " test " };
|
||||
const result = readStringParam(params, "name");
|
||||
expect(result).toBe("test");
|
||||
});
|
||||
|
||||
it("should not trim when trim is false", () => {
|
||||
const params = { name: " test " };
|
||||
const result = readStringParam(params, "name", { trim: false });
|
||||
expect(result).toBe(" test ");
|
||||
});
|
||||
|
||||
it("should return undefined for missing key", () => {
|
||||
const params = {};
|
||||
const result = readStringParam(params, "name");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for non-string value", () => {
|
||||
const params = { name: 123 };
|
||||
const result = readStringParam(params, "name");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw when required and missing", () => {
|
||||
const params = {};
|
||||
expect(() => readStringParam(params, "name", { required: true })).toThrow("name required");
|
||||
});
|
||||
|
||||
it("should throw when required and not a string", () => {
|
||||
const params = { name: null };
|
||||
expect(() => readStringParam(params, "name", { required: true })).toThrow("name required");
|
||||
});
|
||||
|
||||
it("should use custom label in error message", () => {
|
||||
const params = {};
|
||||
expect(() =>
|
||||
readStringParam(params, "name", { required: true, label: "Username" })
|
||||
).toThrow("Username required");
|
||||
});
|
||||
|
||||
it("should return undefined for empty string when not allowEmpty", () => {
|
||||
const params = { name: "" };
|
||||
const result = readStringParam(params, "name");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return empty string when allowEmpty is true", () => {
|
||||
const params = { name: "" };
|
||||
const result = readStringParam(params, "name", { allowEmpty: true });
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("should return undefined for whitespace-only when trimmed", () => {
|
||||
const params = { name: " " };
|
||||
const result = readStringParam(params, "name");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw for required empty string", () => {
|
||||
const params = { name: "" };
|
||||
expect(() => readStringParam(params, "name", { required: true })).toThrow("name required");
|
||||
});
|
||||
});
|
||||
|
||||
describe("readNumberParam", () => {
|
||||
it("should return number value when present", () => {
|
||||
const params = { count: 42 };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("should parse string numbers", () => {
|
||||
const params = { count: "42" };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("should parse float numbers from string", () => {
|
||||
const params = { value: "3.14" };
|
||||
const result = readNumberParam(params, "value");
|
||||
expect(result).toBe(3.14);
|
||||
});
|
||||
|
||||
it("should return undefined for missing key", () => {
|
||||
const params = {};
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for non-numeric string", () => {
|
||||
const params = { count: "abc" };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for NaN", () => {
|
||||
const params = { count: NaN };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for Infinity", () => {
|
||||
const params = { count: Infinity };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should throw when required and missing", () => {
|
||||
const params = {};
|
||||
expect(() => readNumberParam(params, "count", { required: true })).toThrow("count required");
|
||||
});
|
||||
|
||||
it("should throw when required and invalid", () => {
|
||||
const params = { count: "invalid" };
|
||||
expect(() => readNumberParam(params, "count", { required: true })).toThrow("count required");
|
||||
});
|
||||
|
||||
it("should use custom label in error message", () => {
|
||||
const params = {};
|
||||
expect(() =>
|
||||
readNumberParam(params, "count", { required: true, label: "Item Count" })
|
||||
).toThrow("Item Count required");
|
||||
});
|
||||
|
||||
it("should truncate to integer when integer option is true", () => {
|
||||
const params = { count: 3.9 };
|
||||
const result = readNumberParam(params, "count", { integer: true });
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it("should truncate negative float to integer", () => {
|
||||
const params = { count: -3.9 };
|
||||
const result = readNumberParam(params, "count", { integer: true });
|
||||
expect(result).toBe(-3);
|
||||
});
|
||||
|
||||
it("should handle empty string", () => {
|
||||
const params = { count: "" };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle whitespace-only string", () => {
|
||||
const params = { count: " " };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should trim string before parsing", () => {
|
||||
const params = { count: " 42 " };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it("should return undefined for object value", () => {
|
||||
const params = { count: { value: 42 } };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should return undefined for array value", () => {
|
||||
const params = { count: [1, 2, 3] };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle zero correctly", () => {
|
||||
const params = { count: 0 };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle negative numbers", () => {
|
||||
const params = { count: -10 };
|
||||
const result = readNumberParam(params, "count");
|
||||
expect(result).toBe(-10);
|
||||
});
|
||||
});
|
||||
|
||||
describe("jsonResult", () => {
|
||||
it("should return formatted JSON result", () => {
|
||||
const payload = { name: "test", value: 42 };
|
||||
const result = jsonResult(payload);
|
||||
|
||||
expect(result.content).toHaveLength(1);
|
||||
expect(result.content[0].type).toBe("text");
|
||||
expect(result.content[0].text).toBe(JSON.stringify(payload, null, 2));
|
||||
expect(result.details).toBe(payload);
|
||||
});
|
||||
|
||||
it("should handle array payload", () => {
|
||||
const payload = [1, 2, 3];
|
||||
const result = jsonResult(payload);
|
||||
|
||||
expect(result.content[0].text).toBe(JSON.stringify(payload, null, 2));
|
||||
expect(result.details).toBe(payload);
|
||||
});
|
||||
|
||||
it("should handle string payload", () => {
|
||||
const payload = "simple string";
|
||||
const result = jsonResult(payload);
|
||||
|
||||
expect(result.content[0].text).toBe('"simple string"');
|
||||
expect(result.details).toBe(payload);
|
||||
});
|
||||
|
||||
it("should handle null payload", () => {
|
||||
const result = jsonResult(null);
|
||||
|
||||
expect(result.content[0].text).toBe("null");
|
||||
expect(result.details).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle nested objects", () => {
|
||||
const payload = {
|
||||
user: { name: "test", settings: { theme: "dark" } },
|
||||
items: [1, 2, 3],
|
||||
};
|
||||
const result = jsonResult(payload);
|
||||
|
||||
expect(result.content[0].text).toContain("user");
|
||||
expect(result.content[0].text).toContain("settings");
|
||||
expect(result.details).toBe(payload);
|
||||
});
|
||||
});
|
||||
});
|
||||
397
src/agent/tools/web/ssrf.test.ts
Normal file
397
src/agent/tools/web/ssrf.test.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import {
|
||||
isPrivateIpAddress,
|
||||
isBlockedHostname,
|
||||
SsrfBlockedError,
|
||||
createPinnedLookup,
|
||||
resolvePinnedHostname,
|
||||
createPinnedDispatcher,
|
||||
closeDispatcher,
|
||||
} from "./ssrf.js";
|
||||
|
||||
describe("ssrf", () => {
|
||||
describe("isPrivateIpAddress", () => {
|
||||
describe("IPv4 private ranges", () => {
|
||||
it("should block 10.x.x.x range", () => {
|
||||
expect(isPrivateIpAddress("10.0.0.1")).toBe(true);
|
||||
expect(isPrivateIpAddress("10.255.255.255")).toBe(true);
|
||||
expect(isPrivateIpAddress("10.50.100.200")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block 172.16.x.x - 172.31.x.x range", () => {
|
||||
expect(isPrivateIpAddress("172.16.0.1")).toBe(true);
|
||||
expect(isPrivateIpAddress("172.31.255.255")).toBe(true);
|
||||
expect(isPrivateIpAddress("172.20.100.50")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not block 172.15.x.x or 172.32.x.x", () => {
|
||||
expect(isPrivateIpAddress("172.15.0.1")).toBe(false);
|
||||
expect(isPrivateIpAddress("172.32.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should block 192.168.x.x range", () => {
|
||||
expect(isPrivateIpAddress("192.168.0.1")).toBe(true);
|
||||
expect(isPrivateIpAddress("192.168.255.255")).toBe(true);
|
||||
expect(isPrivateIpAddress("192.168.1.100")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block 127.x.x.x loopback range", () => {
|
||||
expect(isPrivateIpAddress("127.0.0.1")).toBe(true);
|
||||
expect(isPrivateIpAddress("127.255.255.255")).toBe(true);
|
||||
expect(isPrivateIpAddress("127.0.0.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block 169.254.x.x link-local range", () => {
|
||||
expect(isPrivateIpAddress("169.254.0.1")).toBe(true);
|
||||
expect(isPrivateIpAddress("169.254.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block 0.x.x.x range", () => {
|
||||
expect(isPrivateIpAddress("0.0.0.0")).toBe(true);
|
||||
expect(isPrivateIpAddress("0.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block 100.64.x.x - 100.127.x.x CGNAT range", () => {
|
||||
expect(isPrivateIpAddress("100.64.0.1")).toBe(true);
|
||||
expect(isPrivateIpAddress("100.127.255.255")).toBe(true);
|
||||
expect(isPrivateIpAddress("100.100.50.25")).toBe(true);
|
||||
});
|
||||
|
||||
it("should not block 100.63.x.x or 100.128.x.x", () => {
|
||||
expect(isPrivateIpAddress("100.63.0.1")).toBe(false);
|
||||
expect(isPrivateIpAddress("100.128.0.1")).toBe(false);
|
||||
});
|
||||
|
||||
it("should allow public IPs", () => {
|
||||
expect(isPrivateIpAddress("8.8.8.8")).toBe(false);
|
||||
expect(isPrivateIpAddress("1.1.1.1")).toBe(false);
|
||||
expect(isPrivateIpAddress("203.0.113.50")).toBe(false);
|
||||
expect(isPrivateIpAddress("198.51.100.100")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv6 addresses", () => {
|
||||
it("should block loopback ::1", () => {
|
||||
expect(isPrivateIpAddress("::1")).toBe(true);
|
||||
expect(isPrivateIpAddress("::")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block fe80: link-local", () => {
|
||||
expect(isPrivateIpAddress("fe80::1")).toBe(true);
|
||||
expect(isPrivateIpAddress("fe80:0000:0000:0000:0000:0000:0000:0001")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block fc/fd unique local addresses", () => {
|
||||
expect(isPrivateIpAddress("fc00::1")).toBe(true);
|
||||
expect(isPrivateIpAddress("fd00::1")).toBe(true);
|
||||
expect(isPrivateIpAddress("fdab:cdef:1234::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block fec0: site-local (deprecated)", () => {
|
||||
expect(isPrivateIpAddress("fec0::1")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("IPv4-mapped IPv6 addresses", () => {
|
||||
it("should block ::ffff:10.x.x.x", () => {
|
||||
expect(isPrivateIpAddress("::ffff:10.0.0.1")).toBe(true);
|
||||
expect(isPrivateIpAddress("::ffff:10.255.255.255")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block ::ffff:127.0.0.1", () => {
|
||||
expect(isPrivateIpAddress("::ffff:127.0.0.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block ::ffff:192.168.x.x", () => {
|
||||
expect(isPrivateIpAddress("::ffff:192.168.1.1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow ::ffff:public IPs", () => {
|
||||
expect(isPrivateIpAddress("::ffff:8.8.8.8")).toBe(false);
|
||||
expect(isPrivateIpAddress("::ffff:1.1.1.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge cases", () => {
|
||||
it("should handle bracketed IPv6", () => {
|
||||
expect(isPrivateIpAddress("[::1]")).toBe(true);
|
||||
expect(isPrivateIpAddress("[fe80::1]")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle whitespace", () => {
|
||||
expect(isPrivateIpAddress(" 10.0.0.1 ")).toBe(true);
|
||||
expect(isPrivateIpAddress("\t192.168.1.1\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle case insensitivity", () => {
|
||||
expect(isPrivateIpAddress("FE80::1")).toBe(true);
|
||||
expect(isPrivateIpAddress("FC00::1")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for empty string", () => {
|
||||
expect(isPrivateIpAddress("")).toBe(false);
|
||||
expect(isPrivateIpAddress(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for invalid IPs", () => {
|
||||
expect(isPrivateIpAddress("not-an-ip")).toBe(false);
|
||||
expect(isPrivateIpAddress("256.256.256.256")).toBe(false);
|
||||
expect(isPrivateIpAddress("192.168.1")).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isBlockedHostname", () => {
|
||||
it("should block localhost", () => {
|
||||
expect(isBlockedHostname("localhost")).toBe(true);
|
||||
expect(isBlockedHostname("LOCALHOST")).toBe(true);
|
||||
expect(isBlockedHostname("LocalHost")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block metadata.google.internal", () => {
|
||||
expect(isBlockedHostname("metadata.google.internal")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block .localhost subdomains", () => {
|
||||
expect(isBlockedHostname("foo.localhost")).toBe(true);
|
||||
expect(isBlockedHostname("sub.domain.localhost")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block .local domains", () => {
|
||||
expect(isBlockedHostname("myhost.local")).toBe(true);
|
||||
expect(isBlockedHostname("printer.local")).toBe(true);
|
||||
});
|
||||
|
||||
it("should block .internal domains", () => {
|
||||
expect(isBlockedHostname("myservice.internal")).toBe(true);
|
||||
expect(isBlockedHostname("app.internal")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle trailing dots", () => {
|
||||
expect(isBlockedHostname("localhost.")).toBe(true);
|
||||
expect(isBlockedHostname("foo.local.")).toBe(true);
|
||||
});
|
||||
|
||||
it("should allow public domains", () => {
|
||||
expect(isBlockedHostname("google.com")).toBe(false);
|
||||
expect(isBlockedHostname("github.com")).toBe(false);
|
||||
expect(isBlockedHostname("example.org")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for empty hostname", () => {
|
||||
expect(isBlockedHostname("")).toBe(false);
|
||||
expect(isBlockedHostname(" ")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SsrfBlockedError", () => {
|
||||
it("should be an instance of Error", () => {
|
||||
const error = new SsrfBlockedError("test message");
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toBe("test message");
|
||||
expect(error.name).toBe("SsrfBlockedError");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPinnedLookup", () => {
|
||||
it("should return pinned address for matching hostname", () => {
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "example.com",
|
||||
addresses: ["1.2.3.4"],
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
lookup("example.com", (err, address, family) => {
|
||||
expect(err).toBeNull();
|
||||
expect(address).toBe("1.2.3.4");
|
||||
expect(family).toBe(4);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should cycle through multiple addresses", () => {
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "example.com",
|
||||
addresses: ["1.2.3.4", "5.6.7.8"],
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
lookup("example.com", (err, address1) => {
|
||||
expect(address1).toBe("1.2.3.4");
|
||||
lookup("example.com", (err2, address2) => {
|
||||
expect(address2).toBe("5.6.7.8");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should return all addresses when requested", () => {
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "example.com",
|
||||
addresses: ["1.2.3.4", "5.6.7.8"],
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
lookup("example.com", { all: true }, (err, addresses) => {
|
||||
expect(err).toBeNull();
|
||||
expect(addresses).toHaveLength(2);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect IPv6 addresses", () => {
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "example.com",
|
||||
addresses: ["2001:db8::1"],
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
lookup("example.com", (err, address, family) => {
|
||||
expect(err).toBeNull();
|
||||
expect(address).toBe("2001:db8::1");
|
||||
expect(family).toBe(6);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by requested family", () => {
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "example.com",
|
||||
addresses: ["1.2.3.4", "2001:db8::1"],
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
lookup("example.com", { family: 4 }, (err, address, family) => {
|
||||
expect(address).toBe("1.2.3.4");
|
||||
expect(family).toBe(4);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should normalize hostname case", () => {
|
||||
const lookup = createPinnedLookup({
|
||||
hostname: "Example.COM",
|
||||
addresses: ["1.2.3.4"],
|
||||
});
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
lookup("example.com", (err, address) => {
|
||||
expect(address).toBe("1.2.3.4");
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolvePinnedHostname", () => {
|
||||
it("should throw for blocked hostname", async () => {
|
||||
await expect(resolvePinnedHostname("localhost")).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
|
||||
it("should throw for private IP as hostname", async () => {
|
||||
await expect(resolvePinnedHostname("10.0.0.1")).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
|
||||
it("should throw for invalid hostname", async () => {
|
||||
await expect(resolvePinnedHostname("")).rejects.toThrow("Invalid hostname");
|
||||
});
|
||||
|
||||
it("should throw for .local domains", async () => {
|
||||
await expect(resolvePinnedHostname("myhost.local")).rejects.toThrow(SsrfBlockedError);
|
||||
});
|
||||
|
||||
it("should resolve with mock lookup function", async () => {
|
||||
const mockLookup = vi.fn().mockResolvedValue([
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]);
|
||||
|
||||
const result = await resolvePinnedHostname("example.com", mockLookup);
|
||||
|
||||
expect(result.hostname).toBe("example.com");
|
||||
expect(result.addresses).toContain("93.184.216.34");
|
||||
expect(result.lookup).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it("should throw when resolved IP is private", async () => {
|
||||
const mockLookup = vi.fn().mockResolvedValue([
|
||||
{ address: "192.168.1.1", family: 4 },
|
||||
]);
|
||||
|
||||
await expect(resolvePinnedHostname("evil.com", mockLookup)).rejects.toThrow(
|
||||
"Blocked: resolves to private/internal IP address"
|
||||
);
|
||||
});
|
||||
|
||||
it("should throw when no addresses resolved", async () => {
|
||||
const mockLookup = vi.fn().mockResolvedValue([]);
|
||||
|
||||
await expect(resolvePinnedHostname("empty.com", mockLookup)).rejects.toThrow(
|
||||
"Unable to resolve hostname"
|
||||
);
|
||||
});
|
||||
|
||||
it("should deduplicate resolved addresses", async () => {
|
||||
const mockLookup = vi.fn().mockResolvedValue([
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
{ address: "93.184.216.34", family: 4 },
|
||||
]);
|
||||
|
||||
const result = await resolvePinnedHostname("example.com", mockLookup);
|
||||
expect(result.addresses).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createPinnedDispatcher", () => {
|
||||
it("should create an Agent dispatcher", () => {
|
||||
const pinned = {
|
||||
hostname: "example.com",
|
||||
addresses: ["1.2.3.4"],
|
||||
lookup: createPinnedLookup({ hostname: "example.com", addresses: ["1.2.3.4"] }),
|
||||
};
|
||||
|
||||
const dispatcher = createPinnedDispatcher(pinned);
|
||||
expect(dispatcher).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("closeDispatcher", () => {
|
||||
it("should handle null dispatcher", async () => {
|
||||
await expect(closeDispatcher(null)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should handle undefined dispatcher", async () => {
|
||||
await expect(closeDispatcher(undefined)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("should call close on dispatcher with close method", async () => {
|
||||
const mockDispatcher = {
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
await closeDispatcher(mockDispatcher as any);
|
||||
expect(mockDispatcher.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call destroy on dispatcher without close method", async () => {
|
||||
const mockDispatcher = {
|
||||
destroy: vi.fn(),
|
||||
};
|
||||
|
||||
await closeDispatcher(mockDispatcher as any);
|
||||
expect(mockDispatcher.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle errors during close", async () => {
|
||||
const mockDispatcher = {
|
||||
close: vi.fn().mockRejectedValue(new Error("Close failed")),
|
||||
};
|
||||
|
||||
await expect(closeDispatcher(mockDispatcher as any)).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
15
vitest.config.ts
Normal file
15
vitest.config.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
coverage: {
|
||||
provider: "v8",
|
||||
reporter: ["text", "html"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: ["src/**/*.test.ts", "src/**/types.ts", "src/**/*.d.ts"],
|
||||
},
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue