diff --git a/README.md b/README.md index b2bf9cc4..e920b57b 100644 --- a/README.md +++ b/README.md @@ -251,6 +251,15 @@ data/ docs/ # Architecture documentation ``` +## Roadmap to v1 + +Open Alice is in pre-release. The following items must land before the first stable version: + +- [ ] **Tool confirmation** — sensitive tools (order placement, cancellation, position close) require explicit user confirmation before execution, with a per-tool bypass mechanism for trusted workflows +- [ ] **Trading-as-Git stable interface** — finalize the stage → commit → push API surface (including `tradingStatus`, `tradingLog`, `tradingShow`, `tradingSync`) as a stable, versioned contract +- [ ] **IBKR adapter** — Interactive Brokers integration via the Client Portal or TWS API, adding a third trading backend alongside CCXT and Alpaca +- [ ] **Account snapshot & analytics** — unified trading account snapshots with P&L breakdown, exposure analysis, and historical performance tracking + ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=TraderAlice/OpenAlice&type=Date)](https://star-history.com/#TraderAlice/OpenAlice&Date) \ No newline at end of file diff --git a/package.json b/package.json index f69f4e93..9b6d47da 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "open-alice", - "version": "0.9.0-beta.3", + "version": "0.9.0-beta.4", "description": "File-based trading agent engine", "type": "module", "scripts": { @@ -47,7 +47,7 @@ "grammy": "^1.40.0", "hono": "^4.12.5", "json5": "^2.2.3", - "@traderalice/opentypebb": "link:./packages/opentypebb", + "@traderalice/opentypebb": "workspace:*", "pino": "^10.3.1", "playwright-core": "1.58.2", "sharp": "^0.34.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b0de3db..5a899797 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,7 +36,7 @@ importers: specifier: 0.34.48 version: 0.34.48 '@traderalice/opentypebb': - specifier: link:./packages/opentypebb + specifier: workspace:* version: link:packages/opentypebb ai: specifier: ^6.0.86 @@ -115,6 +115,40 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(tsx@4.21.0) + packages/opentypebb: + dependencies: + '@hono/node-server': + specifier: ^1.13.8 + version: 1.19.11(hono@4.12.7) + hono: + specifier: ^4.12.7 + version: 4.12.7 + undici: + specifier: ^7.22.0 + version: 7.22.0 + yahoo-finance2: + specifier: ^3.13.1 + version: 3.13.2 + zod: + specifier: ^3.24.2 + version: 3.25.76 + devDependencies: + '@types/node': + specifier: ^22.13.4 + version: 22.19.15 + tsup: + specifier: ^8.4.0 + version: 8.5.1(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + tsx: + specifier: ^4.19.3 + version: 4.21.0 + typescript: + specifier: ^5.7.3 + version: 5.9.3 + vitest: + specifier: ^3.0.6 + version: 3.2.4(@types/node@22.19.15)(tsx@4.21.0) + packages: '@ai-sdk/anthropic@3.0.44': @@ -164,6 +198,12 @@ packages: '@borewit/text-codec@0.2.1': resolution: {integrity: sha512-k7vvKPbf7J2fZ5klGRD9AeKfUvojuZIQ3BT5u7Jfv+puwXkUBUT5PVyMDfJZpy30CBDXGMgw7fguK/lpOMBvgw==} + '@deno/shim-deno-test@0.5.0': + resolution: {integrity: sha512-4nMhecpGlPi0cSzT67L+Tm+GOJqvuk8gqHBziqcUQOarnuIax1z96/gJHCSIz2Z0zhxE6Rzwb3IZXPtFh51j+w==} + + '@deno/shim-deno@0.18.2': + resolution: {integrity: sha512-oQ0CVmOio63wlhwQF75zA4ioolPvOwAoK0yuzcS5bDC1JUvH3y1GS8xPh8EOpcoDQRU4FTG8OQfxhpR+c6DrzA==} + '@emnapi/runtime@1.8.1': resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} @@ -739,6 +779,9 @@ packages: '@types/http-errors@2.0.5': resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/node@22.19.15': + resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/node@25.2.3': resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} @@ -764,9 +807,23 @@ packages: resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==} engines: {node: '>= 20'} + '@vitest/expect@3.2.4': + resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} + '@vitest/expect@4.0.18': resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/mocker@3.2.4': + resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + '@vitest/mocker@4.0.18': resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} peerDependencies: @@ -778,18 +835,33 @@ packages: vite: optional: true + '@vitest/pretty-format@3.2.4': + resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} + '@vitest/pretty-format@4.0.18': resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/runner@3.2.4': + resolution: {integrity: sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==} + '@vitest/runner@4.0.18': resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/snapshot@3.2.4': + resolution: {integrity: sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==} + '@vitest/snapshot@4.0.18': resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/spy@3.2.4': + resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} + '@vitest/spy@4.0.18': resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/utils@3.2.4': + resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@vitest/utils@4.0.18': resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} @@ -905,6 +977,10 @@ packages: resolution: {integrity: sha512-mLNwzq/GbSExA5QxVaIjud5AlhYxKY0q48dV4IHjBaUQNThbBzsGM1DdL60ofO/A4/xoRyBSjOy/YIsAFird7g==} engines: {node: '>=15.0.0'} + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -917,6 +993,10 @@ packages: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + chokidar@4.0.3: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} @@ -978,6 +1058,10 @@ packages: decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1031,6 +1115,10 @@ packages: escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + escape-string-regexp@4.0.0: resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} engines: {node: '>=10'} @@ -1133,6 +1221,9 @@ packages: picomatch: optional: true + fetch-mock-cache@2.3.1: + resolution: {integrity: sha512-hDk+Nbt0Y8Aq7KTEU6ASQAcpB34UjhkpD3QjzD6yvEKP4xVElAqXrjQ7maL+LYMGafx51Zq6qUfDM57PNu/qMw==} + file-entry-cache@6.0.1: resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} engines: {node: ^10.12.0 || >=12.0.0} @@ -1141,6 +1232,18 @@ packages: resolution: {integrity: sha512-8kPJMIGz1Yt/aPEwOsrR97ZyZaD1Iqm8PClb1nYFclUCkBi0Ma5IsYNQzvSFS9ib51lWyIw5mIT9rWzI/xjpzA==} engines: {node: '>=20'} + filename-reserved-regex@2.0.0: + resolution: {integrity: sha512-lc1bnsSr4L4Bdif8Xb/qrtokGbq5zlsms/CYH8PP+WtCkGNF65DPiQY8vG3SakEdRn8Dlnm+gW/qWKKjS5sZzQ==} + engines: {node: '>=4'} + + filenamify-url@2.1.2: + resolution: {integrity: sha512-3rMbAr7vDNMOGsj1aMniQFl749QjgM+lMJ/77ZRSPTIgxvolZwoQbn8dXLs7xfd+hAdli+oTnSWZNkJJLWQFEQ==} + engines: {node: '>=8'} + + filenamify@4.3.0: + resolution: {integrity: sha512-hcFKyUG57yWGAzu1CMt/dPzYZuv+jAJUT85bL8mrXvNe6hWj6yEHEc4EdcgiA6Z3oi1/9wXJdZPXF2dZNgwgOg==} + engines: {node: '>=8'} + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -1237,10 +1340,18 @@ packages: resolution: {integrity: sha512-3qq+FUBtlTHhtYxbxheZgY8NIFnkkC/MR8u5TTsr7YZ3wixryQ3cCwn3iZbg8p8B88iDBBAYSfZDS75t8MN7Vg==} engines: {node: '>=16.9.0'} + hono@4.12.7: + resolution: {integrity: sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==} + engines: {node: '>=16.9.0'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + humanize-url@2.1.1: + resolution: {integrity: sha512-V4nxsPGNE7mPjr1qDp471YfW8nhBiTRWrG/4usZlpvFU8I7gsV7Jvrrzv/snbLm5dWO3dr1ennu2YqnhTWFmYA==} + engines: {node: '>=8'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -1293,6 +1404,10 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isexe@3.1.5: + resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==} + engines: {node: '>=18'} + jose@6.1.3: resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} @@ -1300,6 +1415,9 @@ packages: resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} engines: {node: '>=10'} + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + js-yaml@4.1.1: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true @@ -1358,6 +1476,9 @@ packages: lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1425,6 +1546,10 @@ packages: encoding: optional: true + normalize-url@4.5.1: + resolution: {integrity: sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA==} + engines: {node: '>=8'} + nuid@1.1.6: resolution: {integrity: sha512-Eb3CPCupYscP1/S1FQcO5nxtu6l/F3k0MQ69h7f5osnsemVk5pkc8/5AyalVT+NCfra9M71U8POqF6EZa6IHvg==} engines: {node: '>= 8.16.0'} @@ -1489,6 +1614,10 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1555,6 +1684,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + psl@1.15.0: + resolution: {integrity: sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -1563,6 +1695,9 @@ packages: resolution: {integrity: sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==} engines: {node: '>=0.6'} + querystringify@2.2.0: + resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==} + queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} @@ -1593,6 +1728,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + requires-port@1.0.0: + resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -1718,6 +1856,13 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + strip-outer@1.0.1: + resolution: {integrity: sha512-k55yxKHwaXnpYGsOzg4Vl8+tDrWylxDEpknGjhTiZB8dFRU5rTo9CAzeycivxV3s+zlTKwrs6WxMxR95n26kwg==} + engines: {node: '>=0.10.0'} + strtok3@10.3.4: resolution: {integrity: sha512-KIy5nylvC5le1OdaaoCJ07L+8iQzJHGH6pWDuzS+d07Cu7n1MZ2x26P8ZKIWfbK02+XIL8Mp4RkWeqdUCrDMfg==} engines: {node: '>=18'} @@ -1759,10 +1904,29 @@ packages: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + tinyrainbow@3.0.3: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -1771,6 +1935,18 @@ packages: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} + tough-cookie-file-store@2.0.3: + resolution: {integrity: sha512-sMpZVcmFf6EYFHFFl+SYH4W1/OnXBYMGDsv2IlbQ2caHyFElW/UR/gpj/KYU1JwmP4dE9xqwv2+vWcmlXHojSw==} + engines: {node: '>=6'} + + tough-cookie@4.1.4: + resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} + engines: {node: '>=6'} + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} @@ -1778,6 +1954,10 @@ packages: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true + trim-repeated@1.0.0: + resolution: {integrity: sha512-pkonvlKk8/ZuR0D5tLW8ljt5I8kmxp2XKymhepUeOdCEfKpZaktSArkLHZt76OB1ZvO9bssUsDty4SWhLvZpLg==} + engines: {node: '>=0.10.0'} + ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} @@ -1842,6 +2022,9 @@ packages: resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} engines: {node: '>=18'} + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + undici-types@7.16.0: resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} @@ -1849,6 +2032,10 @@ packages: resolution: {integrity: sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==} engines: {node: '>=20.18.1'} + universalify@0.2.0: + resolution: {integrity: sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==} + engines: {node: '>= 4.0.0'} + unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} @@ -1856,6 +2043,9 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + url-parse@1.5.10: + resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==} + urljoin@0.1.5: resolution: {integrity: sha512-OSGi+PS3zxk8XfQ+7buaupOdrW9P9p+V9rjxGzJaYEYDe/B2rv3WJCupq5LNERW4w4kWxsduUUrhCxZZiQ2udw==} @@ -1866,6 +2056,11 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1906,6 +2101,34 @@ packages: yaml: optional: true + vitest@3.2.4: + resolution: {integrity: sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.4 + '@vitest/ui': 3.2.4 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vitest@4.0.18: resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1951,6 +2174,11 @@ packages: engines: {node: '>= 8'} hasBin: true + which@4.0.0: + resolution: {integrity: sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==} + 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'} @@ -1987,6 +2215,11 @@ packages: utf-8-validate: optional: true + yahoo-finance2@3.13.2: + resolution: {integrity: sha512-aAOJEjuLClfDxVPRKxjcwFoyzMr8BE/svgUqr5IjnQR+kppYbKO92Wl3SbAGz5DRghy6FiUfqi5FBDSBA/e2jg==} + engines: {node: '>=20.0.0'} + hasBin: true + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -1996,6 +2229,9 @@ packages: peerDependencies: zod: ^3.25 || ^4 + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2072,6 +2308,13 @@ snapshots: '@borewit/text-codec@0.2.1': {} + '@deno/shim-deno-test@0.5.0': {} + + '@deno/shim-deno@0.18.2': + dependencies: + '@deno/shim-deno-test': 0.5.0 + which: 4.0.0 + '@emnapi/runtime@1.8.1': dependencies: tslib: 2.8.1 @@ -2191,6 +2434,10 @@ snapshots: dependencies: hono: 4.12.5 + '@hono/node-server@1.19.11(hono@4.12.7)': + dependencies: + hono: 4.12.7 + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -2472,6 +2719,10 @@ snapshots: '@types/http-errors@2.0.5': {} + '@types/node@22.19.15': + dependencies: + undici-types: 6.21.0 + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 @@ -2497,6 +2748,14 @@ snapshots: '@vercel/oidc@3.1.0': {} + '@vitest/expect@3.2.4': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + tinyrainbow: 2.0.0 + '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 @@ -2506,6 +2765,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.0.3 + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0))': dependencies: '@vitest/spy': 4.0.18 @@ -2514,23 +2781,49 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.3)(tsx@4.21.0) + '@vitest/pretty-format@3.2.4': + dependencies: + tinyrainbow: 2.0.0 + '@vitest/pretty-format@4.0.18': dependencies: tinyrainbow: 3.0.3 + '@vitest/runner@3.2.4': + dependencies: + '@vitest/utils': 3.2.4 + pathe: 2.0.3 + strip-literal: 3.1.0 + '@vitest/runner@4.0.18': dependencies: '@vitest/utils': 4.0.18 pathe: 2.0.3 + '@vitest/snapshot@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + magic-string: 0.30.21 + 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@3.2.4': + dependencies: + tinyspy: 4.0.4 + '@vitest/spy@4.0.18': {} + '@vitest/utils@3.2.4': + dependencies: + '@vitest/pretty-format': 3.2.4 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + '@vitest/utils@4.0.18': dependencies: '@vitest/pretty-format': 4.0.18 @@ -2659,6 +2952,14 @@ snapshots: - bufferutil - utf-8-validate + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + chai@6.2.2: {} chalk@4.1.2: @@ -2668,6 +2969,8 @@ snapshots: chalk@5.6.2: {} + check-error@2.1.3: {} + chokidar@4.0.3: dependencies: readdirp: 4.1.2 @@ -2711,6 +3014,8 @@ snapshots: decimal.js@10.6.0: {} + deep-eql@5.0.2: {} + deep-is@0.1.4: {} depd@2.0.0: {} @@ -2774,6 +3079,8 @@ snapshots: escape-html@1.0.3: {} + escape-string-regexp@1.0.5: {} + escape-string-regexp@4.0.0: {} eslint-scope@7.2.2: @@ -2918,6 +3225,13 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-mock-cache@2.3.1: + dependencies: + debug: 4.4.3 + filenamify-url: 2.1.2 + transitivePeerDependencies: + - supports-color + file-entry-cache@6.0.1: dependencies: flat-cache: 3.2.0 @@ -2931,6 +3245,19 @@ snapshots: transitivePeerDependencies: - supports-color + filename-reserved-regex@2.0.0: {} + + filenamify-url@2.1.2: + dependencies: + filenamify: 4.3.0 + humanize-url: 2.1.1 + + filenamify@4.3.0: + dependencies: + filename-reserved-regex: 2.0.0 + strip-outer: 1.0.1 + trim-repeated: 1.0.0 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -3037,6 +3364,8 @@ snapshots: hono@4.12.5: {} + hono@4.12.7: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -3045,6 +3374,10 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + humanize-url@2.1.1: + dependencies: + normalize-url: 4.5.1 + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -3083,10 +3416,14 @@ snapshots: isexe@2.0.0: {} + isexe@3.1.5: {} + jose@6.1.3: {} joycon@3.1.1: {} + js-tokens@9.0.1: {} + js-yaml@4.1.1: dependencies: argparse: 2.0.1 @@ -3130,6 +3467,8 @@ snapshots: lodash@4.17.23: {} + loupe@3.2.1: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3189,6 +3528,8 @@ snapshots: dependencies: whatwg-url: 5.0.0 + normalize-url@4.5.1: {} + nuid@1.1.6: {} object-assign@4.1.1: {} @@ -3240,6 +3581,8 @@ snapshots: pathe@2.0.3: {} + pathval@2.0.1: {} + picocolors@1.1.1: {} picomatch@4.0.3: {} @@ -3298,12 +3641,18 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + psl@1.15.0: + dependencies: + punycode: 2.3.1 + punycode@2.3.1: {} qs@6.15.0: dependencies: side-channel: 1.1.0 + querystringify@2.2.0: {} + queue-microtask@1.2.3: {} quick-format-unescaped@4.0.4: {} @@ -3329,6 +3678,8 @@ snapshots: require-from-string@2.0.2: {} + requires-port@1.0.0: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -3514,6 +3865,14 @@ snapshots: strip-json-comments@3.1.1: {} + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + strip-outer@1.0.1: + dependencies: + escape-string-regexp: 1.0.5 + strtok3@10.3.4: dependencies: '@tokenizer/token': 0.3.0 @@ -3557,8 +3916,20 @@ snapshots: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + tinyrainbow@3.0.3: {} + tinyspy@4.0.4: {} + + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + toidentifier@1.0.1: {} token-types@6.1.2: @@ -3567,10 +3938,29 @@ snapshots: '@tokenizer/token': 0.3.0 ieee754: 1.2.1 + tough-cookie-file-store@2.0.3: + dependencies: + tough-cookie: 4.1.4 + + tough-cookie@4.1.4: + dependencies: + psl: 1.15.0 + punycode: 2.3.1 + universalify: 0.2.0 + url-parse: 1.5.10 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + tr46@0.0.3: {} tree-kill@1.2.2: {} + trim-repeated@1.0.0: + dependencies: + escape-string-regexp: 1.0.5 + ts-interface-checker@0.1.13: {} ts-nkeys@1.0.16: @@ -3637,16 +4027,25 @@ snapshots: uint8array-extras@1.5.0: {} + undici-types@6.21.0: {} + undici-types@7.16.0: {} undici@7.22.0: {} + universalify@0.2.0: {} + unpipe@1.0.0: {} uri-js@4.4.1: dependencies: punycode: 2.3.1 + url-parse@1.5.10: + dependencies: + querystringify: 2.2.0 + requires-port: 1.0.0 + urljoin@0.1.5: dependencies: extend: 2.0.2 @@ -3655,6 +4054,40 @@ snapshots: vary@1.1.2: {} + vite-node@3.2.4(@types/node@22.19.15)(tsx@4.21.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0): + dependencies: + esbuild: 0.27.3 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.19.15 + fsevents: 2.3.3 + tsx: 4.21.0 + vite@7.3.1(@types/node@25.2.3)(tsx@4.21.0): dependencies: esbuild: 0.27.3 @@ -3668,6 +4101,47 @@ snapshots: fsevents: 2.3.3 tsx: 4.21.0 + vitest@3.2.4(@types/node@22.19.15)(tsx@4.21.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@22.19.15)(tsx@4.21.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@22.19.15)(tsx@4.21.0) + vite-node: 3.2.4(@types/node@22.19.15)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.19.15 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(tsx@4.21.0): dependencies: '@vitest/expect': 4.0.18 @@ -3717,6 +4191,10 @@ snapshots: dependencies: isexe: 2.0.0 + which@4.0.0: + dependencies: + isexe: 3.1.5 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -3730,10 +4208,22 @@ snapshots: ws@8.19.0: {} + yahoo-finance2@3.13.2: + dependencies: + '@deno/shim-deno': 0.18.2 + fetch-mock-cache: 2.3.1 + json-schema: 0.4.0 + tough-cookie: 5.1.2 + tough-cookie-file-store: 2.0.3 + transitivePeerDependencies: + - supports-color + yocto-queue@0.1.0: {} zod-to-json-schema@3.25.1(zod@4.3.6): dependencies: zod: 4.3.6 + zod@3.25.76: {} + zod@4.3.6: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* diff --git a/src/connectors/mcp-ask/index.ts b/src/connectors/mcp-ask/index.ts index 080c8e0c..c2ba8cdf 100644 --- a/src/connectors/mcp-ask/index.ts +++ b/src/connectors/mcp-ask/index.ts @@ -1,2 +1,3 @@ export { McpAskPlugin } from './mcp-ask-plugin.js' export type { McpAskConfig } from './mcp-ask-plugin.js' +export { McpAskConnector } from './mcp-ask-connector.js' diff --git a/src/connectors/mcp-ask/mcp-ask-connector.ts b/src/connectors/mcp-ask/mcp-ask-connector.ts new file mode 100644 index 00000000..89a9d58f --- /dev/null +++ b/src/connectors/mcp-ask/mcp-ask-connector.ts @@ -0,0 +1,21 @@ +/** + * MCP Ask no-op connector. + * + * MCP is a pull-based protocol — external clients call tools to interact + * with Alice. There is no push channel, so send() always returns + * delivered: false. Registered with ConnectorCenter so the system knows + * this channel exists but cannot receive proactive notifications. + */ + +import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js' + +export class McpAskConnector implements Connector { + readonly channel = 'mcp-ask' + readonly to = 'default' + readonly capabilities: ConnectorCapabilities = { push: false, media: false } + + async send(_payload: SendPayload): Promise { + // MCP is pull-based; outbound send is a no-op. + return { delivered: false } + } +} diff --git a/src/connectors/mcp-ask/mcp-ask-plugin.ts b/src/connectors/mcp-ask/mcp-ask-plugin.ts index 41a8a8b4..e972a434 100644 --- a/src/connectors/mcp-ask/mcp-ask-plugin.ts +++ b/src/connectors/mcp-ask/mcp-ask-plugin.ts @@ -20,6 +20,7 @@ import { glob } from 'node:fs/promises' import { join, basename } from 'node:path' import type { Plugin, EngineContext } from '../../core/types.js' import { SessionStore, toTextHistory } from '../../core/session.js' +import { McpAskConnector } from './mcp-ask-connector.js' export interface McpAskConfig { port: number @@ -126,15 +127,7 @@ export class McpAskPlugin implements Plugin { }) // Register as connector for outbound delivery (heartbeat/cron) - this.unregisterConnector = ctx.connectorCenter.register({ - channel: 'mcp-ask', - to: 'default', - capabilities: { push: false, media: false }, - send: async () => { - // MCP is pull-based; outbound send is a no-op. - return { delivered: false } - }, - }) + this.unregisterConnector = ctx.connectorCenter.register(new McpAskConnector()) this.server = serve({ fetch: app.fetch, port: this.config.port }, (info) => { console.log(`mcp-ask connector listening on http://localhost:${info.port}/mcp`) diff --git a/src/connectors/telegram/index.ts b/src/connectors/telegram/index.ts index 1e22a438..0640dc10 100644 --- a/src/connectors/telegram/index.ts +++ b/src/connectors/telegram/index.ts @@ -1,2 +1,3 @@ export { TelegramPlugin } from './telegram-plugin.js' +export { TelegramConnector } from './telegram-connector.js' export type { TelegramConfig, ParsedMessage, MediaRef } from './types.js' diff --git a/src/connectors/telegram/telegram-connector.ts b/src/connectors/telegram/telegram-connector.ts new file mode 100644 index 00000000..90ca452d --- /dev/null +++ b/src/connectors/telegram/telegram-connector.ts @@ -0,0 +1,85 @@ +/** + * Telegram outbound connector. + * + * Delivers messages and media to a specific Telegram chat via the grammY + * Bot API. Handles photo attachments (read from disk, sent via sendPhoto) + * and automatic text chunking for messages exceeding Telegram's 4096-char limit. + * + * Does not support streaming (no sendStream) — ConnectorCenter falls back + * to draining the stream and calling send() with the completed result. + */ + +import { readFile } from 'node:fs/promises' +import { Bot, InputFile } from 'grammy' +import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js' + +export const MAX_MESSAGE_LENGTH = 4096 + +export class TelegramConnector implements Connector { + readonly channel = 'telegram' + readonly to: string + readonly capabilities: ConnectorCapabilities = { push: true, media: true } + + constructor( + private readonly bot: Bot, + private readonly chatId: number, + ) { + this.to = String(chatId) + } + + async send(payload: SendPayload): Promise { + // Send media first (photos) + if (payload.media && payload.media.length > 0) { + for (const attachment of payload.media) { + try { + const buf = await readFile(attachment.path) + await this.bot.api.sendPhoto(this.chatId, new InputFile(buf, 'screenshot.jpg')) + } catch (err) { + console.error('telegram: failed to send photo:', err) + } + } + } + + // Send text with chunking + if (payload.text) { + const chunks = splitMessage(payload.text, MAX_MESSAGE_LENGTH) + for (const chunk of chunks) { + await this.bot.api.sendMessage(this.chatId, chunk) + } + } + + return { delivered: true } + } +} + +// ==================== Helpers ==================== + +export function splitMessage(text: string, maxLength: number): string[] { + if (text.length <= maxLength) return [text] + + const chunks: string[] = [] + let remaining = text + + while (remaining.length > 0) { + if (remaining.length <= maxLength) { + chunks.push(remaining) + break + } + + // Try to split at a newline + let splitAt = remaining.lastIndexOf('\n', maxLength) + if (splitAt === -1 || splitAt < maxLength / 2) { + // Fall back to splitting at a space + splitAt = remaining.lastIndexOf(' ', maxLength) + } + if (splitAt === -1 || splitAt < maxLength / 2) { + // Hard split + splitAt = maxLength + } + + chunks.push(remaining.slice(0, splitAt)) + remaining = remaining.slice(splitAt).trimStart() + } + + return chunks +} diff --git a/src/connectors/telegram/telegram-plugin.ts b/src/connectors/telegram/telegram-plugin.ts index fdb6b757..d7f7b337 100644 --- a/src/connectors/telegram/telegram-plugin.ts +++ b/src/connectors/telegram/telegram-plugin.ts @@ -12,9 +12,7 @@ import { SessionStore } from '../../core/session' import { forceCompact } from '../../core/compaction' import { readAIBackend, writeAIBackend, type AIBackend } from '../../core/config' import type { ConnectorCenter } from '../../core/connector-center.js' -import type { Connector } from '../types.js' - -const MAX_MESSAGE_LENGTH = 4096 +import { TelegramConnector, splitMessage, MAX_MESSAGE_LENGTH } from './telegram-connector.js' const BACKEND_LABELS: Record = { 'claude-code': 'Claude Code', @@ -177,7 +175,7 @@ export class TelegramPlugin implements Plugin { // ── Register connector for outbound delivery (heartbeat / cron responses) ── if (this.config.allowedChatIds.length > 0) { const deliveryChatId = this.config.allowedChatIds[0] - this.unregisterConnector = this.connectorCenter!.register(this.createConnector(bot, deliveryChatId)) + this.unregisterConnector = this.connectorCenter!.register(new TelegramConnector(bot, deliveryChatId)) } // ── Start polling ── @@ -196,37 +194,6 @@ export class TelegramPlugin implements Plugin { this.unregisterConnector?.() } - private createConnector(bot: Bot, chatId: number): Connector { - return { - channel: 'telegram', - to: String(chatId), - capabilities: { push: true, media: true }, - send: async (payload) => { - // Send media first (photos) - if (payload.media && payload.media.length > 0) { - for (const attachment of payload.media) { - try { - const buf = await readFile(attachment.path) - await bot.api.sendPhoto(chatId, new InputFile(buf, 'screenshot.jpg')) - } catch (err) { - console.error('telegram: failed to send photo:', err) - } - } - } - - // Send text with chunking - if (payload.text) { - const chunks = splitMessage(payload.text, MAX_MESSAGE_LENGTH) - for (const chunk of chunks) { - await bot.api.sendMessage(chatId, chunk) - } - } - - return { delivered: true } - }, - } - } - private async getSession(userId: number): Promise { let session = this.sessions.get(userId) if (!session) { @@ -436,33 +403,3 @@ export class TelegramPlugin implements Plugin { } } } - -function splitMessage(text: string, maxLength: number): string[] { - if (text.length <= maxLength) return [text] - - const chunks: string[] = [] - let remaining = text - - while (remaining.length > 0) { - if (remaining.length <= maxLength) { - chunks.push(remaining) - break - } - - // Try to split at a newline - let splitAt = remaining.lastIndexOf('\n', maxLength) - if (splitAt === -1 || splitAt < maxLength / 2) { - // Fall back to splitting at a space - splitAt = remaining.lastIndexOf(' ', maxLength) - } - if (splitAt === -1 || splitAt < maxLength / 2) { - // Hard split - splitAt = maxLength - } - - chunks.push(remaining.slice(0, splitAt)) - remaining = remaining.slice(splitAt).trimStart() - } - - return chunks -} diff --git a/src/connectors/web/__tests__/chat-streaming.spec.ts b/src/connectors/web/__tests__/chat-streaming.spec.ts new file mode 100644 index 00000000..125f019e --- /dev/null +++ b/src/connectors/web/__tests__/chat-streaming.spec.ts @@ -0,0 +1,265 @@ +/** + * Web UI streaming tests. + * + * Simulates the chat.ts POST handler flow: AgentCenter produces a + * StreamableResult, which is iterated and forwarded to SSE clients. + * Verifies that tool_use, tool_result, and intermediate text events + * all reach the SSE clients in order. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { createChannel } from '../../../core/async-channel.js' +import { + StreamableResult, + type ProviderEvent, +} from '../../../core/ai-provider.js' +import { + FakeProvider, + MemorySessionStore, + makeAgentCenter, + toolUseEvent, + toolResultEvent, + textEvent, + doneEvent, +} from '../../../core/__tests__/pipeline/helpers.js' +import type { SSEClient } from '../routes/chat.js' + +// ==================== Module Mocks ==================== + +vi.mock('../../../core/compaction.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + compactIfNeeded: vi.fn().mockResolvedValue({ compacted: false, method: 'none' }), + } +}) + +vi.mock('../../../core/media-store.js', () => ({ + persistMedia: vi.fn().mockResolvedValue('2026-03-13/ace-aim-air.png'), + resolveMediaPath: vi.fn((name: string) => `/mock/media/${name}`), +})) + +vi.mock('../../../ai-providers/log-tool-call.js', () => ({ + logToolCall: vi.fn(), +})) + +// ==================== Helpers ==================== + +/** Simulate the chat.ts POST handler streaming loop. */ +async function simulateChatPost( + stream: StreamableResult, + clients: Map, +) { + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + for (const client of clients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + return await stream +} + +/** Create a capturing SSE client that records all sent data. */ +function makeCapturingClient(): { client: SSEClient; sent: string[] } { + const sent: string[] = [] + return { + sent, + client: { + id: 'test-client', + send: (data: string) => { sent.push(data) }, + }, + } +} + +// ==================== Tests ==================== + +describe('Web UI chat streaming', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should push tool_use, tool_result, and text events to SSE client', async () => { + const provider = new FakeProvider([ + toolUseEvent('t1', 'getQuote', { symbol: 'AAPL' }), + toolResultEvent('t1', '{"price": 185}'), + textEvent('AAPL is at $185'), + doneEvent('AAPL is at $185'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('check AAPL', session) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + const result = await simulateChatPost(stream, clients) + + // All 3 streaming events should reach SSE (done is skipped) + expect(sent).toHaveLength(3) + + const parsed = sent.map(s => JSON.parse(s)) + expect(parsed[0]).toEqual({ type: 'stream', event: { type: 'tool_use', id: 't1', name: 'getQuote', input: { symbol: 'AAPL' } } }) + expect(parsed[1]).toEqual({ type: 'stream', event: { type: 'tool_result', tool_use_id: 't1', content: '{"price": 185}' } }) + expect(parsed[2]).toEqual({ type: 'stream', event: { type: 'text', text: 'AAPL is at $185' } }) + + expect(result.text).toBe('AAPL is at $185') + }) + + it('should handle multiple tool loops with intermediate text', async () => { + const provider = new FakeProvider([ + textEvent('Let me check...'), + toolUseEvent('t1', 'getPortfolio', {}), + toolResultEvent('t1', '[{pos: AAPL}]'), + textEvent('Now calculating...'), + toolUseEvent('t2', 'calculateIndicator', { formula: 'RSI' }), + toolResultEvent('t2', '45.3'), + textEvent('Based on analysis, RSI is 45.3'), + doneEvent('Based on analysis, RSI is 45.3'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('analyze portfolio', session) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + await simulateChatPost(stream, clients) + + // 7 events: text, tool_use, tool_result, text, tool_use, tool_result, text + expect(sent).toHaveLength(7) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual([ + 'text', 'tool_use', 'tool_result', + 'text', 'tool_use', 'tool_result', + 'text', + ]) + }) + + it('should push events to multiple SSE clients', async () => { + const provider = new FakeProvider([ + toolUseEvent('t1', 'Read', { path: '/tmp' }), + toolResultEvent('t1', 'contents'), + textEvent('Done'), + doneEvent('Done'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('read file', session) + + const c1 = makeCapturingClient() + const c2 = makeCapturingClient() + const clients = new Map([['c1', c1.client], ['c2', c2.client]]) + + await simulateChatPost(stream, clients) + + expect(c1.sent).toHaveLength(3) + expect(c2.sent).toHaveLength(3) + expect(c1.sent).toEqual(c2.sent) + }) + + it('should deliver events with no SSE clients without error', async () => { + const provider = new FakeProvider([ + toolUseEvent('t1', 'Read', {}), + toolResultEvent('t1', 'ok'), + doneEvent('ok'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + const stream = ac.askWithSession('test', session) + + const emptyClients = new Map() + const result = await simulateChatPost(stream, emptyClients) + + expect(result.text).toBe('ok') + }) + + it('should work with AsyncChannel-based provider (simulates Claude Code CLI)', async () => { + // Simulates the ClaudeCodeProvider pattern: callbacks push to channel + const channel = createChannel() + + // Simulate async CLI output arriving over time + setTimeout(() => { + channel.push({ type: 'tool_use', id: 't1', name: 'Glob', input: { pattern: '*.ts' } }) + }, 5) + setTimeout(() => { + channel.push({ type: 'tool_result', tool_use_id: 't1', content: 'file1.ts\nfile2.ts' }) + }, 10) + setTimeout(() => { + channel.push({ type: 'text', text: 'Found 2 files' }) + }, 15) + setTimeout(() => { + channel.push({ type: 'done', result: { text: 'Found 2 files', media: [] } }) + channel.close() + }, 20) + + const sr = new StreamableResult(channel) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + await simulateChatPost(sr, clients) + + expect(sent).toHaveLength(3) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual(['tool_use', 'tool_result', 'text']) + }) + + it('should work when all events arrive synchronously (burst mode)', async () => { + // Simulates a fast CLI where all events are already in the buffer + const channel = createChannel() + + // Push all events synchronously (same tick) + channel.push({ type: 'tool_use', id: 't1', name: 'Read', input: {} }) + channel.push({ type: 'tool_result', tool_use_id: 't1', content: 'data' }) + channel.push({ type: 'text', text: 'result' }) + channel.push({ type: 'done', result: { text: 'result', media: [] } }) + channel.close() + + const sr = new StreamableResult(channel) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + await simulateChatPost(sr, clients) + + expect(sent).toHaveLength(3) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual(['tool_use', 'tool_result', 'text']) + }) + + it('should work with AgentCenter pipeline (full integration)', async () => { + // Full integration: FakeProvider → AgentCenter._generate() → StreamableResult → SSE + const provider = new FakeProvider([ + toolUseEvent('t1', 'getAccount', {}), + toolResultEvent('t1', '{"cash": 100000}'), + toolUseEvent('t2', 'getQuote', { aliceId: 'alpaca-AAPL' }), + toolResultEvent('t2', '{"last": 255.71}'), + textEvent('Account has $100k, AAPL at $255.71'), + doneEvent('Account has $100k, AAPL at $255.71'), + ]) + const ac = makeAgentCenter(provider) + const session = new MemorySessionStore() + + const stream = ac.askWithSession('show account', session) + + const { client, sent } = makeCapturingClient() + const clients = new Map([['c1', client]]) + + const result = await simulateChatPost(stream, clients) + + // 5 events: tool_use, tool_result, tool_use, tool_result, text + expect(sent).toHaveLength(5) + + const types = sent.map(s => JSON.parse(s).event.type) + expect(types).toEqual([ + 'tool_use', 'tool_result', + 'tool_use', 'tool_result', + 'text', + ]) + + expect(result.text).toBe('Account has $100k, AAPL at $255.71') + }) +}) diff --git a/src/connectors/web/__tests__/sse-transport.spec.ts b/src/connectors/web/__tests__/sse-transport.spec.ts new file mode 100644 index 00000000..d62ae2ea --- /dev/null +++ b/src/connectors/web/__tests__/sse-transport.spec.ts @@ -0,0 +1,488 @@ +/** + * SSE transport integration test. + * + * Stands up a real Hono HTTP server with the same SSE pattern as chat.ts, + * then uses a raw HTTP client to verify events arrive in real-time. + * + * This tests the gap that unit tests don't cover: the actual HTTP transport + * from server-side writeSSE() through @hono/node-server to the client. + */ +import { describe, it, expect, afterEach } from 'vitest' +import { Hono } from 'hono' +import { streamSSE } from 'hono/streaming' +import { serve } from '@hono/node-server' +import http from 'node:http' + +// ==================== Helpers ==================== + +interface TestServer { + port: number + close: () => void +} + +function startServer(app: Hono): Promise { + return new Promise((resolve) => { + const server = serve({ fetch: app.fetch, port: 0 }, (info) => { + resolve({ port: info.port, close: () => server.close() }) + }) + }) +} + +/** Minimal SSE client using raw http.get — no external dependencies. */ +function createSSEClient(url: string): { + events: string[] + connected: Promise + waitForEvents: (count: number, timeoutMs?: number) => Promise + close: () => void +} { + const events: string[] = [] + let req: http.ClientRequest | null = null + let resolveConnected: () => void + let rejectConnected: (err: Error) => void + const connected = new Promise((res, rej) => { + resolveConnected = res + rejectConnected = rej + }) + + // Waiters for specific event counts + let eventWaiter: { count: number; resolve: (events: string[]) => void; reject: (err: Error) => void } | null = null + + req = http.get(url, (res) => { + if (res.statusCode !== 200) { + rejectConnected(new Error(`SSE status ${res.statusCode}`)) + return + } + resolveConnected() + + let buffer = '' + res.on('data', (chunk: Buffer) => { + buffer += chunk.toString() + + // Parse SSE format: lines separated by \n\n + let idx: number + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + + // Extract data lines (skip event:, id:, etc.) + const dataLines = block.split('\n') + .filter(line => line.startsWith('data:')) + .map(line => line.slice(5).trim()) + + if (dataLines.length > 0) { + const data = dataLines.join('\n') + if (data) { + events.push(data) + if (eventWaiter && events.length >= eventWaiter.count) { + eventWaiter.resolve([...events]) + eventWaiter = null + } + } + } + } + }) + }) + + req.on('error', (err) => rejectConnected(err)) + + return { + events, + connected, + waitForEvents(count: number, timeoutMs = 5000): Promise { + if (events.length >= count) return Promise.resolve([...events]) + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + eventWaiter = null + reject(new Error(`SSE timeout: got ${events.length}/${count} events`)) + }, timeoutMs) + eventWaiter = { + count, + resolve: (evts) => { clearTimeout(timer); resolve(evts) }, + reject: (err) => { clearTimeout(timer); reject(err) }, + } + }) + }, + close() { + req?.destroy() + }, + } +} + +// ==================== Tests ==================== + +describe('SSE transport (real HTTP)', () => { + const servers: TestServer[] = [] + const clients: ReturnType[] = [] + + afterEach(() => { + clients.forEach(c => c.close()) + clients.length = 0 + servers.forEach(s => s.close()) + servers.length = 0 + }) + + it('writeSSE delivers events to client in real-time', async () => { + // Shared state between SSE and POST routes (same pattern as chat.ts) + type SSEClient = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('test', { + id: 'test', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('test') }) + await new Promise(() => {}) // keep alive + }) + }) + + app.post('/send', async (c) => { + const { events } = await c.req.json() as { events: Array<{ type: string; [k: string]: unknown }> } + for (const event of events) { + const data = JSON.stringify({ type: 'stream', event }) + for (const client of sseClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + return c.json({ ok: true }) + }) + + const server = await startServer(app) + servers.push(server) + + // Connect SSE client + const sse = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(sse) + await sse.connected + + // Small delay for client registration + await new Promise(r => setTimeout(r, 50)) + + // Send events via POST (simulating chat.ts handler) + const testEvents = [ + { type: 'tool_use', id: 't1', name: 'Read', input: { path: '/tmp' } }, + { type: 'tool_result', tool_use_id: 't1', content: 'file contents' }, + { type: 'text', text: 'Here is the file' }, + ] + + const waitPromise = sse.waitForEvents(3) + await fetch(`http://localhost:${server.port}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ events: testEvents }), + }) + + const received = await waitPromise + expect(received).toHaveLength(3) + + const parsed = received.map(e => JSON.parse(e)) + expect(parsed[0]).toEqual({ type: 'stream', event: testEvents[0] }) + expect(parsed[1]).toEqual({ type: 'stream', event: testEvents[1] }) + expect(parsed[2]).toEqual({ type: 'stream', event: testEvents[2] }) + }) + + it('fire-and-forget writeSSE still delivers events (race condition test)', async () => { + type SSEClient = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('test', { + id: 'test', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('test') }) + await new Promise(() => {}) + }) + }) + + app.post('/send', async (c) => { + // Simulate chat.ts: fire-and-forget SSE writes, then return JSON + const events = [ + { type: 'tool_use', id: 't1', name: 'Test', input: {} }, + { type: 'tool_result', tool_use_id: 't1', content: 'result' }, + { type: 'text', text: 'done' }, + ] + for (const event of events) { + const data = JSON.stringify({ type: 'stream', event }) + for (const client of sseClients.values()) { + try { client.send(data) } catch {} + } + } + return c.json({ text: 'done' }) + }) + + const server = await startServer(app) + servers.push(server) + + const sse = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(sse) + await sse.connected + await new Promise(r => setTimeout(r, 50)) + + const waitPromise = sse.waitForEvents(3) + + // POST and wait for response + const resp = await fetch(`http://localhost:${server.port}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + const postResult = await resp.json() + expect(postResult).toEqual({ text: 'done' }) + + // SSE events should still arrive even though POST already returned + const received = await waitPromise + expect(received).toHaveLength(3) + + const types = received.map(e => JSON.parse(e).event.type) + expect(types).toEqual(['tool_use', 'tool_result', 'text']) + }) + + it('async events (simulating Claude Code CLI timing) arrive in order', async () => { + type SSEClient = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('test', { + id: 'test', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('test') }) + await new Promise(() => {}) + }) + }) + + app.post('/send', async (c) => { + // Simulate Claude Code CLI: events arrive with async delays + const events = [ + { type: 'text', text: 'Let me check...' }, + { type: 'tool_use', id: 't1', name: 'Read', input: { path: '/tmp' } }, + { type: 'tool_result', tool_use_id: 't1', content: 'contents' }, + { type: 'text', text: 'Here are the contents' }, + ] + + for (const event of events) { + await new Promise(r => setTimeout(r, 10)) + const data = JSON.stringify({ type: 'stream', event }) + for (const client of sseClients.values()) { + try { client.send(data) } catch {} + } + } + + return c.json({ text: 'Here are the contents' }) + }) + + const server = await startServer(app) + servers.push(server) + + const sse = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(sse) + await sse.connected + await new Promise(r => setTimeout(r, 50)) + + const waitPromise = sse.waitForEvents(4) + + await fetch(`http://localhost:${server.port}/send`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }) + + const received = await waitPromise + expect(received).toHaveLength(4) + + const types = received.map(e => JSON.parse(e).event.type) + expect(types).toEqual(['text', 'tool_use', 'tool_result', 'text']) + }) + + // ==================== Streaming POST tests (new architecture) ==================== + + it('POST returns SSE stream with events and done (streaming POST pattern)', async () => { + const app = new Hono() + + app.post('/chat', (c) => { + return streamSSE(c, async (sseStream) => { + const events = [ + { type: 'tool_use', id: 't1', name: 'getQuote', input: { symbol: 'AAPL' } }, + { type: 'tool_result', tool_use_id: 't1', content: '{"price": 185}' }, + { type: 'text', text: 'AAPL is at $185' }, + ] + for (const event of events) { + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event }) }) + } + await sseStream.writeSSE({ + data: JSON.stringify({ type: 'done', text: 'AAPL is at $185', media: [] }), + }) + }) + }) + + const server = await startServer(app) + servers.push(server) + + // Read SSE from POST response body (same as frontend sendStreaming) + const res = await fetch(`http://localhost:${server.port}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'check AAPL' }), + }) + + expect(res.ok).toBe(true) + expect(res.headers.get('content-type')).toContain('text/event-stream') + + const text = await res.text() + const events = text + .split('\n\n') + .filter(block => block.trim()) + .map(block => { + const dataLine = block.split('\n').find(l => l.startsWith('data:')) + return dataLine ? JSON.parse(dataLine.slice(5).trim()) : null + }) + .filter(Boolean) + + expect(events).toHaveLength(4) + expect(events[0]).toEqual({ type: 'stream', event: { type: 'tool_use', id: 't1', name: 'getQuote', input: { symbol: 'AAPL' } } }) + expect(events[1]).toEqual({ type: 'stream', event: { type: 'tool_result', tool_use_id: 't1', content: '{"price": 185}' } }) + expect(events[2]).toEqual({ type: 'stream', event: { type: 'text', text: 'AAPL is at $185' } }) + expect(events[3]).toEqual({ type: 'done', text: 'AAPL is at $185', media: [] }) + }) + + it('POST SSE stream delivers events incrementally (not buffered)', async () => { + const app = new Hono() + + app.post('/chat', (c) => { + return streamSSE(c, async (sseStream) => { + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event: { type: 'text', text: 'thinking...' } }) }) + await new Promise(r => setTimeout(r, 50)) + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event: { type: 'tool_use', id: 't1', name: 'Read', input: {} } }) }) + await new Promise(r => setTimeout(r, 50)) + await sseStream.writeSSE({ data: JSON.stringify({ type: 'stream', event: { type: 'tool_result', tool_use_id: 't1', content: 'data' } }) }) + await new Promise(r => setTimeout(r, 50)) + await sseStream.writeSSE({ data: JSON.stringify({ type: 'done', text: 'done', media: [] }) }) + }) + }) + + const server = await startServer(app) + servers.push(server) + + const res = await fetch(`http://localhost:${server.port}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'test' }), + }) + + // Parse SSE incrementally using ReadableStream (same as frontend sendStreaming) + const reader = res.body!.getReader() + const decoder = new TextDecoder() + const parsed: unknown[] = [] + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + let idx: number + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + const dataLines = block.split('\n') + .filter(l => l.startsWith('data:')) + .map(l => l.slice(5).trim()) + if (dataLines.length > 0) { + try { parsed.push(JSON.parse(dataLines.join('\n'))) } catch {} + } + } + } + + expect(parsed).toHaveLength(4) + const types = parsed.map((e: any) => e.type === 'stream' ? e.event.type : e.type) + expect(types).toEqual(['text', 'tool_use', 'tool_result', 'done']) + }) + + it('POST SSE stream + separate SSE clients both receive events (multi-tab)', async () => { + type Client = { id: string; send: (data: string) => void } + const sseClients = new Map() + + const app = new Hono() + + // Separate SSE endpoint (for other tabs) + app.get('/events', (c) => { + return streamSSE(c, async (stream) => { + sseClients.set('tab2', { + id: 'tab2', + send: (data) => { stream.writeSSE({ data }).catch(() => {}) }, + }) + stream.onAbort(() => { sseClients.delete('tab2') }) + await new Promise(() => {}) + }) + }) + + // POST returns SSE stream (for requesting tab) + pushes to other clients + app.post('/chat', (c) => { + return streamSSE(c, async (sseStream) => { + const events = [ + { type: 'tool_use', id: 't1', name: 'Test', input: {} }, + { type: 'tool_result', tool_use_id: 't1', content: 'ok' }, + { type: 'text', text: 'result' }, + ] + for (const event of events) { + const data = JSON.stringify({ type: 'stream', event }) + await sseStream.writeSSE({ data }) + // Push to other SSE clients (multi-tab) + for (const client of sseClients.values()) { + try { client.send(data) } catch {} + } + } + await sseStream.writeSSE({ + data: JSON.stringify({ type: 'done', text: 'result', media: [] }), + }) + }) + }) + + const server = await startServer(app) + servers.push(server) + + // Tab 2: connect via SSE + const tab2 = createSSEClient(`http://localhost:${server.port}/events`) + clients.push(tab2) + await tab2.connected + await new Promise(r => setTimeout(r, 50)) + + // Tab 1: POST (gets SSE stream back) + const tab2Wait = tab2.waitForEvents(3) + const res = await fetch(`http://localhost:${server.port}/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message: 'test' }), + }) + + // Tab 1: verify POST SSE stream + const postText = await res.text() + const postEvents = postText + .split('\n\n') + .filter(b => b.trim()) + .map(b => { + const d = b.split('\n').find(l => l.startsWith('data:')) + return d ? JSON.parse(d.slice(5).trim()) : null + }) + .filter(Boolean) + + expect(postEvents).toHaveLength(4) // 3 stream + 1 done + expect(postEvents[3]).toEqual({ type: 'done', text: 'result', media: [] }) + + // Tab 2: verify SSE events arrived + const tab2Events = await tab2Wait + expect(tab2Events).toHaveLength(3) + const tab2Types = tab2Events.map(e => JSON.parse(e).event.type) + expect(tab2Types).toEqual(['tool_use', 'tool_result', 'text']) + }) +}) diff --git a/src/connectors/web/index.ts b/src/connectors/web/index.ts index e94617ef..65d40285 100644 --- a/src/connectors/web/index.ts +++ b/src/connectors/web/index.ts @@ -1,2 +1,3 @@ export { WebPlugin } from './web-plugin.js' +export { WebConnector } from './web-connector.js' export type { WebConfig } from './web-plugin.js' diff --git a/src/connectors/web/routes/chat.ts b/src/connectors/web/routes/chat.ts index 02c3ebaa..8cd59d71 100644 --- a/src/connectors/web/routes/chat.ts +++ b/src/connectors/web/routes/chat.ts @@ -55,28 +55,40 @@ export function createChatRoutes({ ctx, sessions, sseByChannel }: ChatDeps) { const stream = ctx.agentCenter.askWithSession(message, session, opts) - // Stream events to SSE clients for this channel as they arrive + // Stream events directly on the POST response (reliable, same connection). + // Also push to other SSE clients for multi-tab sync (best-effort). const channelClients = sseByChannel.get(channelId) ?? new Map() - for await (const event of stream) { - if (event.type === 'done') continue - const data = JSON.stringify({ type: 'stream', event }) - for (const client of channelClients.values()) { - try { client.send(data) } catch { /* disconnected */ } + + return streamSSE(c, async (sseStream) => { + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + + // Write to requesting client (reliable) + await sseStream.writeSSE({ data }) + + // Push to other SSE clients (best-effort, multi-tab) + for (const client of channelClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } } - } - // Stream fully drained — await resolves immediately with cached result - const result = await stream + // Stream fully drained — await resolves immediately with cached result + const result = await stream - await ctx.eventLog.append('message.sent', { - channel: 'web', to: channelId, prompt: message, - reply: result.text, durationMs: Date.now() - receivedEntry.ts, - }) + await ctx.eventLog.append('message.sent', { + channel: 'web', to: channelId, prompt: message, + reply: result.text, durationMs: Date.now() - receivedEntry.ts, + }) - // Media already persisted by AgentCenter — use pre-built URLs - const media = (result.mediaUrls ?? []).map(url => ({ type: 'image', url })) + // Media already persisted by AgentCenter — use pre-built URLs + const media = (result.mediaUrls ?? []).map((url: string) => ({ type: 'image', url })) - return c.json({ text: result.text, media }) + // Final event with result + await sseStream.writeSSE({ + data: JSON.stringify({ type: 'done', text: result.text, media }), + }) + }) }) app.get('/history', async (c) => { diff --git a/src/connectors/web/web-connector.ts b/src/connectors/web/web-connector.ts new file mode 100644 index 00000000..1d6d702a --- /dev/null +++ b/src/connectors/web/web-connector.ts @@ -0,0 +1,112 @@ +/** + * Web UI outbound connector. + * + * Delivers messages and streaming AI responses to connected web clients via + * Server-Sent Events (SSE). Persists media attachments to the content-addressable + * media store and records all outbound messages in the session JSONL for history. + * + * Supports both send() for completed messages and sendStream() for real-time + * streaming of ProviderEvents (tool_use, tool_result, text) to the browser. + */ + +import type { Connector, ConnectorCapabilities, SendPayload, SendResult } from '../types.js' +import type { StreamableResult } from '../../core/ai-provider.js' +import type { SSEClient } from './routes/chat.js' +import { SessionStore, type ContentBlock } from '../../core/session.js' +import { persistMedia } from '../../core/media-store.js' + +export class WebConnector implements Connector { + readonly channel = 'web' + readonly to = 'default' + readonly capabilities: ConnectorCapabilities = { push: true, media: true } + + constructor( + private readonly sseByChannel: Map>, + private readonly session: SessionStore, + ) {} + + async send(payload: SendPayload): Promise { + // Persist media to data/media/ with 3-word names + const media: Array<{ type: 'image'; url: string }> = [] + for (const m of payload.media ?? []) { + const name = await persistMedia(m.path) + media.push({ type: 'image', url: `/api/media/${name}` }) + } + + const data = JSON.stringify({ + type: 'message', + kind: payload.kind, + text: payload.text, + media: media.length > 0 ? media : undefined, + source: payload.source, + }) + + // Only broadcast to default channel SSE clients (heartbeat/cron stay in main channel) + const defaultClients = this.sseByChannel.get('default') ?? new Map() + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* client disconnected */ } + } + + // Persist to session so history survives page refresh (text + image blocks) + const blocks: ContentBlock[] = [ + { type: 'text', text: payload.text }, + ...media.map((m) => ({ type: 'image' as const, url: m.url })), + ] + await this.session.appendAssistant(blocks, 'notification', { + kind: payload.kind, + source: payload.source, + }) + + return { delivered: defaultClients.size > 0 } + } + + async sendStream( + stream: StreamableResult, + meta?: Pick, + ): Promise { + const defaultClients = this.sseByChannel.get('default') ?? new Map() + + // Push streaming events to SSE clients as they arrive + for await (const event of stream) { + if (event.type === 'done') continue + const data = JSON.stringify({ type: 'stream', event }) + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + } + + // Get completed result (resolves immediately — drain already finished) + const result = await stream + + // Persist media + const media: Array<{ type: 'image'; url: string }> = [] + for (const m of result.media) { + const name = await persistMedia(m.path) + media.push({ type: 'image', url: `/api/media/${name}` }) + } + + // Push final message to SSE (same format as send()) + const data = JSON.stringify({ + type: 'message', + kind: meta?.kind ?? 'notification', + text: result.text, + media: media.length > 0 ? media : undefined, + source: meta?.source, + }) + for (const client of defaultClients.values()) { + try { client.send(data) } catch { /* disconnected */ } + } + + // Persist to session (push notifications appear in web chat history) + const blocks: ContentBlock[] = [ + { type: 'text', text: result.text }, + ...media.map((m) => ({ type: 'image' as const, url: m.url })), + ] + await this.session.appendAssistant(blocks, 'notification', { + kind: meta?.kind ?? 'notification', + source: meta?.source, + }) + + return { delivered: defaultClients.size > 0 } + } +} diff --git a/src/connectors/web/web-plugin.ts b/src/connectors/web/web-plugin.ts index 11b55435..9458020c 100644 --- a/src/connectors/web/web-plugin.ts +++ b/src/connectors/web/web-plugin.ts @@ -4,10 +4,8 @@ import { serve } from '@hono/node-server' import { serveStatic } from '@hono/node-server/serve-static' import { resolve } from 'node:path' import type { Plugin, EngineContext } from '../../core/types.js' -import { SessionStore, type ContentBlock } from '../../core/session.js' -import type { Connector, SendPayload } from '../types.js' -import type { StreamableResult } from '../../core/ai-provider.js' -import { persistMedia } from '../../core/media-store.js' +import { SessionStore } from '../../core/session.js' +import { WebConnector } from './web-connector.js' import { readWebSubchannels } from '../../core/config.js' import { createChatRoutes, createMediaRoutes, type SSEClient } from './routes/chat.js' import { createChannelsRoutes } from './routes/channels.js' @@ -92,7 +90,7 @@ export class WebPlugin implements Plugin { // ==================== Connector registration ==================== // The web connector only targets the main 'default' channel (heartbeat/cron notifications). this.unregisterConnector = ctx.connectorCenter.register( - this.createConnector(this.sseByChannel, defaultSession), + new WebConnector(this.sseByChannel, defaultSession), ) // ==================== Start server ==================== @@ -106,96 +104,4 @@ export class WebPlugin implements Plugin { this.unregisterConnector?.() this.server?.close() } - - private createConnector( - sseByChannel: Map>, - session: SessionStore, - ): Connector { - return { - channel: 'web', - to: 'default', - capabilities: { push: true, media: true }, - send: async (payload) => { - // Persist media to data/media/ with 3-word names - const media: Array<{ type: 'image'; url: string }> = [] - for (const m of payload.media ?? []) { - const name = await persistMedia(m.path) - media.push({ type: 'image', url: `/api/media/${name}` }) - } - - const data = JSON.stringify({ - type: 'message', - kind: payload.kind, - text: payload.text, - media: media.length > 0 ? media : undefined, - source: payload.source, - }) - - // Only broadcast to default channel SSE clients (heartbeat/cron stay in main channel) - const defaultClients = sseByChannel.get('default') ?? new Map() - for (const client of defaultClients.values()) { - try { client.send(data) } catch { /* client disconnected */ } - } - - // Persist to session so history survives page refresh (text + image blocks) - const blocks: ContentBlock[] = [ - { type: 'text', text: payload.text }, - ...media.map((m) => ({ type: 'image' as const, url: m.url })), - ] - await session.appendAssistant(blocks, 'notification', { - kind: payload.kind, - source: payload.source, - }) - - return { delivered: defaultClients.size > 0 } - }, - - sendStream: async (stream: StreamableResult, meta?: Pick) => { - const defaultClients = sseByChannel.get('default') ?? new Map() - - // Push streaming events to SSE clients as they arrive - for await (const event of stream) { - if (event.type === 'done') continue - const data = JSON.stringify({ type: 'stream', event }) - for (const client of defaultClients.values()) { - try { client.send(data) } catch { /* disconnected */ } - } - } - - // Get completed result (resolves immediately — drain already finished) - const result = await stream - - // Persist media - const media: Array<{ type: 'image'; url: string }> = [] - for (const m of result.media) { - const name = await persistMedia(m.path) - media.push({ type: 'image', url: `/api/media/${name}` }) - } - - // Push final message to SSE (same format as send()) - const data = JSON.stringify({ - type: 'message', - kind: meta?.kind ?? 'notification', - text: result.text, - media: media.length > 0 ? media : undefined, - source: meta?.source, - }) - for (const client of defaultClients.values()) { - try { client.send(data) } catch { /* disconnected */ } - } - - // Persist to session (push notifications appear in web chat history) - const blocks: ContentBlock[] = [ - { type: 'text', text: result.text }, - ...media.map((m) => ({ type: 'image' as const, url: m.url })), - ] - await session.appendAssistant(blocks, 'notification', { - kind: meta?.kind ?? 'notification', - source: meta?.source, - }) - - return { delivered: defaultClients.size > 0 } - }, - } - } } diff --git a/src/core/session.spec.ts b/src/core/session.spec.ts index 90bf0885..6bfe5943 100644 --- a/src/core/session.spec.ts +++ b/src/core/session.spec.ts @@ -268,12 +268,12 @@ describe('toChatHistory', () => { { type: 'tool_use', id: 't1', name: 'Search', input: { q: 'test' } }, ]), ]) - // Should produce both a tool_calls item and a text item + // Should produce text before tool_calls (边想边做) expect(items).toHaveLength(2) - expect(items[0].kind).toBe('tool_calls') - expect(items[1].kind).toBe('text') - if (items[1].kind === 'text') { - expect(items[1].text).toBe('Let me check') + expect(items[0].kind).toBe('text') + expect(items[1].kind).toBe('tool_calls') + if (items[0].kind === 'text') { + expect(items[0].text).toBe('Let me check') } }) diff --git a/src/core/session.ts b/src/core/session.ts index 688bd5d4..7eeab828 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -508,13 +508,13 @@ export function toChatHistory(entries: SessionEntry[]): ChatHistoryItem[] { result: resultMap.get(tu.id) ? truncate(resultMap.get(tu.id)!, TOOL_SUMMARY_MAX) : undefined, })) - items.push({ kind: 'tool_calls', calls, timestamp: entry.timestamp }) - - // If there were also text blocks in this same entry, emit them separately. + // Text before tools (边想边做 — thinking then doing). const text = textBlocks.map((b) => b.text).join('\n') if (text.trim() || media) { items.push({ kind: 'text', role: message.role as 'user' | 'assistant', text, timestamp: entry.timestamp, metadata: entry.metadata, media }) } + + items.push({ kind: 'tool_calls', calls, timestamp: entry.timestamp }) continue } diff --git a/ui/index.html b/ui/index.html index 25ffa4d1..96dd23d1 100644 --- a/ui/index.html +++ b/ui/index.html @@ -3,6 +3,7 @@ + Open Alice diff --git a/ui/public/alice.ico b/ui/public/alice.ico new file mode 100644 index 00000000..f5d89ded Binary files /dev/null and b/ui/public/alice.ico differ diff --git a/ui/src/api/chat.ts b/ui/src/api/chat.ts index aa333ebd..db7e1c32 100644 --- a/ui/src/api/chat.ts +++ b/ui/src/api/chat.ts @@ -1,18 +1,66 @@ import { headers } from './client' -import type { ChatResponse, ChatHistoryItem } from './types' +import type { ChatHistoryItem } from './types' + +// ==================== Stream event types ==================== + +export type ChatStreamEvent = + | { type: 'stream'; event: { type: 'tool_use'; id: string; name: string; input: unknown } } + | { type: 'stream'; event: { type: 'tool_result'; tool_use_id: string; content: string } } + | { type: 'stream'; event: { type: 'text'; text: string } } + | { type: 'done'; text: string; media: Array<{ type: string; url: string }> } + +// ==================== API ==================== export const chatApi = { - async send(message: string, channelId?: string): Promise { + /** + * Send a chat message and stream back events (SSE over POST). + * Yields tool_use, tool_result, text events as they arrive, + * then a final done event with the complete result. + */ + async *sendStreaming( + message: string, + channelId?: string, + signal?: AbortSignal, + ): AsyncGenerator { const res = await fetch('/api/chat', { method: 'POST', headers, body: JSON.stringify({ message, ...(channelId ? { channelId } : {}) }), + signal, }) if (!res.ok) { const err = await res.json().catch(() => ({ error: res.statusText })) throw new Error(err.error || res.statusText) } - return res.json() + + // Parse SSE format from streaming POST response + const reader = res.body!.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + + // SSE blocks are separated by \n\n + let idx: number + while ((idx = buffer.indexOf('\n\n')) !== -1) { + const block = buffer.slice(0, idx) + buffer = buffer.slice(idx + 2) + + // Extract data: lines from SSE block + const dataLines = block.split('\n') + .filter(l => l.startsWith('data:')) + .map(l => l.slice(5).trim()) + + if (dataLines.length > 0) { + try { + yield JSON.parse(dataLines.join('\n')) as ChatStreamEvent + } catch { /* ignore malformed events */ } + } + } + } }, async history(limit = 100, channel?: string): Promise<{ messages: ChatHistoryItem[] }> { diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index 8d46e5b6..9555af7f 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -162,7 +162,18 @@ export interface AccountInfo { } export interface Position { - contract: { aliceId?: string; symbol?: string; secType?: string; exchange?: string; currency?: string } + contract: { + aliceId?: string + symbol?: string + secType?: string + exchange?: string + currency?: string + lastTradeDateOrContractMonth?: string + strike?: number + right?: string + multiplier?: number + localSymbol?: string + } side: 'long' | 'short' qty: number avgEntryPrice: number diff --git a/ui/src/hooks/__tests__/useChat.spec.ts b/ui/src/hooks/__tests__/useChat.spec.ts new file mode 100644 index 00000000..979e00cf --- /dev/null +++ b/ui/src/hooks/__tests__/useChat.spec.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from 'vitest' +import { reduceStreamEvent, finalizeMessages } from '../useChat' + +// ==================== reduceStreamEvent ==================== + +describe('reduceStreamEvent', () => { + it('text event creates a new text segment', () => { + const result = reduceStreamEvent([], { type: 'text', text: 'hello' }) + expect(result).toEqual([{ kind: 'text', text: 'hello' }]) + }) + + it('consecutive text events merge into one segment', () => { + const s1 = reduceStreamEvent([], { type: 'text', text: 'hel' }) + const s2 = reduceStreamEvent(s1, { type: 'text', text: 'lo' }) + expect(s2).toEqual([{ kind: 'text', text: 'hello' }]) + }) + + it('tool_use after text creates a new tools segment', () => { + const s1 = reduceStreamEvent([], { type: 'text', text: 'thinking...' }) + const s2 = reduceStreamEvent(s1, { + type: 'tool_use', id: 't1', name: 'read_file', input: { path: '/foo' }, + }) + expect(s2).toHaveLength(2) + expect(s2[0]).toEqual({ kind: 'text', text: 'thinking...' }) + expect(s2[1]).toEqual({ + kind: 'tools', + tools: [{ id: 't1', name: 'read_file', input: { path: '/foo' }, status: 'running' }], + }) + }) + + it('consecutive tool_use events merge into one tools segment', () => { + const s1 = reduceStreamEvent([], { + type: 'tool_use', id: 't1', name: 'read', input: 'a', + }) + const s2 = reduceStreamEvent(s1, { + type: 'tool_use', id: 't2', name: 'write', input: 'b', + }) + expect(s2).toHaveLength(1) + expect(s2[0].kind).toBe('tools') + if (s2[0].kind === 'tools') { + expect(s2[0].tools).toHaveLength(2) + expect(s2[0].tools[0].name).toBe('read') + expect(s2[0].tools[1].name).toBe('write') + } + }) + + it('text after tools creates a new text segment (边想边做)', () => { + let segs = reduceStreamEvent([], { type: 'text', text: 'Let me check' }) + segs = reduceStreamEvent(segs, { type: 'tool_use', id: 't1', name: 'search', input: {} }) + segs = reduceStreamEvent(segs, { type: 'text', text: 'Found it' }) + expect(segs).toHaveLength(3) + expect(segs[0]).toEqual({ kind: 'text', text: 'Let me check' }) + expect(segs[1].kind).toBe('tools') + expect(segs[2]).toEqual({ kind: 'text', text: 'Found it' }) + }) + + it('tool_result marks the correct tool as done', () => { + let segs = reduceStreamEvent([], { type: 'tool_use', id: 't1', name: 'read', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_use', id: 't2', name: 'write', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_result', tool_use_id: 't1', content: 'file contents' }) + if (segs[0].kind === 'tools') { + expect(segs[0].tools[0].status).toBe('done') + expect(segs[0].tools[0].result).toBe('file contents') + expect(segs[0].tools[1].status).toBe('running') + } + }) + + it('tool_result finds tools across multiple segments', () => { + let segs = reduceStreamEvent([], { type: 'tool_use', id: 't1', name: 'read', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_result', tool_use_id: 't1', content: 'ok' }) + segs = reduceStreamEvent(segs, { type: 'text', text: 'now writing' }) + segs = reduceStreamEvent(segs, { type: 'tool_use', id: 't2', name: 'write', input: '' }) + segs = reduceStreamEvent(segs, { type: 'tool_result', tool_use_id: 't2', content: 'written' }) + + // t2 is in the second tools segment (index 2) + expect(segs).toHaveLength(3) + if (segs[2].kind === 'tools') { + expect(segs[2].tools[0].status).toBe('done') + expect(segs[2].tools[0].result).toBe('written') + } + }) + + it('tool_result for unknown id is a no-op', () => { + const segs = reduceStreamEvent( + [{ kind: 'tools', tools: [{ id: 't1', name: 'read', input: '', status: 'running' }] }], + { type: 'tool_result', tool_use_id: 'unknown', content: 'nope' }, + ) + if (segs[0].kind === 'tools') { + expect(segs[0].tools[0].status).toBe('running') + expect(segs[0].tools[0].result).toBeUndefined() + } + }) + + it('full interleaved sequence produces correct structure', () => { + const events = [ + { type: 'text' as const, text: 'Let me look into this.' }, + { type: 'tool_use' as const, id: 't1', name: 'search', input: { q: 'bug' } }, + { type: 'tool_result' as const, tool_use_id: 't1', content: 'found 3 results' }, + { type: 'text' as const, text: 'I see the issue. Let me fix it.' }, + { type: 'tool_use' as const, id: 't2', name: 'edit', input: { file: 'a.ts' } }, + { type: 'tool_result' as const, tool_use_id: 't2', content: 'edited' }, + ] + + let segs = reduceStreamEvent([], events[0]) + for (let i = 1; i < events.length; i++) { + segs = reduceStreamEvent(segs, events[i]) + } + + expect(segs).toHaveLength(4) + expect(segs[0]).toEqual({ kind: 'text', text: 'Let me look into this.' }) + expect(segs[1].kind).toBe('tools') + if (segs[1].kind === 'tools') { + expect(segs[1].tools[0].name).toBe('search') + expect(segs[1].tools[0].status).toBe('done') + expect(segs[1].tools[0].result).toBe('found 3 results') + } + expect(segs[2]).toEqual({ kind: 'text', text: 'I see the issue. Let me fix it.' }) + expect(segs[3].kind).toBe('tools') + if (segs[3].kind === 'tools') { + expect(segs[3].tools[0].name).toBe('edit') + expect(segs[3].tools[0].status).toBe('done') + } + }) + + it('does not mutate the input segments array', () => { + const original = [{ kind: 'text' as const, text: 'hello' }] + const frozen = [...original] + const result = reduceStreamEvent(original, { type: 'text', text: ' world' }) + expect(original).toEqual(frozen) + expect(result).not.toBe(original) + }) +}) + +// ==================== finalizeMessages ==================== + +describe('finalizeMessages', () => { + let idCounter = 0 + const idGen = () => idCounter++ + + it('returns empty array when finalText is empty', () => { + const result = finalizeMessages([], '', undefined, idGen) + expect(result).toEqual([]) + }) + + it('text-only response produces a single assistant message', () => { + idCounter = 100 + const result = finalizeMessages([], 'Hello!', undefined, idGen) + expect(result).toHaveLength(1) + expect(result[0]).toEqual({ + kind: 'text', role: 'assistant', text: 'Hello!', media: undefined, _id: 100, + }) + }) + + it('preserves interleaved text → tools order', () => { + idCounter = 200 + const segments = [ + { kind: 'text' as const, text: 'thinking' }, + { + kind: 'tools' as const, + tools: [ + { id: 't1', name: 'read_file', input: '/foo', status: 'done' as const, result: 'contents' }, + ], + }, + ] + const result = finalizeMessages(segments, 'Done reading.', undefined, idGen) + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ kind: 'text', role: 'assistant', text: 'thinking' }) + expect(result[1].kind).toBe('tool_calls') + if (result[1].kind === 'tool_calls') { + expect(result[1].calls).toEqual([ + { name: 'read_file', input: '/foo', result: 'contents' }, + ]) + } + // finalText appended as new text item (last segment was tools, not text) + expect(result[2]).toMatchObject({ kind: 'text', role: 'assistant', text: 'Done reading.' }) + }) + + it('serializes non-string tool input to JSON', () => { + idCounter = 300 + const segments = [ + { + kind: 'tools' as const, + tools: [ + { id: 't1', name: 'search', input: { query: 'bug' }, status: 'done' as const, result: 'ok' }, + ], + }, + ] + const result = finalizeMessages(segments, 'Found it.', undefined, idGen) + // tools segment is first, then finalText appended + expect(result[0].kind).toBe('tool_calls') + if (result[0].kind === 'tool_calls') { + expect(result[0].calls[0].input).toBe('{"query":"bug"}') + } + }) + + it('passes through media array', () => { + idCounter = 400 + const media = [{ type: 'image', url: '/img.png' }] + const result = finalizeMessages([], 'Here is an image.', media, idGen) + expect(result[0]).toMatchObject({ + kind: 'text', role: 'assistant', media, + }) + }) + + it('preserves interleaved tools → text → tools structure', () => { + idCounter = 500 + const segments = [ + { kind: 'tools' as const, tools: [{ id: 't1', name: 'a', input: '', status: 'done' as const }] }, + { kind: 'text' as const, text: 'middle' }, + { kind: 'tools' as const, tools: [{ id: 't2', name: 'b', input: '', status: 'done' as const }] }, + ] + const result = finalizeMessages(segments, 'All done.', undefined, idGen) + // Each segment becomes its own DisplayItem, plus finalText appended + expect(result).toHaveLength(4) + expect(result[0].kind).toBe('tool_calls') + if (result[0].kind === 'tool_calls') { + expect(result[0].calls).toHaveLength(1) + expect(result[0].calls[0].name).toBe('a') + } + expect(result[1]).toMatchObject({ kind: 'text', role: 'assistant', text: 'middle' }) + expect(result[2].kind).toBe('tool_calls') + if (result[2].kind === 'tool_calls') { + expect(result[2].calls).toHaveLength(1) + expect(result[2].calls[0].name).toBe('b') + } + // finalText replaces nothing (last segment was tools), so appended + expect(result[3]).toMatchObject({ kind: 'text', role: 'assistant', text: 'All done.' }) + }) + + it('replaces last text segment with finalText when last segment is text', () => { + idCounter = 600 + const segments = [ + { kind: 'tools' as const, tools: [{ id: 't1', name: 'read', input: '', status: 'done' as const }] }, + { kind: 'text' as const, text: 'streaming partial...' }, + ] + const result = finalizeMessages(segments, 'Complete final text.', undefined, idGen) + expect(result).toHaveLength(2) + expect(result[0].kind).toBe('tool_calls') + // Last text segment replaced with authoritative finalText + expect(result[1]).toMatchObject({ kind: 'text', role: 'assistant', text: 'Complete final text.' }) + }) +}) diff --git a/ui/src/hooks/useChat.ts b/ui/src/hooks/useChat.ts new file mode 100644 index 00000000..6cc1f592 --- /dev/null +++ b/ui/src/hooks/useChat.ts @@ -0,0 +1,198 @@ +import { useState, useEffect, useRef, useCallback } from 'react' +import { chatApi } from '../api/chat' +import type { ChatStreamEvent } from '../api/chat' +import type { ToolCall, StreamingToolCall } from '../api/types' +import { useSSE } from './useSSE' + +// ==================== Types ==================== + +export type DisplayItem = + | { kind: 'text'; role: 'user' | 'assistant' | 'notification'; text: string; timestamp?: string | null; media?: Array<{ type: string; url: string }>; _id: number } + | { kind: 'tool_calls'; calls: ToolCall[]; timestamp?: string; _id: number } + +export type StreamSegment = + | { kind: 'text'; text: string } + | { kind: 'tools'; tools: StreamingToolCall[] } + +// ==================== Pure reducers ==================== + +type StreamEventPayload = Extract['event'] + +export function reduceStreamEvent(segments: StreamSegment[], ev: StreamEventPayload): StreamSegment[] { + const next = segments.map((s): StreamSegment => + s.kind === 'text' ? { ...s } : { ...s, tools: [...s.tools] }, + ) + + if (ev.type === 'tool_use') { + const last = next[next.length - 1] + if (last?.kind === 'tools') { + last.tools.push({ id: ev.id, name: ev.name, input: ev.input, status: 'running' }) + } else { + next.push({ kind: 'tools', tools: [{ id: ev.id, name: ev.name, input: ev.input, status: 'running' }] }) + } + } else if (ev.type === 'tool_result') { + for (const seg of next) { + if (seg.kind === 'tools') { + const t = seg.tools.find((tool) => tool.id === ev.tool_use_id) + if (t) { t.status = 'done'; t.result = ev.content; break } + } + } + } else if (ev.type === 'text') { + const last = next[next.length - 1] + if (last?.kind === 'text') { + last.text += ev.text + } else { + next.push({ kind: 'text', text: ev.text }) + } + } + + return next +} + +export function finalizeMessages( + segments: StreamSegment[], + finalText: string, + finalMedia: Array<{ type: string; url: string }> | undefined, + idGen: () => number, +): DisplayItem[] { + if (!finalText) return [] + const items: DisplayItem[] = [] + + // Preserve interleaved order: emit each segment as a DisplayItem + for (const seg of segments) { + if (seg.kind === 'tools') { + items.push({ + kind: 'tool_calls', + calls: seg.tools.map((t) => ({ + name: t.name, + input: typeof t.input === 'string' ? t.input : JSON.stringify(t.input ?? ''), + result: t.result, + })), + _id: idGen(), + }) + } else { + items.push({ kind: 'text', role: 'assistant', text: seg.text, _id: idGen() }) + } + } + + // Final text from the done event (the complete response) + // If the last segment was already a text block, replace it with finalText + media + // (the done event's text is the authoritative final version) + const lastItem = items[items.length - 1] + if (lastItem?.kind === 'text' && lastItem.role === 'assistant') { + lastItem.text = finalText + lastItem.media = finalMedia + } else { + items.push({ kind: 'text', role: 'assistant', text: finalText, media: finalMedia, _id: idGen() }) + } + + return items +} + +// ==================== Hook ==================== + +interface UseChatOptions { + channel: string + onSSEStatus?: (connected: boolean) => void +} + +export interface UseChatReturn { + messages: DisplayItem[] + streamSegments: StreamSegment[] + isWaiting: boolean + send: (text: string) => Promise + abort: () => void +} + +export function useChat({ channel, onSSEStatus }: UseChatOptions): UseChatReturn { + const [messages, setMessages] = useState([]) + const [streamSegments, setStreamSegments] = useState([]) + const [isWaiting, setIsWaiting] = useState(false) + const abortRef = useRef(null) + const nextId = useRef(0) + const channelRef = useRef(channel) + channelRef.current = channel + + // Load chat history when channel changes + useEffect(() => { + const ch = channel === 'default' ? undefined : channel + chatApi.history(100, ch).then(({ messages: msgs }) => { + setMessages(msgs.map((m): DisplayItem => { + if (m.kind === 'text' && m.metadata?.kind === 'notification') { + return { ...m, role: 'notification', _id: nextId.current++ } + } + return { ...m, _id: nextId.current++ } + })) + }).catch((err) => { + console.warn('Failed to load history:', err) + }) + }, [channel]) + + // SSE for push notifications (heartbeat, cron, multi-tab sync) + const sseChannel = channel === 'default' ? undefined : channel + useSSE({ + url: sseChannel ? `/api/chat/events?channel=${encodeURIComponent(sseChannel)}` : '/api/chat/events', + onMessage: (data) => { + if (data.type === 'message' && data.text) { + const role = data.kind === 'message' ? 'assistant' : 'notification' + setMessages((prev) => [ + ...prev, + { kind: 'text', role, text: data.text, media: data.media, _id: nextId.current++ }, + ]) + } + }, + onStatus: channel === 'default' ? onSSEStatus : undefined, + }) + + // Abort streaming on unmount + useEffect(() => { + return () => { abortRef.current?.abort() } + }, []) + + const send = useCallback(async (text: string) => { + setStreamSegments([]) + setMessages((prev) => [...prev, { kind: 'text', role: 'user', text, _id: nextId.current++ }]) + setIsWaiting(true) + + const abort = new AbortController() + abortRef.current = abort + + try { + const ch = channelRef.current === 'default' ? undefined : channelRef.current + let finalText = '' + let finalMedia: Array<{ type: string; url: string }> | undefined + let segments: StreamSegment[] = [] + + for await (const event of chatApi.sendStreaming(text, ch, abort.signal)) { + if (event.type === 'stream') { + segments = reduceStreamEvent(segments, event.event) + setStreamSegments(segments) + } else if (event.type === 'done') { + finalText = event.text + finalMedia = event.media?.length ? event.media : undefined + } + } + + setStreamSegments([]) + const newItems = finalizeMessages(segments, finalText, finalMedia, () => nextId.current++) + if (newItems.length > 0) { + setMessages((prev) => [...prev, ...newItems]) + } + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return + setStreamSegments([]) + const msg = err instanceof Error ? err.message : 'Unknown error' + setMessages((prev) => [ + ...prev, + { kind: 'text', role: 'notification', text: `Error: ${msg}`, _id: nextId.current++ }, + ]) + } finally { + setIsWaiting(false) + abortRef.current = null + } + }, []) + + const abortFn = useCallback(() => { abortRef.current?.abort() }, []) + + return { messages, streamSegments, isWaiting, send, abort: abortFn } +} diff --git a/ui/src/pages/ChatPage.tsx b/ui/src/pages/ChatPage.tsx index c82ab5b3..faf6937d 100644 --- a/ui/src/pages/ChatPage.tsx +++ b/ui/src/pages/ChatPage.tsx @@ -1,16 +1,11 @@ import { useState, useEffect, useRef, useCallback } from 'react' -import { api, type ToolCall, type StreamingToolCall } from '../api' +import { api } from '../api' import type { ChannelListItem } from '../api/channels' -import { useSSE } from '../hooks/useSSE' +import { useChat } from '../hooks/useChat' import { ChatMessage, ToolCallGroup, ThinkingIndicator, StreamingToolGroup } from '../components/ChatMessage' import { ChatInput } from '../components/ChatInput' import { ChannelConfigModal } from '../components/ChannelConfigModal' -/** Unified display item for the message list. */ -type DisplayItem = - | { kind: 'text'; role: 'user' | 'assistant' | 'notification'; text: string; timestamp?: string | null; media?: Array<{ type: string; url: string }>; _id: number } - | { kind: 'tool_calls'; calls: ToolCall[]; timestamp?: string; _id: number } - interface ChatPageProps { onSSEStatus?: (connected: boolean) => void } @@ -18,15 +13,13 @@ interface ChatPageProps { export function ChatPage({ onSSEStatus }: ChatPageProps) { const [channels, setChannels] = useState([{ id: 'default', label: 'Alice' }]) const [activeChannel, setActiveChannel] = useState('default') - const [messages, setMessages] = useState([]) - const [isWaiting, setIsWaiting] = useState(false) const [showScrollBtn, setShowScrollBtn] = useState(false) const [newMsgCount, setNewMsgCount] = useState(0) - const [streamText, setStreamText] = useState('') - const [streamTools, setStreamTools] = useState([]) - const streamTextRef = useRef('') - const streamToolsRef = useRef([]) - streamToolsRef.current = streamTools + + const { messages, streamSegments, isWaiting, send, abort } = useChat({ + channel: activeChannel, + onSSEStatus: activeChannel === 'default' ? onSSEStatus : undefined, + }) // Popover state const [popoverOpen, setPopoverOpen] = useState(false) @@ -37,12 +30,9 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { const [editingChannel, setEditingChannel] = useState(null) const popoverRef = useRef(null) - const nextId = useRef(0) const messagesEndRef = useRef(null) const userScrolledUp = useRef(false) const containerRef = useRef(null) - const activeChannelRef = useRef(activeChannel) - activeChannelRef.current = activeChannel const isOnSubChannel = activeChannel !== 'default' const subChannels = channels.filter((ch) => ch.id !== 'default') @@ -69,7 +59,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { } }, []) - useEffect(scrollToBottom, [messages, isWaiting, streamText, streamTools, scrollToBottom]) + useEffect(scrollToBottom, [messages, isWaiting, streamSegments, scrollToBottom]) // Detect user scroll useEffect(() => { @@ -91,116 +81,10 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { api.channels.list().then(({ channels: ch }) => setChannels(ch)).catch(() => {}) }, []) - // Load chat history when active channel changes + // Cleanup abort on unmount useEffect(() => { - const channel = activeChannel === 'default' ? undefined : activeChannel - api.chat.history(100, channel).then(({ messages: msgs }) => { - setMessages(msgs.map((m): DisplayItem => { - if (m.kind === 'text' && m.metadata?.kind === 'notification') { - return { ...m, role: 'notification', _id: nextId.current++ } - } - return { ...m, _id: nextId.current++ } - })) - }).catch((err) => { - console.warn('Failed to load history:', err) - }) - }, [activeChannel]) - - // SSE for the active channel - const sseChannel = activeChannel === 'default' ? undefined : activeChannel - useSSE({ - url: sseChannel ? `/api/chat/events?channel=${encodeURIComponent(sseChannel)}` : '/api/chat/events', - onMessage: (data) => { - // Streaming events (tool_use / tool_result / text) during AI generation - if (data.type === 'stream' && data.event) { - const ev = data.event - if (ev.type === 'tool_use') { - setStreamTools((prev) => [...prev, { - id: ev.id, name: ev.name, input: ev.input, status: 'running', - }]) - } else if (ev.type === 'tool_result') { - setStreamTools((prev) => prev.map((t) => - t.id === ev.tool_use_id ? { ...t, status: 'done' as const, result: ev.content } : t, - )) - } else if (ev.type === 'text') { - streamTextRef.current += ev.text - setStreamText(streamTextRef.current) - } - return - } - - // Push notifications (heartbeat, cron, etc.) - if (data.type === 'message' && data.text) { - const role = data.kind === 'message' ? 'assistant' : 'notification' - setMessages((prev) => [ - ...prev, - { kind: 'text', role, text: data.text, media: data.media, _id: nextId.current++ }, - ]) - if (userScrolledUp.current) { - setNewMsgCount((c) => c + 1) - } - } - }, - onStatus: activeChannel === 'default' ? onSSEStatus : undefined, - }) - - // Send message - const handleSend = useCallback(async (text: string) => { - // Clear streaming state from previous round - setStreamText('') - setStreamTools([]) - streamTextRef.current = '' - - setMessages((prev) => [...prev, { kind: 'text', role: 'user', text, _id: nextId.current++ }]) - setIsWaiting(true) - - try { - const channel = activeChannelRef.current === 'default' ? undefined : activeChannelRef.current - const data = await api.chat.send(text, channel) - - // POST returned — persist streaming tool calls, then add final text - const tools = streamToolsRef.current - setStreamText('') - setStreamTools([]) - streamTextRef.current = '' - - if (data.text) { - const media = data.media?.length ? data.media : undefined - setMessages((prev) => { - const next = [...prev] - // Persist tool calls collected during streaming - if (tools.length > 0) { - next.push({ - kind: 'tool_calls', - calls: tools.map((t) => ({ - name: t.name, - input: typeof t.input === 'string' ? t.input : JSON.stringify(t.input ?? ''), - result: t.result, - })), - _id: nextId.current++, - }) - } - next.push({ kind: 'text', role: 'assistant', text: data.text, media, _id: nextId.current++ }) - return next - }) - if (userScrolledUp.current) { - setNewMsgCount((c) => c + 1) - } - } - } catch (err) { - setStreamText('') - setStreamTools([]) - streamTextRef.current = '' - - const msg = err instanceof Error ? err.message : 'Unknown error' - setMessages((prev) => [ - ...prev, - { kind: 'text', role: 'notification', text: `Error: ${msg}`, _id: nextId.current++ }, - ]) - } finally { - setIsWaiting(false) - } - }, []) + return () => { abort() } + }, [abort]) const handleScrollToBottom = useCallback(() => { userScrolledUp.current = false @@ -440,31 +324,46 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { })} {isWaiting && (
0 ? 'mt-5' : ''}`}> - {streamTools.length > 0 || streamText ? ( + {streamSegments.length > 0 ? ( <> -
-
- - - -
- Alice -
- {streamTools.length > 0 && } - {streamText ? ( -
- -
- ) : streamTools.length > 0 && streamTools.every((t) => t.status === 'done') ? ( - /* All tools finished but text hasn't arrived yet — show thinking dots */ -
-
- . - . - . + {streamSegments.map((seg, i) => { + if (seg.kind === 'tools') { + const allDone = seg.tools.every((t) => t.status === 'done') + return ( +
0 ? 'mt-1' : ''}> + {allDone ? ( + ({ + name: t.name, + input: typeof t.input === 'string' ? t.input : JSON.stringify(t.input ?? ''), + result: t.result, + }))} /> + ) : ( + + )} +
+ ) + } + return ( +
0 ? 'mt-1' : ''}> + 0} />
-
- ) : null} + ) + })} + {(() => { + const last = streamSegments[streamSegments.length - 1] + if (last?.kind === 'tools' && last.tools.every((t) => t.status === 'done')) { + return ( +
+
+ . + . + . +
+
+ ) + } + return null + })()} ) : ( @@ -497,7 +396,7 @@ export function ChatPage({ onSSEStatus }: ChatPageProps) { )} {/* Input */} - + {/* Channel config modal */} {editingChannel && ( diff --git a/ui/src/pages/PortfolioPage.tsx b/ui/src/pages/PortfolioPage.tsx index 3ac9c7fd..ee56d916 100644 --- a/ui/src/pages/PortfolioPage.tsx +++ b/ui/src/pages/PortfolioPage.tsx @@ -214,9 +214,38 @@ interface PositionWithAccount extends Position { accountProvider: string } -function PositionsTable({ positions }: { positions: PositionWithAccount[] }) { - const hasLeverage = positions.some(p => p.leverage > 1) +/** True when the position carries derivative-specific context worth showing (side/leverage). */ +function isDerivative(p: Position): boolean { + const t = p.contract.secType + if (t === 'FUT' || t === 'OPT' || t === 'FOP') return true + if (t === 'CRYPTO' && p.leverage > 1) return true + return p.side === 'short' +} + +/** Build display fragments for a contract based on its secType. */ +function contractDisplay(p: Position): { name: string; tag?: string } { + const c = p.contract + const sym = c.symbol ?? '???' + const t = c.secType + + if (t === 'OPT' || t === 'FOP') { + // Options: show localSymbol if available, else construct from parts + const optDesc = c.localSymbol + ?? [sym, c.lastTradeDateOrContractMonth, c.right, c.strike && fmt(c.strike)].filter(Boolean).join(' ') + return { name: optDesc, tag: 'opt' } + } + if (t === 'FUT') { + const expiry = c.lastTradeDateOrContractMonth + return { name: expiry ? `${sym} ${expiry}` : sym, tag: 'fut' } + } + if (t === 'CRYPTO') { + return { name: sym, tag: p.leverage > 1 ? 'swap' : 'spot' } + } + // STK, CASH, BOND, CMDTY, etc. — just the symbol, no tag + return { name: sym } +} +function PositionsTable({ positions }: { positions: PositionWithAccount[] }) { return (

@@ -227,41 +256,61 @@ function PositionsTable({ positions }: { positions: PositionWithAccount[] }) { Symbol - Side Qty - Entry + Avg Cost Current - {hasLeverage && Lev} - Cost Basis Mkt Value PnL PnL % - {positions.map((p, i) => ( - - - {p.contract.symbol} - {p.accountLabel} - - - {p.side} - - {fmtNum(p.qty)} - {fmt(p.avgEntryPrice)} - {fmt(p.currentPrice)} - {hasLeverage && {p.leverage}x} - {fmt(p.costBasis)} - {fmt(p.marketValue)} - = 0 ? 'text-green' : 'text-red'}`}> - {fmtPnl(p.unrealizedPnL)} - - = 0 ? 'text-green' : 'text-red'}`}> - {p.unrealizedPnLPercent >= 0 ? '+' : ''}{p.unrealizedPnLPercent.toFixed(2)}% - - - ))} + {positions.map((p, i) => { + const display = contractDisplay(p) + const deriv = isDerivative(p) + const hasMarginInfo = p.margin || p.liquidationPrice + + return ( + + + {/* Primary: symbol + inline badges */} +
+ {display.name} + {display.tag && ( + {display.tag} + )} + {deriv && ( + + {p.side} + + )} + {p.leverage > 1 && ( + {p.leverage}x + )} + {p.accountLabel} +
+ {/* Secondary: margin / liquidation for derivatives */} + {hasMarginInfo && ( +
+ {p.margin ? `Margin ${fmt(p.margin)}` : ''} + {p.margin && p.liquidationPrice ? ' \u00b7 ' : ''} + {p.liquidationPrice ? `Liq ${fmt(p.liquidationPrice)}` : ''} +
+ )} + + {fmtNum(p.qty)} + {fmt(p.avgEntryPrice)} + {fmt(p.currentPrice)} + {fmt(p.marketValue)} + = 0 ? 'text-green' : 'text-red'}`}> + {fmtPnl(p.unrealizedPnL)} + + = 0 ? 'text-green' : 'text-red'}`}> + {p.unrealizedPnLPercent >= 0 ? '+' : ''}{p.unrealizedPnLPercent.toFixed(2)}% + + + ) + })}