Merge pull request #30 from multica-ai/forrestchang/add-unit-tests

test: add comprehensive unit tests across the codebase
This commit is contained in:
Jiayuan 2026-01-30 14:13:10 +08:00 committed by GitHub
commit 75fba044bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 3937 additions and 24 deletions

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ release
*.apk
*.ipa
monorepo.md
# test coverage
coverage

View file

@ -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
View file

@ -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

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
}
});
});
});
});

View 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("");
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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.");
});
});
});

View 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);
});
});
});

View 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();
});
});
});

View 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 &amp; World &lt;test&gt;</p>";
const result = htmlToMarkdownSimple(html);
expect(result.text).toContain("Hello & World <test>");
});
it("should decode numeric entities", () => {
const html = "<p>&#60;tag&#62; &#x3C;hex&#x3E;</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 ![alt](image.png) more text";
const result = markdownToText(md);
expect(result).not.toContain("![");
expect(result).toContain("Text");
expect(result).toContain("more text");
});
it("should extract link text and remove URLs", () => {
const md = "Click [here](https://example.com) 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;");
});
});
});

View 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);
});
});
});

View 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
View 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"],
},
},
});