test(v2): add vitest and cargo tests for sdk-messages, agent-tree, session, ctx
Frontend (vitest): - sdk-messages.test.ts: adaptSDKMessage() for all 9 message types - agent-tree.test.ts: buildAgentTree(), countTreeNodes(), subtreeCost() - vite.config.ts: vitest test config (src/**/*.test.ts) - package.json: vitest ^4.0.18 dev dep, "test" script Backend (cargo): - session.rs: SessionDb CRUD tests (sessions, SSH, settings, layout) with tempfile - ctx.rs: CtxDb error handling tests with missing database - Cargo.toml: tempfile 3 dev dependency
This commit is contained in:
parent
7e6e777713
commit
35a515db25
9 changed files with 1482 additions and 3 deletions
336
v2/package-lock.json
generated
336
v2/package-lock.json
generated
|
|
@ -23,7 +23,8 @@
|
|||
"svelte": "^5.45.2",
|
||||
"svelte-check": "^4.3.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
|
|
@ -968,6 +969,13 @@
|
|||
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@sveltejs/acorn-typescript": {
|
||||
"version": "1.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz",
|
||||
|
|
@ -1043,6 +1051,24 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
|
|
@ -1097,6 +1123,117 @@
|
|||
"integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"chai": "^6.2.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.18",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-canvas": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-canvas/-/addon-canvas-0.7.0.tgz",
|
||||
|
|
@ -1144,6 +1281,16 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/axobject-query": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
|
||||
|
|
@ -1164,6 +1311,16 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/character-entities-html4": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
|
||||
|
|
@ -1259,6 +1416,13 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.3",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
|
||||
|
|
@ -1318,6 +1482,26 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -1603,6 +1787,13 @@
|
|||
"regex-recursion": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -1777,6 +1968,13 @@
|
|||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
|
|
@ -1797,6 +1995,20 @@
|
|||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
|
|
@ -1863,6 +2075,23 @@
|
|||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
|
||||
"integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
|
@ -1880,6 +2109,16 @@
|
|||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/trim-lines": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
||||
|
|
@ -2102,6 +2341,101 @@
|
|||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
"@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.2.2",
|
||||
"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": "^6.0.0 || ^7.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/zimmerframe": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz",
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"check": "svelte-check --tsconfig ./tsconfig.app.json && tsc -p tsconfig.node.json",
|
||||
"tauri": "cargo tauri",
|
||||
"tauri:dev": "cargo tauri dev",
|
||||
"tauri:build": "cargo tauri build"
|
||||
"tauri:build": "cargo tauri build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
|
|
@ -19,7 +20,8 @@
|
|||
"svelte": "^5.45.2",
|
||||
"svelte-check": "^4.3.4",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.3.1"
|
||||
"vite": "^7.3.1",
|
||||
"vitest": "^4.0.18"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.10.1",
|
||||
|
|
|
|||
1
v2/src-tauri/Cargo.lock
generated
1
v2/src-tauri/Cargo.lock
generated
|
|
@ -253,6 +253,7 @@ dependencies = [
|
|||
"tauri-build",
|
||||
"tauri-plugin-log",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -28,3 +28,6 @@ rusqlite = { version = "0.31", features = ["bundled"] }
|
|||
dirs = "5"
|
||||
notify = { version = "6", features = ["macos_fsevent"] }
|
||||
tauri-plugin-updater = "2.10.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
|
|
|||
|
|
@ -170,3 +170,68 @@ impl CtxDb {
|
|||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Create a CtxDb with conn set to None, simulating a missing database.
|
||||
fn make_missing_db() -> CtxDb {
|
||||
CtxDb { conn: Mutex::new(None) }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_new_does_not_panic() {
|
||||
// CtxDb::new() should never panic even if ~/.claude-context/context.db
|
||||
// doesn't exist — it just stores None for the connection.
|
||||
let _db = CtxDb::new();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_projects_missing_db_returns_error() {
|
||||
let db = make_missing_db();
|
||||
let result = db.list_projects();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "ctx database not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_context_missing_db_returns_error() {
|
||||
let db = make_missing_db();
|
||||
let result = db.get_context("any-project");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "ctx database not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_shared_missing_db_returns_error() {
|
||||
let db = make_missing_db();
|
||||
let result = db.get_shared();
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "ctx database not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_summaries_missing_db_returns_error() {
|
||||
let db = make_missing_db();
|
||||
let result = db.get_summaries("any-project", 10);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "ctx database not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_missing_db_returns_error() {
|
||||
let db = make_missing_db();
|
||||
let result = db.search("anything");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "ctx database not found");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_empty_query_missing_db_returns_error() {
|
||||
let db = make_missing_db();
|
||||
let result = db.search("");
|
||||
assert!(result.is_err());
|
||||
assert_eq!(result.unwrap_err(), "ctx database not found");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -316,3 +316,331 @@ impl SessionDb {
|
|||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_db() -> SessionDb {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
SessionDb::open(&dir.path().to_path_buf()).unwrap()
|
||||
}
|
||||
|
||||
fn make_session(id: &str, title: &str) -> Session {
|
||||
Session {
|
||||
id: id.to_string(),
|
||||
session_type: "terminal".to_string(),
|
||||
title: title.to_string(),
|
||||
shell: Some("/bin/bash".to_string()),
|
||||
cwd: Some("/home/user".to_string()),
|
||||
args: Some(vec!["--login".to_string()]),
|
||||
created_at: 1000,
|
||||
last_used_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_ssh_session(id: &str, name: &str) -> SshSession {
|
||||
SshSession {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
host: "example.com".to_string(),
|
||||
port: 22,
|
||||
username: "admin".to_string(),
|
||||
key_file: "/home/user/.ssh/id_rsa".to_string(),
|
||||
folder: "/srv".to_string(),
|
||||
color: "#89b4fa".to_string(),
|
||||
created_at: 1000,
|
||||
last_used_at: 2000,
|
||||
}
|
||||
}
|
||||
|
||||
// --- Session CRUD ---
|
||||
|
||||
#[test]
|
||||
fn test_list_sessions_empty() {
|
||||
let db = make_db();
|
||||
let sessions = db.list_sessions().unwrap();
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_list_session() {
|
||||
let db = make_db();
|
||||
let s = make_session("s1", "My Terminal");
|
||||
db.save_session(&s).unwrap();
|
||||
|
||||
let sessions = db.list_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, "s1");
|
||||
assert_eq!(sessions[0].title, "My Terminal");
|
||||
assert_eq!(sessions[0].session_type, "terminal");
|
||||
assert_eq!(sessions[0].shell, Some("/bin/bash".to_string()));
|
||||
assert_eq!(sessions[0].cwd, Some("/home/user".to_string()));
|
||||
assert_eq!(sessions[0].args, Some(vec!["--login".to_string()]));
|
||||
assert_eq!(sessions[0].created_at, 1000);
|
||||
assert_eq!(sessions[0].last_used_at, 2000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_session_upsert() {
|
||||
let db = make_db();
|
||||
let mut s = make_session("s1", "First");
|
||||
db.save_session(&s).unwrap();
|
||||
|
||||
s.title = "Updated".to_string();
|
||||
db.save_session(&s).unwrap();
|
||||
|
||||
let sessions = db.list_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].title, "Updated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_session() {
|
||||
let db = make_db();
|
||||
db.save_session(&make_session("s1", "A")).unwrap();
|
||||
db.save_session(&make_session("s2", "B")).unwrap();
|
||||
assert_eq!(db.list_sessions().unwrap().len(), 2);
|
||||
|
||||
db.delete_session("s1").unwrap();
|
||||
let sessions = db.list_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, "s2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_session_no_error() {
|
||||
let db = make_db();
|
||||
// Should not error when deleting a session that doesn't exist
|
||||
db.delete_session("nonexistent").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_title() {
|
||||
let db = make_db();
|
||||
db.save_session(&make_session("s1", "Old")).unwrap();
|
||||
db.update_title("s1", "New Title").unwrap();
|
||||
|
||||
let sessions = db.list_sessions().unwrap();
|
||||
assert_eq!(sessions[0].title, "New Title");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_touch_session() {
|
||||
let db = make_db();
|
||||
db.save_session(&make_session("s1", "A")).unwrap();
|
||||
|
||||
let before = db.list_sessions().unwrap()[0].last_used_at;
|
||||
db.touch_session("s1").unwrap();
|
||||
let after = db.list_sessions().unwrap()[0].last_used_at;
|
||||
|
||||
// touch_session sets last_used_at to current time (epoch seconds),
|
||||
// which should be greater than our test fixture value of 2000
|
||||
assert!(after > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_session_with_no_optional_fields() {
|
||||
let db = make_db();
|
||||
let s = Session {
|
||||
id: "s1".to_string(),
|
||||
session_type: "agent".to_string(),
|
||||
title: "Agent".to_string(),
|
||||
shell: None,
|
||||
cwd: None,
|
||||
args: None,
|
||||
created_at: 1000,
|
||||
last_used_at: 2000,
|
||||
};
|
||||
db.save_session(&s).unwrap();
|
||||
|
||||
let sessions = db.list_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert!(sessions[0].shell.is_none());
|
||||
assert!(sessions[0].cwd.is_none());
|
||||
assert!(sessions[0].args.is_none());
|
||||
}
|
||||
|
||||
// --- Layout ---
|
||||
|
||||
#[test]
|
||||
fn test_load_default_layout() {
|
||||
let db = make_db();
|
||||
let layout = db.load_layout().unwrap();
|
||||
assert_eq!(layout.preset, "1-col");
|
||||
assert!(layout.pane_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_layout() {
|
||||
let db = make_db();
|
||||
let layout = LayoutState {
|
||||
preset: "2-col".to_string(),
|
||||
pane_ids: vec!["p1".to_string(), "p2".to_string()],
|
||||
};
|
||||
db.save_layout(&layout).unwrap();
|
||||
|
||||
let loaded = db.load_layout().unwrap();
|
||||
assert_eq!(loaded.preset, "2-col");
|
||||
assert_eq!(loaded.pane_ids, vec!["p1", "p2"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_layout_overwrites() {
|
||||
let db = make_db();
|
||||
let layout1 = LayoutState {
|
||||
preset: "2-col".to_string(),
|
||||
pane_ids: vec!["p1".to_string()],
|
||||
};
|
||||
db.save_layout(&layout1).unwrap();
|
||||
|
||||
let layout2 = LayoutState {
|
||||
preset: "3-col".to_string(),
|
||||
pane_ids: vec!["a".to_string(), "b".to_string(), "c".to_string()],
|
||||
};
|
||||
db.save_layout(&layout2).unwrap();
|
||||
|
||||
let loaded = db.load_layout().unwrap();
|
||||
assert_eq!(loaded.preset, "3-col");
|
||||
assert_eq!(loaded.pane_ids.len(), 3);
|
||||
}
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
#[test]
|
||||
fn test_get_setting_missing_returns_none() {
|
||||
let db = make_db();
|
||||
let val = db.get_setting("nonexistent").unwrap();
|
||||
assert!(val.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_and_get_setting() {
|
||||
let db = make_db();
|
||||
db.set_setting("theme", "mocha").unwrap();
|
||||
let val = db.get_setting("theme").unwrap();
|
||||
assert_eq!(val, Some("mocha".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_setting_overwrites() {
|
||||
let db = make_db();
|
||||
db.set_setting("font_size", "12").unwrap();
|
||||
db.set_setting("font_size", "14").unwrap();
|
||||
assert_eq!(db.get_setting("font_size").unwrap(), Some("14".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_settings() {
|
||||
let db = make_db();
|
||||
db.set_setting("b_key", "val_b").unwrap();
|
||||
db.set_setting("a_key", "val_a").unwrap();
|
||||
|
||||
let all = db.get_all_settings().unwrap();
|
||||
assert_eq!(all.len(), 2);
|
||||
// Should be ordered by key
|
||||
assert_eq!(all[0].0, "a_key");
|
||||
assert_eq!(all[1].0, "b_key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_all_settings_empty() {
|
||||
let db = make_db();
|
||||
let all = db.get_all_settings().unwrap();
|
||||
assert!(all.is_empty());
|
||||
}
|
||||
|
||||
// --- SSH Sessions ---
|
||||
|
||||
#[test]
|
||||
fn test_list_ssh_sessions_empty() {
|
||||
let db = make_db();
|
||||
let sessions = db.list_ssh_sessions().unwrap();
|
||||
assert!(sessions.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_list_ssh_session() {
|
||||
let db = make_db();
|
||||
let s = make_ssh_session("ssh1", "Prod Server");
|
||||
db.save_ssh_session(&s).unwrap();
|
||||
|
||||
let sessions = db.list_ssh_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, "ssh1");
|
||||
assert_eq!(sessions[0].name, "Prod Server");
|
||||
assert_eq!(sessions[0].host, "example.com");
|
||||
assert_eq!(sessions[0].port, 22);
|
||||
assert_eq!(sessions[0].username, "admin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_ssh_session() {
|
||||
let db = make_db();
|
||||
db.save_ssh_session(&make_ssh_session("ssh1", "A")).unwrap();
|
||||
db.save_ssh_session(&make_ssh_session("ssh2", "B")).unwrap();
|
||||
|
||||
db.delete_ssh_session("ssh1").unwrap();
|
||||
let sessions = db.list_ssh_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].id, "ssh2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_ssh_session() {
|
||||
let db = make_db();
|
||||
let mut s = make_ssh_session("ssh1", "Old Name");
|
||||
db.save_ssh_session(&s).unwrap();
|
||||
|
||||
s.name = "New Name".to_string();
|
||||
s.host = "new.example.com".to_string();
|
||||
s.port = 2222;
|
||||
s.last_used_at = 9999;
|
||||
db.update_ssh_session(&s).unwrap();
|
||||
|
||||
let sessions = db.list_ssh_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].name, "New Name");
|
||||
assert_eq!(sessions[0].host, "new.example.com");
|
||||
assert_eq!(sessions[0].port, 2222);
|
||||
assert_eq!(sessions[0].last_used_at, 9999);
|
||||
// created_at should be unchanged (UPDATE doesn't touch it)
|
||||
assert_eq!(sessions[0].created_at, 1000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ssh_session_upsert() {
|
||||
let db = make_db();
|
||||
let mut s = make_ssh_session("ssh1", "First");
|
||||
db.save_ssh_session(&s).unwrap();
|
||||
|
||||
s.name = "Second".to_string();
|
||||
db.save_ssh_session(&s).unwrap();
|
||||
|
||||
let sessions = db.list_ssh_sessions().unwrap();
|
||||
assert_eq!(sessions.len(), 1);
|
||||
assert_eq!(sessions[0].name, "Second");
|
||||
}
|
||||
|
||||
// --- Multiple sessions ordering ---
|
||||
|
||||
#[test]
|
||||
fn test_sessions_ordered_by_last_used_desc() {
|
||||
let db = make_db();
|
||||
let mut s1 = make_session("s1", "Older");
|
||||
s1.last_used_at = 1000;
|
||||
let mut s2 = make_session("s2", "Newer");
|
||||
s2.last_used_at = 3000;
|
||||
let mut s3 = make_session("s3", "Middle");
|
||||
s3.last_used_at = 2000;
|
||||
|
||||
db.save_session(&s1).unwrap();
|
||||
db.save_session(&s2).unwrap();
|
||||
db.save_session(&s3).unwrap();
|
||||
|
||||
let sessions = db.list_sessions().unwrap();
|
||||
assert_eq!(sessions[0].id, "s2");
|
||||
assert_eq!(sessions[1].id, "s3");
|
||||
assert_eq!(sessions[2].id, "s1");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
446
v2/src/lib/adapters/sdk-messages.test.ts
Normal file
446
v2/src/lib/adapters/sdk-messages.test.ts
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { adaptSDKMessage } from './sdk-messages';
|
||||
import type { InitContent, TextContent, ThinkingContent, ToolCallContent, ToolResultContent, StatusContent, CostContent, ErrorContent } from './sdk-messages';
|
||||
|
||||
// Mock crypto.randomUUID for deterministic IDs when uuid is missing
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('crypto', {
|
||||
randomUUID: () => 'fallback-uuid',
|
||||
});
|
||||
});
|
||||
|
||||
describe('adaptSDKMessage', () => {
|
||||
describe('system/init messages', () => {
|
||||
it('adapts a system init message', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: 'sys-001',
|
||||
session_id: 'sess-abc',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
cwd: '/home/user/project',
|
||||
tools: ['Read', 'Write', 'Bash'],
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe('sys-001');
|
||||
expect(result[0].type).toBe('init');
|
||||
const content = result[0].content as InitContent;
|
||||
expect(content.sessionId).toBe('sess-abc');
|
||||
expect(content.model).toBe('claude-sonnet-4-20250514');
|
||||
expect(content.cwd).toBe('/home/user/project');
|
||||
expect(content.tools).toEqual(['Read', 'Write', 'Bash']);
|
||||
});
|
||||
|
||||
it('defaults tools to empty array when missing', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: 'sys-002',
|
||||
session_id: 'sess-abc',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
cwd: '/tmp',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as InitContent;
|
||||
expect(content.tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('system/status messages (non-init subtypes)', () => {
|
||||
it('adapts a system status message', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'api_key_check',
|
||||
uuid: 'sys-003',
|
||||
status: 'API key is valid',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('status');
|
||||
const content = result[0].content as StatusContent;
|
||||
expect(content.subtype).toBe('api_key_check');
|
||||
expect(content.message).toBe('API key is valid');
|
||||
});
|
||||
|
||||
it('handles missing status field', () => {
|
||||
const raw = {
|
||||
type: 'system',
|
||||
subtype: 'some_event',
|
||||
uuid: 'sys-004',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as StatusContent;
|
||||
expect(content.subtype).toBe('some_event');
|
||||
expect(content.message).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant/text messages', () => {
|
||||
it('adapts a single text block', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-001',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'Hello, world!' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
expect(result[0].id).toBe('asst-001-text-0');
|
||||
const content = result[0].content as TextContent;
|
||||
expect(content.text).toBe('Hello, world!');
|
||||
});
|
||||
|
||||
it('preserves parentId on assistant messages', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-002',
|
||||
parent_tool_use_id: 'tool-parent-123',
|
||||
message: {
|
||||
content: [{ type: 'text', text: 'subagent response' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result[0].parentId).toBe('tool-parent-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant/thinking messages', () => {
|
||||
it('adapts a thinking block with thinking field', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-003',
|
||||
message: {
|
||||
content: [{ type: 'thinking', thinking: 'Let me consider...', text: 'fallback' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('thinking');
|
||||
expect(result[0].id).toBe('asst-003-think-0');
|
||||
const content = result[0].content as ThinkingContent;
|
||||
expect(content.text).toBe('Let me consider...');
|
||||
});
|
||||
|
||||
it('falls back to text field when thinking is absent', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-004',
|
||||
message: {
|
||||
content: [{ type: 'thinking', text: 'Thinking via text field' }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as ThinkingContent;
|
||||
expect(content.text).toBe('Thinking via text field');
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant/tool_use messages', () => {
|
||||
it('adapts a tool_use block', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-005',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'toolu_abc123',
|
||||
name: 'Read',
|
||||
input: { file_path: '/src/main.ts' },
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool_call');
|
||||
expect(result[0].id).toBe('asst-005-tool-0');
|
||||
const content = result[0].content as ToolCallContent;
|
||||
expect(content.toolUseId).toBe('toolu_abc123');
|
||||
expect(content.name).toBe('Read');
|
||||
expect(content.input).toEqual({ file_path: '/src/main.ts' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assistant messages with multiple content blocks', () => {
|
||||
it('produces one AgentMessage per content block', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-multi',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Hmm...' },
|
||||
{ type: 'text', text: 'Here is the answer.' },
|
||||
{ type: 'tool_use', id: 'toolu_xyz', name: 'Bash', input: { command: 'ls' } },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].type).toBe('thinking');
|
||||
expect(result[0].id).toBe('asst-multi-think-0');
|
||||
expect(result[1].type).toBe('text');
|
||||
expect(result[1].id).toBe('asst-multi-text-1');
|
||||
expect(result[2].type).toBe('tool_call');
|
||||
expect(result[2].id).toBe('asst-multi-tool-2');
|
||||
});
|
||||
|
||||
it('skips unknown content block types silently', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-unk-block',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello' },
|
||||
{ type: 'image', data: 'base64...' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('user/tool_result messages', () => {
|
||||
it('adapts a tool_result block', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-001',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_abc123',
|
||||
content: 'file contents here',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool_result');
|
||||
expect(result[0].id).toBe('user-001-result-0');
|
||||
const content = result[0].content as ToolResultContent;
|
||||
expect(content.toolUseId).toBe('toolu_abc123');
|
||||
expect(content.output).toBe('file contents here');
|
||||
});
|
||||
|
||||
it('falls back to tool_use_result when block content is missing', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-002',
|
||||
tool_use_result: { status: 'success', output: 'done' },
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_def456',
|
||||
// no content field
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as ToolResultContent;
|
||||
expect(content.output).toEqual({ status: 'success', output: 'done' });
|
||||
});
|
||||
|
||||
it('preserves parentId on user messages', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-003',
|
||||
parent_tool_use_id: 'parent-tool-id',
|
||||
message: {
|
||||
content: [{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'toolu_ghi',
|
||||
content: 'ok',
|
||||
}],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result[0].parentId).toBe('parent-tool-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('result/cost messages', () => {
|
||||
it('adapts a full result message', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
uuid: 'res-001',
|
||||
total_cost_usd: 0.0125,
|
||||
duration_ms: 4500,
|
||||
usage: { input_tokens: 1000, output_tokens: 500 },
|
||||
num_turns: 3,
|
||||
is_error: false,
|
||||
result: 'Task completed successfully.',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('cost');
|
||||
expect(result[0].id).toBe('res-001');
|
||||
const content = result[0].content as CostContent;
|
||||
expect(content.totalCostUsd).toBe(0.0125);
|
||||
expect(content.durationMs).toBe(4500);
|
||||
expect(content.inputTokens).toBe(1000);
|
||||
expect(content.outputTokens).toBe(500);
|
||||
expect(content.numTurns).toBe(3);
|
||||
expect(content.isError).toBe(false);
|
||||
expect(content.result).toBe('Task completed successfully.');
|
||||
expect(content.errors).toBeUndefined();
|
||||
});
|
||||
|
||||
it('defaults numeric fields to 0 when missing', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
uuid: 'res-002',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as CostContent;
|
||||
expect(content.totalCostUsd).toBe(0);
|
||||
expect(content.durationMs).toBe(0);
|
||||
expect(content.inputTokens).toBe(0);
|
||||
expect(content.outputTokens).toBe(0);
|
||||
expect(content.numTurns).toBe(0);
|
||||
expect(content.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('includes errors array when present', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
uuid: 'res-003',
|
||||
is_error: true,
|
||||
errors: ['Rate limit exceeded', 'Retry failed'],
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
const content = result[0].content as CostContent;
|
||||
expect(content.isError).toBe(true);
|
||||
expect(content.errors).toEqual(['Rate limit exceeded', 'Retry failed']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('returns unknown type for unrecognized message types', () => {
|
||||
const raw = {
|
||||
type: 'something_new',
|
||||
uuid: 'unk-001',
|
||||
data: 'arbitrary',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('unknown');
|
||||
expect(result[0].id).toBe('unk-001');
|
||||
expect(result[0].content).toBe(raw);
|
||||
});
|
||||
|
||||
it('uses crypto.randomUUID when uuid is missing', () => {
|
||||
const raw = {
|
||||
type: 'result',
|
||||
total_cost_usd: 0.001,
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result[0].id).toBe('fallback-uuid');
|
||||
});
|
||||
|
||||
it('returns empty array when assistant message has no message field', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-empty',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when assistant message.content is not an array', () => {
|
||||
const raw = {
|
||||
type: 'assistant',
|
||||
uuid: 'asst-bad-content',
|
||||
message: { content: 'not-an-array' },
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when user message has no message field', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-empty',
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns empty array when user message.content is not an array', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-bad',
|
||||
message: { content: 'string' },
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('ignores non-tool_result blocks in user messages', () => {
|
||||
const raw = {
|
||||
type: 'user',
|
||||
uuid: 'user-text',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'text', text: 'User typed something' },
|
||||
{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'ok' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const result = adaptSDKMessage(raw);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].type).toBe('tool_result');
|
||||
});
|
||||
|
||||
it('sets timestamp on every message', () => {
|
||||
const before = Date.now();
|
||||
const result = adaptSDKMessage({
|
||||
type: 'system',
|
||||
subtype: 'init',
|
||||
uuid: 'ts-test',
|
||||
session_id: 's',
|
||||
model: 'm',
|
||||
cwd: '/',
|
||||
});
|
||||
const after = Date.now();
|
||||
|
||||
expect(result[0].timestamp).toBeGreaterThanOrEqual(before);
|
||||
expect(result[0].timestamp).toBeLessThanOrEqual(after);
|
||||
});
|
||||
});
|
||||
});
|
||||
297
v2/src/lib/utils/agent-tree.test.ts
Normal file
297
v2/src/lib/utils/agent-tree.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { buildAgentTree, countTreeNodes, subtreeCost } from './agent-tree';
|
||||
import type { AgentMessage, ToolCallContent, ToolResultContent } from '../adapters/sdk-messages';
|
||||
import type { AgentTreeNode } from './agent-tree';
|
||||
|
||||
// Helper to create typed AgentMessages
|
||||
function makeToolCall(
|
||||
uuid: string,
|
||||
toolUseId: string,
|
||||
name: string,
|
||||
parentId?: string,
|
||||
): AgentMessage {
|
||||
return {
|
||||
id: uuid,
|
||||
type: 'tool_call',
|
||||
parentId,
|
||||
content: { toolUseId, name, input: {} } satisfies ToolCallContent,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeToolResult(uuid: string, toolUseId: string, parentId?: string): AgentMessage {
|
||||
return {
|
||||
id: uuid,
|
||||
type: 'tool_result',
|
||||
parentId,
|
||||
content: { toolUseId, output: 'ok' } satisfies ToolResultContent,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeTextMessage(uuid: string, text: string, parentId?: string): AgentMessage {
|
||||
return {
|
||||
id: uuid,
|
||||
type: 'text',
|
||||
parentId,
|
||||
content: { text },
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAgentTree', () => {
|
||||
it('creates a root node with no children from empty messages', () => {
|
||||
const tree = buildAgentTree('session-1', [], 'done', 0.05, 1500);
|
||||
|
||||
expect(tree.id).toBe('session-1');
|
||||
expect(tree.label).toBe('session-');
|
||||
expect(tree.status).toBe('done');
|
||||
expect(tree.costUsd).toBe(0.05);
|
||||
expect(tree.tokens).toBe(1500);
|
||||
expect(tree.children).toEqual([]);
|
||||
});
|
||||
|
||||
it('maps running/starting status to running', () => {
|
||||
const tree1 = buildAgentTree('s1', [], 'running', 0, 0);
|
||||
expect(tree1.status).toBe('running');
|
||||
|
||||
const tree2 = buildAgentTree('s2', [], 'starting', 0, 0);
|
||||
expect(tree2.status).toBe('running');
|
||||
});
|
||||
|
||||
it('maps error status to error', () => {
|
||||
const tree = buildAgentTree('s3', [], 'error', 0, 0);
|
||||
expect(tree.status).toBe('error');
|
||||
});
|
||||
|
||||
it('maps other statuses to done', () => {
|
||||
const tree = buildAgentTree('s4', [], 'completed', 0, 0);
|
||||
expect(tree.status).toBe('done');
|
||||
});
|
||||
|
||||
it('adds tool_call messages as children of root', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-1', 'Read'),
|
||||
makeToolCall('m2', 'tool-2', 'Write'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(2);
|
||||
expect(tree.children[0].id).toBe('tool-1');
|
||||
expect(tree.children[0].label).toBe('Read');
|
||||
expect(tree.children[0].toolName).toBe('Read');
|
||||
expect(tree.children[1].id).toBe('tool-2');
|
||||
expect(tree.children[1].label).toBe('Write');
|
||||
});
|
||||
|
||||
it('marks tool nodes as running until a result arrives', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-1', 'Bash'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'running', 0, 0);
|
||||
expect(tree.children[0].status).toBe('running');
|
||||
});
|
||||
|
||||
it('marks tool nodes as done when result arrives', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-1', 'Bash'),
|
||||
makeToolResult('m2', 'tool-1'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
expect(tree.children[0].status).toBe('done');
|
||||
});
|
||||
|
||||
it('nests subagent tool calls under their parent tool node', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'tool-parent', 'Agent'),
|
||||
makeToolCall('m2', 'tool-child', 'Read', 'tool-parent'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
const parentNode = tree.children[0];
|
||||
expect(parentNode.id).toBe('tool-parent');
|
||||
expect(parentNode.children).toHaveLength(1);
|
||||
expect(parentNode.children[0].id).toBe('tool-child');
|
||||
expect(parentNode.children[0].label).toBe('Read');
|
||||
});
|
||||
|
||||
it('handles deeply nested subagents (3 levels)', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'level-1', 'Agent'),
|
||||
makeToolCall('m2', 'level-2', 'SubAgent', 'level-1'),
|
||||
makeToolCall('m3', 'level-3', 'Read', 'level-2'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].children).toHaveLength(1);
|
||||
expect(tree.children[0].children[0].children).toHaveLength(1);
|
||||
expect(tree.children[0].children[0].children[0].id).toBe('level-3');
|
||||
});
|
||||
|
||||
it('attaches to root when parentId references a non-existent tool node', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolCall('m1', 'orphan-tool', 'Bash', 'nonexistent-parent'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].id).toBe('orphan-tool');
|
||||
});
|
||||
|
||||
it('ignores non-tool messages (text, thinking, etc.)', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeTextMessage('m1', 'Hello'),
|
||||
makeToolCall('m2', 'tool-1', 'Read'),
|
||||
makeTextMessage('m3', 'Done'),
|
||||
];
|
||||
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
|
||||
expect(tree.children).toHaveLength(1);
|
||||
expect(tree.children[0].id).toBe('tool-1');
|
||||
});
|
||||
|
||||
it('handles tool_result for a non-existent tool gracefully', () => {
|
||||
const messages: AgentMessage[] = [
|
||||
makeToolResult('m1', 'nonexistent-tool'),
|
||||
];
|
||||
|
||||
// Should not throw
|
||||
const tree = buildAgentTree('sess', messages, 'done', 0, 0);
|
||||
expect(tree.children).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('truncates session ID to 8 chars for label', () => {
|
||||
const tree = buildAgentTree('abcdefghijklmnop', [], 'done', 0, 0);
|
||||
expect(tree.label).toBe('abcdefgh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countTreeNodes', () => {
|
||||
it('returns 1 for a leaf node', () => {
|
||||
const leaf: AgentTreeNode = {
|
||||
id: 'leaf',
|
||||
label: 'leaf',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [],
|
||||
};
|
||||
expect(countTreeNodes(leaf)).toBe(1);
|
||||
});
|
||||
|
||||
it('counts all nodes in a flat tree', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
{ id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
{ id: 'c', label: 'c', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(countTreeNodes(root)).toBe(4);
|
||||
});
|
||||
|
||||
it('counts all nodes in a nested tree', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{
|
||||
id: 'a',
|
||||
label: 'a',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a1', label: 'a1', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
{ id: 'a2', label: 'a2', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
},
|
||||
{ id: 'b', label: 'b', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(countTreeNodes(root)).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtreeCost', () => {
|
||||
it('returns own cost for a leaf node', () => {
|
||||
const leaf: AgentTreeNode = {
|
||||
id: 'leaf',
|
||||
label: 'leaf',
|
||||
status: 'done',
|
||||
costUsd: 0.05,
|
||||
tokens: 0,
|
||||
children: [],
|
||||
};
|
||||
expect(subtreeCost(leaf)).toBe(0.05);
|
||||
});
|
||||
|
||||
it('aggregates cost across children', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0.10,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a', label: 'a', status: 'done', costUsd: 0.03, tokens: 0, children: [] },
|
||||
{ id: 'b', label: 'b', status: 'done', costUsd: 0.02, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(subtreeCost(root)).toBeCloseTo(0.15);
|
||||
});
|
||||
|
||||
it('aggregates cost recursively across nested children', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 1.0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{
|
||||
id: 'a',
|
||||
label: 'a',
|
||||
status: 'done',
|
||||
costUsd: 0.5,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a1', label: 'a1', status: 'done', costUsd: 0.25, tokens: 0, children: [] },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(subtreeCost(root)).toBeCloseTo(1.75);
|
||||
});
|
||||
|
||||
it('returns 0 for a tree with all zero costs', () => {
|
||||
const root: AgentTreeNode = {
|
||||
id: 'root',
|
||||
label: 'root',
|
||||
status: 'done',
|
||||
costUsd: 0,
|
||||
tokens: 0,
|
||||
children: [
|
||||
{ id: 'a', label: 'a', status: 'done', costUsd: 0, tokens: 0, children: [] },
|
||||
],
|
||||
};
|
||||
expect(subtreeCost(root)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -8,4 +8,7 @@ export default defineConfig({
|
|||
strictPort: true,
|
||||
},
|
||||
clearScreen: false,
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue