diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a31d3c6..29c9d392 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Fix useUIMessages and useThreadMessages freezing when a page ends partway + through a message (#395) + ## 0.7.8 - Fix answering a tool approval after an earlier approval in the same thread diff --git a/package-lock.json b/package-lock.json index fcc7a8be..93c0042c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "@radix-ui/react-toast": "1.2.15", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "4.3.3", + "@testing-library/react": "16.3.3", "@types/node": "24.13.3", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", @@ -56,6 +57,7 @@ "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.2", "globals": "17.11.0", + "jsdom": "30.1.1", "lucide-react": "1.47.0", "openai": "7.5.0", "pkg-pr-new": "0.0.66", @@ -302,6 +304,59 @@ "zod": "^3.25.76 || ^4.1.8" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-7.1.2.tgz", + "integrity": "sha512-99DHAnXDB5z6EEK+9GMpVI7Mw4oxj97dY5bpOzMnjADQWxI8rN6TvTduuFLUhUMlS7/CfVZ06tcZsus6cltnNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.4.1", + "@csstools/css-color-parser": "^4.2.4", + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-9.2.2.tgz", + "integrity": "sha512-lSWTBMjAcmu2xn5yEDU7jh6QDV+C8GEKtdJ4pIQhXh26RkKQ7S3FuQlt+zZkUbTdJ4d3XyV1kPI/G0zWXxqaqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.1.0", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.3" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", @@ -514,6 +569,16 @@ "node": ">=6.0.0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", @@ -562,6 +627,19 @@ "node": ">=6.9.0" } }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, "node_modules/@cfworker/json-schema": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@cfworker/json-schema/-/json-schema-4.1.1.tgz", @@ -851,6 +929,146 @@ "convex-helpers": "^0.1.94" } }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.2.tgz", + "integrity": "sha512-grhRy3OKmniaAEKXMjua5z/EODX0MSqBGjunw8+j/3HQjOnahs2AGhvEOIYVUWcU6ScApbhLhVrQTX8XqrMrow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.4.1.tgz", + "integrity": "sha512-EtC7SoN1j6J4E4DCwg5QgbO5TGxgxIA1RXqe+W+qUM+BUcezx9wT+/tiQ/WO2yCX4i5X+Cuf9ciJ22aP4UEwWw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.4.tgz", + "integrity": "sha512-DyefytAZ735mX4Dq/WcDAXFtXhaEFvme0ZS9tVEBAc2whxUthXr0R0L2rmEPm59SrMBkGrFzQviBXMs/UtnABQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.2", + "@csstools/css-calc": "^3.4.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.1", + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.1.tgz", + "integrity": "sha512-ShL8BqPfbKJrJiKFH0xBbN0i7Nrh9HXYRuF+pzyj93R/BL2YAsUxJeqANErzqM+0JGl7vjHp+3OIgd/DL69YIA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.2" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.14.tgz", + "integrity": "sha512-HpbVXyrofRXpHpgkNIjU/3EWR4WJvOkO3emNK/L6X/mTJU7bGUI3AkkpoTNXznQLp0KRjLHELTGeKI5dIkI9JQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.2.tgz", + "integrity": "sha512-OoKoR0f76dCY666JlcbhmVTs2drYj1GUXZTYTcbUgJjh9Nv41aFfZ21bPQTERm5+L5cBDo466NltB2lplS5GBw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@edge-runtime/primitives": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@edge-runtime/primitives/-/primitives-6.0.0.tgz", @@ -1048,6 +1266,24 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@exodus/bytes": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.16.0.tgz", + "integrity": "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.3", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", @@ -3358,6 +3594,74 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.2.tgz", + "integrity": "sha512-yzr2S9HyAIdhz2/6qHgbs665Q7PKVcDF05vsOlHPxG1mo36gKVesdYVeDLnXgfjJ03CrKRk08knc6+E/9m8v2Q==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -3916,6 +4220,31 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -4227,6 +4556,16 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5295,6 +5634,20 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -5322,6 +5675,35 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5411,6 +5793,13 @@ "node": ">=0.10.0" } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decode-named-character-reference": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", @@ -5539,6 +5928,14 @@ "node": ">=0.10.0" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/dotenv": { "version": "18.0.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-18.0.1.tgz", @@ -5598,6 +5995,19 @@ "node": ">=10.13.0" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-abstract": { "version": "1.24.0", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz", @@ -6614,6 +7024,19 @@ "hermes-estree": "0.25.1" } }, + "node_modules/html-encoding-sniffer": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-7.0.0.tgz", + "integrity": "sha512-UikN5yr7xsCDAq87Or5or0PAlD3HJJOKVzM05az588WnpDJ4Ux7a2A53Qi6gofGg2/EtvF/H4hCi/TXfCW4Y6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -7005,6 +7428,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-regex": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", @@ -7245,6 +7675,66 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "30.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.1.1.tgz", + "integrity": "sha512-FahmoPK5vbPc+jxV1iErMHmAZypCZ942NHF4+qqaWAuvaKKTBZxawnmAtrbGWLU7MtlxfqIP0qw6aSI+aWGtLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^7.0.0", + "@asamuzakjp/dom-selector": "^9.2.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.13", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^7.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "tough-cookie": "^6.0.2", + "undici": "^8.10.2", + "w3c-xmlserializer": "^6.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", + "integrity": "sha512-U4N8FgzmWxc8k1VH8Kr6lQg18U7Fjvby6wXHVRX/ZZ7IwWbRMgrRbP0Wrb5q5NVinryp4SQampHKdvtecItxUg==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsdom/node_modules/undici": { + "version": "8.11.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.11.2.tgz", + "integrity": "sha512-u4UB2/IrKdU6lFxumHmmo1a3fCQO5tzQllRorfoRS63txhrB7xTpSn1PftwC4qEHkOaqP95fCWW4lJzwErwzhQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -7736,6 +8226,17 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -7917,6 +8418,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -8797,6 +9305,19 @@ "dev": true, "license": "MIT" }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8933,6 +9454,30 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/prop-types": { "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", @@ -9311,6 +9856,16 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", @@ -9435,6 +9990,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -9983,6 +10551,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.16.tgz", + "integrity": "sha512-QwBER5KMR86IIjpIiO7H/Z3IMJPsZ1A6RKPAqzTTgOyUQUSt9FdnKcqhTaJmkY6HVrgouZHZR0ncK5QxvmnQeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.16" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.16", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.16.tgz", + "integrity": "sha512-MDolfaSJtlSK5Y0A1xl3277ekubZwobpBjugknDizI9O5Rm60a1m8k4ICK+MRsCDzPygT81mp3BBf5RKDlFRfA==", + "dev": true, + "license": "MIT" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9996,6 +10584,32 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -10699,6 +11313,54 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/w3c-xmlserializer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-6.0.0.tgz", + "integrity": "sha512-4Nsy8K5Tr6SPDH9jhKJOHf7ChDrc1zufZTVSF7x72hwuEXBqxqk9G6cK+K2NRUtB3iELRJqjXb4JPDMBjMTl2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.2.tgz", + "integrity": "sha512-TEZA+Zqxin7Jjsm2cjRohCmen5awh+hT6Zi3VZdqZlNRk7zvOI/9WpBFg/DWlA56bWnzwm6DuB8NS0EsxQH9uQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10867,6 +11529,23 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/package.json b/package.json index 4d769fce..0c023fd3 100644 --- a/package.json +++ b/package.json @@ -117,6 +117,7 @@ "@radix-ui/react-toast": "1.2.15", "@tailwindcss/typography": "0.5.19", "@tailwindcss/vite": "4.3.3", + "@testing-library/react": "16.3.3", "@types/node": "24.13.3", "@types/react": "19.2.14", "@types/react-dom": "19.2.3", @@ -136,6 +137,7 @@ "eslint-plugin-react-hooks": "7.1.1", "eslint-plugin-react-refresh": "0.5.2", "globals": "17.11.0", + "jsdom": "30.1.1", "lucide-react": "1.47.0", "openai": "7.5.0", "pkg-pr-new": "0.0.66", diff --git a/src/vercel/UIMessages.ts b/src/vercel/UIMessages.ts index 48c8efc1..527885ad 100644 --- a/src/vercel/UIMessages.ts +++ b/src/vercel/UIMessages.ts @@ -922,8 +922,10 @@ export function combineUIMessages(messages: UIMessage[]): UIMessage[] { newParts.push(part); continue; } - const previousPart = newParts.splice(previousPartIndex, 1)[0]; - newParts.push(mergeParts(previousPart, part)); + newParts[previousPartIndex] = mergeParts( + newParts[previousPartIndex], + part, + ); } acc.push({ ...previous, diff --git a/src/vercel/client/streaming.integration.test.ts b/src/vercel/client/streaming.integration.test.ts index 7658d06b..309747b8 100644 --- a/src/vercel/client/streaming.integration.test.ts +++ b/src/vercel/client/streaming.integration.test.ts @@ -12,7 +12,6 @@ import { import { getParts, deriveUIMessagesFromDeltas } from "../deltas.js"; import type { TestConvex } from "convex-test"; import type { StreamDelta, StreamMessage } from "../../validators.js"; -import { dedupeMessages } from "../react/useUIMessages.js"; const defaultTestOptions = { throttleMs: 0, @@ -434,34 +433,6 @@ describe("Stream Exclusion Logic", () => { expect(t1Streams[0].streamId).not.toBe(t2Streams[0].streamId); }); }); - - test("dedupeMessages prefers finalized over streaming over pending", () => { - type M = { - order: number; - stepOrder: number; - status: "pending" | "success" | "failed" | "streaming"; - }; - - const messages: M[] = [ - { order: 1, stepOrder: 0, status: "pending" }, - { order: 2, stepOrder: 0, status: "success" }, - { order: 3, stepOrder: 0, status: "pending" }, - ]; - const streamMessages: M[] = [ - { order: 1, stepOrder: 0, status: "streaming" }, - { order: 2, stepOrder: 0, status: "streaming" }, - { order: 3, stepOrder: 0, status: "success" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - expect(result).toHaveLength(3); - // pending replaced by streaming - expect(result[0].status).toBe("streaming"); - // success kept over streaming - expect(result[1].status).toBe("success"); - // pending replaced by success - expect(result[2].status).toBe("success"); - }); }); // ============================================================================ @@ -862,35 +833,6 @@ describe("Fallback Behavior", () => { expect(msgs[2].status).toBe("failed"); }); - test("dedupeMessages handles fallback from streaming to finalized gracefully", () => { - type M = { - order: number; - stepOrder: number; - status: "pending" | "success" | "failed" | "streaming"; - text: string; - }; - - // Simulate: full messages from DB include finalized versions, streaming - // messages are still around from the delta stream - const dbMessages: M[] = [ - { order: 1, stepOrder: 0, status: "success", text: "Final answer" }, - { order: 2, stepOrder: 0, status: "pending", text: "Thinking..." }, - ]; - const streamMessages: M[] = [ - { order: 1, stepOrder: 0, status: "streaming", text: "Final ans..." }, - { order: 2, stepOrder: 0, status: "streaming", text: "Thinking..." }, - ]; - - const result = dedupeMessages(dbMessages, streamMessages); - - // Order 1: finalized DB version preferred over streaming - expect(result[0].status).toBe("success"); - expect(result[0].text).toBe("Final answer"); - - // Order 2: streaming preferred over pending DB version - expect(result[1].status).toBe("streaming"); - }); - test("mergeTransforms adds smoothStream when streaming is enabled", () => { // No streaming options - returns existing transforms expect(mergeTransforms(undefined, undefined)).toBeUndefined(); diff --git a/src/vercel/react/assemble.test.ts b/src/vercel/react/assemble.test.ts new file mode 100644 index 00000000..1e108664 --- /dev/null +++ b/src/vercel/react/assemble.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "vitest"; +import { resolveStreams } from "./assemble.js"; + +const row = (position: string, status = "success") => { + const [order, stepOrder] = position.split("/").map(Number); + return { order, stepOrder, status, position }; +}; + +const live = (id: string, position: string) => { + const [order, stepOrder] = position.split("/").map(Number); + return { id, order, stepOrder, live: true, value: id }; +}; + +const ended = (id: string, position: string) => { + const [order, stepOrder] = position.split("/").map(Number); + return { id, order, stepOrder, live: false, value: id }; +}; + +describe("resolveStreams", () => { + test.each([ + { + name: "rows pass through when there are no streams", + rows: [row("1/0"), row("1/1")], + streams: [], + kept: ["1/0", "1/1"], + shown: [], + }, + { + name: "a live stream replaces every row from its start, whatever their status", + rows: [ + row("1/0"), + row("1/1"), + row("1/2", "failed"), + row("1/3", "pending"), + ], + streams: [live("a", "1/1")], + kept: ["1/0"], + shown: ["a"], + }, + { + name: "of two live streams the latest started wins", + rows: [row("1/0"), row("1/1", "pending"), row("1/2", "pending")], + streams: [live("first", "1/1"), live("retry", "1/2")], + kept: ["1/0", "1/1"], + shown: ["retry"], + }, + { + name: "an ended stream with a saved row at or after its start is hidden", + rows: [row("1/0"), row("1/2")], + streams: [ended("f", "1/1")], + kept: ["1/0", "1/2"], + shown: [], + }, + { + name: "an ended stream with no saved row from its start is shown", + rows: [row("1/0")], + streams: [ended("f", "1/1")], + kept: ["1/0"], + shown: ["f"], + }, + { + name: "an ended stream starting inside the live stream's range is hidden", + rows: [row("1/0")], + streams: [live("a", "1/1"), ended("f", "1/2")], + kept: ["1/0"], + shown: ["a"], + }, + { + name: "a live stream wins over an ended stream at the same step", + rows: [row("1/0")], + streams: [ended("b", "1/1"), live("a", "1/1")], + kept: ["1/0"], + shown: ["a"], + }, + { + name: "two ended streams at the same step resolve to the greatest id", + rows: [row("1/0")], + streams: [ended("b", "1/1"), ended("a", "1/1")], + kept: ["1/0"], + shown: ["b"], + }, + { + name: "a pending row does not hide an ended stream, which replaces it", + rows: [row("1/0"), row("1/1", "pending")], + streams: [ended("f", "1/1")], + kept: ["1/0"], + shown: ["f"], + }, + { + name: "orders are resolved independently", + rows: [row("1/0"), row("1/1"), row("2/0"), row("2/1")], + streams: [ended("f", "1/1"), live("a", "2/1")], + kept: ["1/0", "1/1", "2/0"], + shown: ["a"], + }, + ])("$name", ({ rows, streams, kept, shown }) => { + const resolved = resolveStreams(rows, streams); + expect(resolved.rows.map((r) => r.position)).toEqual(kept); + expect(resolved.streams).toEqual(shown); + }); +}); diff --git a/src/vercel/react/assemble.ts b/src/vercel/react/assemble.ts new file mode 100644 index 00000000..b5273be1 --- /dev/null +++ b/src/vercel/react/assemble.ts @@ -0,0 +1,181 @@ +"use client"; +import { usePaginatedQuery } from "convex-helpers/react"; +import type { + PaginatedQueryArgs, + PaginatedQueryReference, + UsePaginatedQueryReturnType, +} from "convex/react"; +import { getFunctionName } from "convex/server"; +import { convexToJson, type Value } from "convex/values"; +import { useEffect, useMemo, useState } from "react"; + +type Positioned = { order: number; stepOrder: number }; + +// Rows fetched per automatic page while finishing the oldest visible order. +const AUTO_PAGE_SIZE = 100; + +/** + * usePaginatedQuery over a thread's messages, where a page boundary can fall + * anywhere inside an order. The oldest order the caller asked to see is + * loaded to its first row with further pages before it settles, and rows of + * older orders read along the way stay hidden until the next `loadMore`, so + * the results always start at an order boundary. + */ +export function usePaginatedOrders( + query: Query, + args: PaginatedQueryArgs | "skip", + options: { initialNumItems: number }, +): UsePaginatedQueryReturnType { + const paginated = usePaginatedQuery(query, args, options); + const items = paginated.results as Positioned[]; + const queryKey = `${getFunctionName(query)}:${ + args === "skip" ? "skip" : JSON.stringify(convexToJson(args as Value)) + }`; + + const [state, setState] = useState<{ + queryKey: string; + target?: number; + loadingFrom?: string; + }>({ queryKey }); + let { target, loadingFrom } = state.queryKey === queryKey ? state : {}; + + let oldest: Positioned | undefined; + for (const item of items) { + if ( + !oldest || + item.order < oldest.order || + (item.order === oldest.order && item.stepOrder < oldest.stepOrder) + ) { + oldest = item; + } + } + const oldestKey = oldest && `${oldest.order}/${oldest.stepOrder}`; + if (!oldest) { + target = undefined; + } else { + if ( + loadingFrom !== undefined && + (loadingFrom !== oldestKey || paginated.status === "Exhausted") + ) { + target = oldest.order; + loadingFrom = undefined; + } + if (target === undefined || oldest.order > target) { + target = oldest.order; + } + } + if ( + state.queryKey !== queryKey || + state.target !== target || + state.loadingFrom !== loadingFrom + ) { + setState({ queryKey, target, loadingFrom }); + } + + const completing = + !!oldest && + paginated.status === "CanLoadMore" && + oldest.order === target && + oldest.stepOrder > 0; + const autoPageSize = completing + ? Math.min(oldest!.stepOrder, AUTO_PAGE_SIZE) + : 0; + const underlyingLoadMore = paginated.loadMore; + // usePaginatedQuery ignores a second loadMore until the page it started + // arrives, so a repeated effect (StrictMode) requests one page. + useEffect(() => { + if (completing) underlyingLoadMore(autoPageSize); + }, [completing, autoPageSize, underlyingLoadMore]); + + const results = paginated.results; + const visible = useMemo(() => { + if (target === undefined) return results; + const kept = results.filter((item) => (item as Positioned).order >= target); + return kept.length === results.length ? results : kept; + }, [results, target]); + const hidden = visible.length < paginated.results.length; + let status = paginated.status; + if (completing) { + status = "LoadingMore"; + } else if (hidden && status === "Exhausted") { + status = "CanLoadMore"; + } + return { + results: visible, + status, + isLoading: status === "LoadingFirstPage" || status === "LoadingMore", + loadMore: (numItems: number) => { + if (hidden && oldest) { + setState({ queryKey, target: oldest.order }); + } else if (status === "CanLoadMore") { + setState({ queryKey, target, loadingFrom: oldestKey }); + underlyingLoadMore(numItems); + } + }, + } as UsePaginatedQueryReturnType; +} + +/** + * A stream's message for an order, starting at `stepOrder`. A stream carries + * every step of its generation from there on. + */ +export type StreamCandidate = { + id: string; + order: number; + stepOrder: number; + live: boolean; + value: S; +}; + +const byStart = (a: StreamCandidate, b: StreamCandidate) => + a.order - b.order || + a.stepOrder - b.stepOrder || + Number(a.live) - Number(b.live) || + (a.id < b.id ? -1 : a.id > b.id ? 1 : 0); + +/** + * Resolves which saved rows and which streams are shown, per order: + * - The live stream that started latest wins from its start: rows from there + * are replaced, whatever their status or the query's filters. + * - A finished or aborted stream is shown only if it starts before that and + * no finalized (not pending) row exists at or after its start, so saved + * messages win. It replaces the pending rows from its start. + * Of streams starting at the same step, a live one wins, then the greatest id. + */ +export function resolveStreams< + R extends { order: number; stepOrder: number; status: string }, + S, +>(rows: R[], streams: StreamCandidate[]): { rows: R[]; streams: S[] } { + const sorted = [...streams] + .sort(byStart) + .filter( + (s, i, all) => + all[i + 1]?.order !== s.order || all[i + 1]?.stepOrder !== s.stepOrder, + ); + const liveFrom = new Map(); + for (const s of sorted) if (s.live) liveFrom.set(s.order, s.stepOrder); + const shown = sorted.filter((s) => + s.live + ? liveFrom.get(s.order) === s.stepOrder + : s.stepOrder < (liveFrom.get(s.order) ?? Infinity) && + !rows.some( + (r) => + r.order === s.order && + r.stepOrder >= s.stepOrder && + r.status !== "pending", + ), + ); + const replacedFrom = new Map(); + for (const s of shown) { + replacedFrom.set( + s.order, + Math.min(s.stepOrder, replacedFrom.get(s.order) ?? Infinity), + ); + } + return { + rows: rows.filter( + (r) => r.stepOrder < (replacedFrom.get(r.order) ?? Infinity), + ), + streams: shown.map((s) => s.value), + }; +} diff --git a/src/vercel/react/useThreadMessages.ts b/src/vercel/react/useThreadMessages.ts index ba4c2f3a..da3b42aa 100644 --- a/src/vercel/react/useThreadMessages.ts +++ b/src/vercel/react/useThreadMessages.ts @@ -5,7 +5,6 @@ import { type ErrorMessage, type Expand, } from "convex-helpers"; -import { usePaginatedQuery } from "convex-helpers/react"; import { type PaginatedQueryArgs, type UsePaginatedQueryResult, @@ -19,7 +18,7 @@ import type { import { useMemo, useState, useEffect, useRef } from "react"; import type { SyncStreamsReturnValue } from "../client/types.js"; import { sorted } from "../../shared.js"; -import { fromUIMessages } from "../UIMessages.js"; +import { fromUIMessages, type UIMessage } from "../UIMessages.js"; import type { Message, MessageDoc, @@ -28,6 +27,7 @@ import type { } from "../../validators.js"; import type { StreamQueryArgs, StreamQuery } from "./types.js"; import { useStreamingUIMessages } from "./useStreamingUIMessages.js"; +import { resolveStreams, usePaginatedOrders } from "./assemble.js"; export type MessageDocLike = { order: number; @@ -129,7 +129,7 @@ export function useThreadMessages>( ThreadMessagesResult & { streaming: boolean; key: string } > { // These are full messages - const paginated = usePaginatedQuery( + const paginated = usePaginatedOrders( query, args as PaginatedQueryArgs | "skip", { initialNumItems: options.initialNumItems }, @@ -145,7 +145,7 @@ export function useThreadMessages>( } } // These are streaming messages that will not include full messages. - const streamMessages = useStreamingThreadMessages( + const streams = useStreamingDocs( query as StreamQuery>, !options.stream || args === "skip" || @@ -157,49 +157,27 @@ export function useThreadMessages>( const threadId = args === "skip" ? undefined : args.threadId; - const merged = useMemo(() => { - const streamListMessages = - streamMessages?.map((m) => ({ + const results = useMemo(() => { + const resolved = resolveStreams( + paginated.results as MessageDocLike[], + (streams ?? []).map(({ message, docs }) => ({ + id: message.id, + order: message.order, + stepOrder: message.stepOrder, + live: message.status === "streaming", + value: docs, + })), + ); + return sorted([ + ...resolved.rows.map((m) => ({ ...m, streaming: false })), + ...resolved.streams.flat().map((m) => ({ ...m, streaming: !m.status || m.status === "pending", - })) ?? []; - return { - ...paginated, - results: sorted( - paginated.results - .map((m) => ({ ...m, streaming: false })) - // Note: this is intentionally after paginated results. - .concat(streamListMessages) as (MessageDocLike & { - streaming: boolean; - key: string; - })[], - ).reduce( - (msgs, msg: MessageDocLike & { streaming: boolean; key: string }) => { - msg.key = `${threadId}-${msg.order}-${msg.stepOrder}`; - const last = msgs.at(-1); - if (!last) { - return [msg]; - } - if (last.order !== msg.order || last.stepOrder !== msg.stepOrder) { - return [...msgs, msg]; - } - if ( - last.status === "pending" && - (msg.streaming || msg.status !== "pending") - ) { - // Let's prefer a streaming or finalized message over a pending - // one. - return [...msgs.slice(0, -1), msg]; - } - // skip the new one if the previous one (listed) was finalized - return msgs; - }, - [] as (MessageDocLike & { streaming: boolean; key: string })[], - ), - }; - }, [paginated, streamMessages, threadId]); + })), + ]).map((m) => ({ ...m, key: `${threadId}-${m.order}-${m.stepOrder}` })); + }, [paginated.results, streams, threadId]); - return merged as ThreadMessagesResult & { + return { ...paginated, results } as ThreadMessagesResult & { key: string; streaming: boolean; }; @@ -238,29 +216,46 @@ export function useStreamingThreadMessages>( const startOrder = args === "skip" ? undefined : (args.startOrder ?? undefined); const queryOptions = { startOrder, ...options }; - const uiMessages = useStreamingUIMessages(query, queryArgs, queryOptions); - const [messages, setMessages] = useState | undefined>(); + return useStreamingDocs(query, queryArgs, queryOptions)?.flatMap( + ({ docs }) => docs, + ); +} + +function useStreamingDocs>( + query: Query, + args: StreamQueryArgs | "skip", + options?: { + startOrder?: number; + skipStreamIds?: string[]; + }, +): { message: UIMessage; docs: MessageDoc[] }[] | undefined { + const uiMessages = useStreamingUIMessages(query, args, options); + const [converted, setConverted] = useState<{ + threadId: string; + messages: { message: UIMessage; docs: MessageDoc[] }[]; + }>(); const generationRef = useRef(0); const threadId = args === "skip" ? undefined : args.threadId; - if ((threadId === undefined || !uiMessages) && messages !== undefined) { - setMessages(undefined); - } - useEffect(() => { if (threadId === undefined || !uiMessages) { return; } const currentGeneration = ++generationRef.current; (async () => { - const nested = await Promise.all( - uiMessages.map((m) => fromUIMessages([m], { threadId })), + const messages = await Promise.all( + uiMessages.map(async (message) => ({ + message, + docs: await fromUIMessages([message], { threadId }), + })), ); if (generationRef.current === currentGeneration) { - setMessages(nested.flat()); + setConverted({ threadId, messages }); } })(); }, [uiMessages, threadId]); - return messages; + return uiMessages && converted && converted.threadId === threadId + ? converted.messages + : undefined; } diff --git a/src/vercel/react/useUIMessages.pages.test.ts b/src/vercel/react/useUIMessages.pages.test.ts new file mode 100644 index 00000000..7a187dd8 --- /dev/null +++ b/src/vercel/react/useUIMessages.pages.test.ts @@ -0,0 +1,667 @@ +// @vitest-environment jsdom +import { act, renderHook } from "@testing-library/react"; +import { makeFunctionReference } from "convex/server"; +import { convexToJson, type Value } from "convex/values"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { components, initConvexTest } from "../client/setup.test.js"; +import { syncStreams } from "../client/streaming.js"; +import { listMessages } from "../../client/messages.js"; +import { createThread } from "../../client/threads.js"; +import { toUIMessages, type UIMessage } from "../UIMessages.js"; +import { useThreadMessages } from "./useThreadMessages.js"; +import { useUIMessages } from "./useUIMessages.js"; + +// A stand-in for the Convex client backed by the real component: every +// subscription the hooks open is answered by running the app query against +// convex-test, and `sync` is one round trip that re-runs them all. A result +// keeps its identity until its data changes, and every page usePaginatedQuery +// asks for is recorded under its own page key. +const client = { + t: undefined as unknown as ReturnType, + subscriptions: new Map(), + results: new Map(), + pages: new Map(), +}; + +function subscribe(query: unknown, args: any) { + const name = (query as Record)[Symbol.for("functionName")]; + const key = `${name}|${JSON.stringify(convexToJson(args as Value))}`; + client.subscriptions.set(key, { name, args }); + return client.results.get(key)?.value; +} + +vi.mock("convex/react", () => ({ + useConvex: () => ({ logger: console }), + useQuery: (query: unknown, args: any) => + args === "skip" ? undefined : subscribe(query, args), + useQueries: (queries: Record) => { + const results = Object.fromEntries( + Object.entries(queries).map(([pageKey, { query, args }]) => { + client.pages.set( + `${pageKey}|${JSON.stringify(convexToJson(args as Value))}`, + args, + ); + return [pageKey, subscribe(query, args)]; + }), + ); + const previous = lastQueriesResults.get(queries); + if ( + previous && + Object.keys(results).length === Object.keys(previous).length && + Object.entries(results).every(([key, value]) => previous[key] === value) + ) { + return previous; + } + lastQueriesResults.set(queries, results); + return results; + }, +})); + +// Like the Convex client, useQueries hands back the same object until one of +// its results changes. +const lastQueriesResults = new WeakMap>(); + +// The app queries the hooks are pointed at: MessageDoc pages for +// useThreadMessages, and UIMessage pages, converted as listUIMessages does, +// for useUIMessages. +const queries = { + rows: makeFunctionReference<"query">("chat:listMessages"), + ui: makeFunctionReference<"query">("chat:listUIMessages"), + uiWithoutTools: makeFunctionReference<"query">( + "chat:listUIMessagesWithoutTools", + ), + uiSuccessOnly: makeFunctionReference<"query">( + "chat:listUIMessagesSuccessOnly", + ), +}; +type Shape = keyof typeof queries; + +function runQuery({ name, args }: { name: string; args: any }) { + return client.t.run(async (ctx) => { + const listArgs = { + threadId: args.threadId, + paginationOpts: args.paginationOpts, + }; + const paginated = await listMessages(ctx, components.agent, { + ...listArgs, + excludeToolMessages: name.includes("WithoutTools"), + statuses: name.includes("SuccessOnly") ? ["success"] : undefined, + }); + const streams = await syncStreams(ctx, components.agent, args); + return name.includes("UIMessages") + ? { ...paginated, page: toUIMessages(paginated.page), streams } + : { ...paginated, streams }; + }); +} + +async function sync(rerender: () => void) { + // Results can open new subscriptions, so repeat until the set settles. + for (let size = -1; size !== client.subscriptions.size; ) { + size = client.subscriptions.size; + for (const [key, request] of [...client.subscriptions]) { + const value = await runQuery(request); + const json = JSON.stringify(convexToJson(value as Value)); + if (client.results.get(key)?.json !== json) { + client.results.set(key, { json, value }); + } + } + act(rerender); + } + // Let the streaming hooks finish converting what they received. + await act(() => new Promise((resolve) => setTimeout(resolve, 0))); +} + +// Pages fetched for new rows, as opposed to re-reads that pin or split a page +// already loaded. +function pagesLoaded() { + return [...client.pages.values()].filter( + (args) => + !args.paginationOpts.endCursor && args.paginationOpts.numItems > 0, + ).length; +} + +let threadId: string; + +beforeEach(async () => { + client.t = initConvexTest(); + client.subscriptions.clear(); + client.results.clear(); + client.pages.clear(); + threadId = await client.t.run((ctx) => + createThread(ctx, components.agent, { userId: "u" }), + ); +}); + +const addMessages = (args: { + messages: unknown[]; + promptMessageId?: string; + pendingMessageId?: string; + finishStreamId?: string; +}) => + client.t.run((ctx) => + ctx.runMutation(components.agent.messages.addMessages, { + threadId, + ...(args as { messages: [] }), + }), + ); + +async function createStream(order: number, stepOrder: number) { + const streamId = await client.t.run((ctx) => + ctx.runMutation(components.agent.streams.create, { + threadId, + order, + stepOrder, + format: "UIMessageChunk", + }), + ); + let cursor = 0; + return { + streamId, + write: (parts: unknown[]) => + client.t.run((ctx) => + ctx.runMutation(components.agent.streams.addDelta, { + streamId, + start: cursor, + end: ++cursor, + parts, + }), + ), + }; +} + +const toolCallFor = (toolCallId: string) => ({ + role: "assistant", + content: [ + { type: "text", text: "checking" }, + { + type: "tool-call", + toolCallId, + toolName: "weather", + input: { city: "NYC" }, + }, + ], +}); +const toolResultFor = (toolCallId: string) => ({ + role: "tool", + content: [ + { + type: "tool-result", + toolCallId, + toolName: "weather", + output: { type: "text", value: "72F" }, + }, + ], +}); +const toolCall = toolCallFor("c1"); +const toolResult = toolResultFor("c1"); + +async function seedEarlierExchange() { + await addMessages({ + messages: [ + { message: { role: "user", content: "hi" } }, + { message: { role: "assistant", content: "hello" } }, + ], + }); +} + +async function seedToolLoop() { + return addMessages({ + messages: [ + { message: { role: "user", content: "what is the weather" } }, + { message: toolCall }, + { message: toolResult }, + { message: { role: "assistant", content: "It is 72F" } }, + ], + }); +} + +const earlierExchange = [ + { role: "user", status: "success", parts: ["hi"] }, + { role: "assistant", status: "success", parts: ["hello"] }, +]; +const prompt = { + role: "user", + status: "success", + parts: ["what is the weather"], +}; +const streamingAnswer = { + role: "assistant", + status: "streaming", + parts: ["checking", 'tool-weather {"city":"NYC"} -> 72F', "It is"], +}; +const finishedAnswer = { + role: "assistant", + status: "success", + parts: ["checking", 'tool-weather {"city":"NYC"} -> 72F', "It is 72F"], +}; + +// The rows and deltas streamText writes for a tool loop of `toolSteps` +// steps, stopped partway through the final text step. +async function startToolLoopGeneration(toolSteps = 1) { + const started = await addMessages({ + messages: [ + { message: { role: "user", content: "what is the weather" } }, + { message: { role: "assistant", content: [] }, status: "pending" }, + ], + }); + const promptDoc = started.messages[0]; + let pending = started.messages[1]; + const stream = await createStream(pending.order, pending.stepOrder); + await stream.write([{ type: "start" }]); + for (let i = 1; i <= toolSteps; i++) { + await stream.write([ + { type: "start-step" }, + { type: "text-start", id: `t${i}` }, + { type: "text-delta", id: `t${i}`, delta: "checking" }, + { type: "text-end", id: `t${i}` }, + { + type: "tool-input-available", + toolCallId: `c${i}`, + toolName: "weather", + input: { city: "NYC" }, + }, + { type: "tool-output-available", toolCallId: `c${i}`, output: "72F" }, + { type: "finish-step" }, + ]); + const step = await addMessages({ + promptMessageId: promptDoc._id, + pendingMessageId: pending._id, + messages: [ + { message: toolCallFor(`c${i}`) }, + { message: toolResultFor(`c${i}`) }, + { message: { role: "assistant", content: [] }, status: "pending" }, + ], + }); + pending = step.messages.at(-1)!; + } + await stream.write([ + { type: "start-step" }, + { type: "text-start", id: "answer" }, + { type: "text-delta", id: "answer", delta: "It is" }, + ]); + return { + finish: async () => { + await stream.write([ + { type: "text-delta", id: "answer", delta: " 72F" }, + { type: "text-end", id: "answer" }, + { type: "finish-step" }, + { type: "finish" }, + ]); + await addMessages({ + promptMessageId: promptDoc._id, + pendingMessageId: pending._id, + finishStreamId: stream.streamId, + messages: [{ message: { role: "assistant", content: "It is 72F" } }], + }); + }, + }; +} + +function summarize(messages: UIMessage[]) { + return messages.map((m) => ({ + role: m.role, + status: m.status, + parts: m.parts.flatMap((p: any) => + p.type === "text" + ? [p.text] + : p.type.startsWith("tool-") + ? [`${p.type} ${JSON.stringify(p.input)} -> ${p.output}`] + : [], + ), + })); +} + +function renderUIMessages(shape: Shape, initialNumItems: number) { + const hook = renderHook(() => + useUIMessages(queries[shape] as never, { threadId } as never, { + initialNumItems, + stream: true as never, + }), + ); + return { + sync: () => sync(hook.rerender), + results: () => hook.result.current.results as UIMessage[], + messages: () => summarize(hook.result.current.results as UIMessage[]), + status: () => hook.result.current.status, + loadMore: (n: number) => act(() => hook.result.current.loadMore(n)), + }; +} + +describe("hook work across renders", () => { + test("switching the query with the same args starts completion over", async () => { + await seedEarlierExchange(); + await seedToolLoop(); + const hook = renderHook( + ({ shape }: { shape: Shape }) => + useUIMessages(queries[shape] as never, { threadId } as never, { + initialNumItems: 3, + }), + { initialProps: { shape: "uiWithoutTools" as Shape } }, + ); + const withoutTools = [ + ...earlierExchange, + prompt, + { role: "assistant", status: "success", parts: ["It is 72F"] }, + ]; + await sync(() => hook.rerender({ shape: "uiWithoutTools" })); + expect(summarize(hook.result.current.results as UIMessage[])).toEqual( + withoutTools, + ); + await sync(() => hook.rerender({ shape: "ui" })); + expect(summarize(hook.result.current.results as UIMessage[])).toEqual([ + prompt, + finishedAnswer, + ]); + + // The first query's pages are still loaded, so they come back at once. + await sync(() => hook.rerender({ shape: "uiWithoutTools" })); + expect(summarize(hook.result.current.results as UIMessage[])).toEqual( + withoutTools, + ); + }); + + test("useThreadMessages keeps its results while nothing changed", async () => { + await seedEarlierExchange(); + await seedToolLoop(); + const hook = renderHook(() => + useThreadMessages(queries.rows as never, { threadId } as never, { + initialNumItems: 10, + stream: true as never, + }), + ); + await sync(hook.rerender); + const before = hook.result.current.results; + act(() => hook.rerender()); + expect(hook.result.current.results).toBe(before); + }); +}); + +describe("useUIMessages when a page ends inside an order", () => { + test("a finished tool loop split by the first page renders whole", async () => { + await seedEarlierExchange(); + await seedToolLoop(); + const ui = renderUIMessages("ui", 2); + await ui.sync(); + expect(ui.messages()).toEqual([prompt, finishedAnswer]); + expect(ui.status()).toBe("CanLoadMore"); + }); + + test("a streaming generation keeps its prompt, earlier steps and status", async () => { + await seedEarlierExchange(); + const generation = await startToolLoopGeneration(); + const ui = renderUIMessages("ui", 2); + await ui.sync(); + expect(ui.messages()).toEqual([prompt, streamingAnswer]); + + await generation.finish(); + await ui.sync(); + expect(ui.messages()).toEqual([prompt, finishedAnswer]); + }); + + test("a generation longer than many pages streams with its prompt", async () => { + const started = await addMessages({ + messages: [ + { message: { role: "user", content: "what is the weather" } }, + ...Array.from({ length: 250 }, (_, i) => ({ + message: { role: "assistant", content: `step ${i}` }, + })), + { message: { role: "assistant", content: [] }, status: "pending" }, + ], + }); + // streamText keeps one stream for all steps of a call, starting at its + // first step, so the saved steps here are steps of the streaming call. + const stream = await createStream(started.messages[0].order, 1); + await stream.write([ + { type: "start" }, + { type: "start-step" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "streaming" }, + ]); + const ui = renderUIMessages("ui", 1); + await ui.sync(); + expect(ui.messages()).toEqual([ + prompt, + { role: "assistant", status: "streaming", parts: ["streaming"] }, + ]); + }, 60_000); + + test("rows read past a deleted prompt are hidden until asked for", async () => { + await seedEarlierExchange(); + const loop = await seedToolLoop(); + await client.t.run((ctx) => + ctx.runMutation(components.agent.messages.deleteByIds, { + messageIds: [loop.messages[0]._id], + }), + ); + const ui = renderUIMessages("ui", 2); + await ui.sync(); + expect(ui.messages()).toEqual([finishedAnswer]); + expect(ui.status()).toBe("CanLoadMore"); + expect(pagesLoaded()).toBe(2); + + ui.loadMore(2); + await ui.sync(); + expect(ui.messages()).toEqual([...earlierExchange, finishedAnswer]); + }); + + test("a first page that slides onto a new generation completes it", async () => { + await seedEarlierExchange(); + const ui = renderUIMessages("ui", 2); + await ui.sync(); + expect(ui.messages()).toEqual(earlierExchange); + + await startToolLoopGeneration(); + await ui.sync(); + expect(ui.messages()).toEqual([prompt, streamingAnswer]); + }); + + test("a pinned page split inside the streaming order renders the same", async () => { + await seedEarlierExchange(); + const ui = renderUIMessages("ui", 2); + await ui.sync(); + ui.loadMore(2); + await ui.sync(); + expect(ui.status()).toBe("Exhausted"); + + // The page grows to eight rows and usePaginatedQuery splits it after + // the fourth, between the tool steps of the streaming order. + await startToolLoopGeneration(2); + await ui.sync(); + expect(ui.messages()).toEqual([ + ...earlierExchange, + prompt, + { + ...streamingAnswer, + parts: [ + "checking", + 'tool-weather {"city":"NYC"} -> 72F', + "checking", + 'tool-weather {"city":"NYC"} -> 72F', + "It is", + ], + }, + ]); + }); +}); + +// A generation retried in the same order while the first attempt's stream is +// still listed as streaming. +async function startRetriedGeneration() { + const started = await addMessages({ + messages: [ + { message: { role: "user", content: "what is the weather" } }, + { message: { role: "assistant", content: [] }, status: "pending" }, + ], + }); + const [promptDoc, firstPending] = started.messages; + const first = await createStream(firstPending.order, firstPending.stepOrder); + await first.write([ + { type: "start" }, + { type: "start-step" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "first try" }, + ]); + const retried = await addMessages({ + promptMessageId: promptDoc._id, + messages: [ + { message: { role: "assistant", content: [] }, status: "pending" }, + ], + }); + const second = await createStream( + retried.messages[0].order, + retried.messages[0].stepOrder, + ); + await second.write([ + { type: "start" }, + { type: "start-step" }, + { type: "text-start", id: "t1" }, + { type: "text-delta", id: "t1", delta: "second try" }, + ]); +} + +describe("useUIMessages streams resolved by order", () => { + test("a success-only query keeps the live stream of a multi-step generation", async () => { + await seedEarlierExchange(); + await startToolLoopGeneration(); + const ui = renderUIMessages("uiSuccessOnly", 10); + await ui.sync(); + expect(ui.messages()).toEqual([ + ...earlierExchange, + prompt, + streamingAnswer, + ]); + }); + + test("of two live streams for an order only the retry is shown", async () => { + await startRetriedGeneration(); + const ui = renderUIMessages("ui", 10); + await ui.sync(); + expect(ui.messages()).toEqual([ + prompt, + { role: "assistant", status: "streaming", parts: ["second try"] }, + ]); + }); +}); + +describe("useUIMessages tool parts across pages", () => { + test("a tool call between two texts keeps its place across pages", async () => { + await seedEarlierExchange(); + await addMessages({ + messages: [ + { message: { role: "user", content: "what is the weather" } }, + { + message: { + ...toolCall, + content: [...toolCall.content, { type: "text", text: "then" }], + }, + }, + { message: toolResult }, + { message: { role: "assistant", content: "It is 72F" } }, + ], + }); + const ui = renderUIMessages("ui", 2); + await ui.sync(); + expect(ui.messages()).toEqual([ + prompt, + { + ...finishedAnswer, + parts: [ + "checking", + 'tool-weather {"city":"NYC"} -> 72F', + "then", + "It is 72F", + ], + }, + ]); + }); +}); + +describe("useUIMessages over a filtered query", () => { + test("completing a boundary order stops at the order the user asked for", async () => { + for (let i = 0; i < 5; i++) { + await seedToolLoop(); + } + const ui = renderUIMessages("uiWithoutTools", 1); + await ui.sync(); + expect(ui.messages()).toEqual([ + prompt, + { role: "assistant", status: "success", parts: ["It is 72F"] }, + ]); + expect(pagesLoaded()).toBe(2); + + // The rows read past the boundary are already loaded. + ui.loadMore(1); + await ui.sync(); + expect(ui.messages()).toEqual([ + prompt, + { role: "assistant", status: "success", parts: ["It is 72F"] }, + prompt, + { role: "assistant", status: "success", parts: ["It is 72F"] }, + ]); + expect(pagesLoaded()).toBe(2); + }); +}); + +describe("useThreadMessages when the first page ends inside an order", () => { + function renderThreadMessages( + initialNumItems: number, + shape: Shape = "rows", + ) { + const hook = renderHook(() => + useThreadMessages(queries[shape] as never, { threadId } as never, { + initialNumItems, + stream: true as never, + }), + ); + return { + sync: () => sync(hook.rerender), + rerender: () => act(() => hook.rerender()), + rows: () => + hook.result.current.results.map( + (m: any) => + `${m.order}/${m.stepOrder} ${m.status}${m.streaming ? " streaming" : ""}${m.text ? ` ${m.text}` : ""}`, + ), + }; + } + + test("switching threads never applies one thread's stream to another's rows", async () => { + const threadB = threadId; + await seedToolLoop(); + await startToolLoopGeneration(); + const threadA = await client.t.run((ctx) => + createThread(ctx, components.agent, { userId: "u" }), + ); + threadId = threadA; + await startToolLoopGeneration(); + + threadId = threadB; + const thread = renderThreadMessages(10); + await thread.sync(); + threadId = threadA; + await thread.sync(); + expect(thread.rows().at(-1)).toBe("0/3 pending streaming It is"); + + // Thread B's own stream is listed but not yet converted on this render. + threadId = threadB; + thread.rerender(); + expect(thread.rows().filter((row) => row.startsWith("0/"))).toEqual([ + "0/0 success what is the weather", + "0/1 success checking", + "0/2 success", + "0/3 success It is 72F", + ]); + }); + + test("the page holds every row of the order", async () => { + await seedEarlierExchange(); + await seedToolLoop(); + const thread = renderThreadMessages(2); + await thread.sync(); + expect(thread.rows()).toEqual([ + "1/0 success what is the weather", + "1/1 success checking", + "1/2 success", + "1/3 success It is 72F", + ]); + }); +}); diff --git a/src/vercel/react/useUIMessages.test.ts b/src/vercel/react/useUIMessages.test.ts deleted file mode 100644 index b6ef9ba2..00000000 --- a/src/vercel/react/useUIMessages.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - dedupeMessages, - mergeUIMessages, - type UIMessageLike, -} from "./useUIMessages.js"; - -type TestMessage = { - order: number; - stepOrder: number; - status: "pending" | "success" | "failed" | "streaming"; - id: string; -}; - -type TestUIMessage = UIMessageLike & { - id: string; - key: string; - text: string; - _creationTime: number; -}; - -function testUIMessage({ - id, - order, - stepOrder, - status, - text, -}: { - id: string; - order: number; - stepOrder: number; - status: TestUIMessage["status"]; - text: string; -}): TestUIMessage { - return { - id, - key: `thread-${order}-${stepOrder}`, - order, - stepOrder, - status, - role: "assistant", - parts: [{ type: "text", text }], - text, - _creationTime: 0, - }; -} - -describe("dedupeMessages", () => { - it("should prefer messages from messages list when streaming messages are absent", () => { - const messages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "success", id: "msg1" }, - { order: 2, stepOrder: 0, status: "success", id: "msg2" }, - ]; - const streamMessages: TestMessage[] = []; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(2); - expect(result).toEqual(messages); - }); - - it("should prefer streaming messages when messages list is empty", () => { - const messages: TestMessage[] = []; - const streamMessages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "streaming", id: "stream1" }, - { order: 2, stepOrder: 0, status: "streaming", id: "stream2" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(2); - expect(result).toEqual(streamMessages); - }); - - it("should prefer non-pending messages over pending messages", () => { - const messages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "pending", id: "pending1" }, - { order: 2, stepOrder: 0, status: "success", id: "success1" }, - ]; - const streamMessages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "success", id: "finalized1" }, - { order: 2, stepOrder: 0, status: "streaming", id: "streaming1" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - order: 1, - stepOrder: 0, - status: "success", - id: "finalized1", - }); - expect(result[1]).toEqual({ - order: 2, - stepOrder: 0, - status: "success", - id: "success1", - }); - }); - - it("should prefer streaming over pending messages", () => { - const messages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "pending", id: "pending1" }, - ]; - const streamMessages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "streaming", id: "streaming1" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - order: 1, - stepOrder: 0, - status: "streaming", - id: "streaming1", - }); - }); - - it("should prefer non-pending messages from messages list over streaming messages", () => { - const messages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "success", id: "success1" }, - ]; - const streamMessages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "streaming", id: "streaming1" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(1); - expect(result[0]).toEqual({ - order: 1, - stepOrder: 0, - status: "success", - id: "success1", - }); - }); - - it("should handle complex scenarios with multiple duplicates", () => { - const messages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "pending", id: "pending1" }, - { order: 2, stepOrder: 0, status: "success", id: "success2" }, - { order: 3, stepOrder: 0, status: "failed", id: "failed3" }, - { order: 4, stepOrder: 0, status: "success", id: "success4" }, - ]; - const streamMessages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "streaming", id: "streaming1" }, - { order: 2, stepOrder: 0, status: "streaming", id: "streaming2" }, - { order: 5, stepOrder: 0, status: "streaming", id: "streaming5" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(5); - // Order 1: streaming should replace pending - expect(result[0]).toEqual({ - order: 1, - stepOrder: 0, - status: "streaming", - id: "streaming1", - }); - // Order 2: success should be kept over streaming - expect(result[1]).toEqual({ - order: 2, - stepOrder: 0, - status: "success", - id: "success2", - }); - // Order 3: failed should be kept (no streaming equivalent) - expect(result[2]).toEqual({ - order: 3, - stepOrder: 0, - status: "failed", - id: "failed3", - }); - // Order 4: success should be kept (no streaming equivalent) - expect(result[3]).toEqual({ - order: 4, - stepOrder: 0, - status: "success", - id: "success4", - }); - // Order 5: streaming should be added (no messages equivalent) - expect(result[4]).toEqual({ - order: 5, - stepOrder: 0, - status: "streaming", - id: "streaming5", - }); - }); - - it("should handle different stepOrder values correctly", () => { - const messages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "pending", id: "pending1-0" }, - { order: 1, stepOrder: 1, status: "success", id: "success1-1" }, - ]; - const streamMessages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "streaming", id: "streaming1-0" }, - { order: 1, stepOrder: 2, status: "streaming", id: "streaming1-2" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(3); - // Order 1, stepOrder 0: streaming should replace pending - expect(result[0]).toEqual({ - order: 1, - stepOrder: 0, - status: "streaming", - id: "streaming1-0", - }); - // Order 1, stepOrder 1: success should be kept - expect(result[1]).toEqual({ - order: 1, - stepOrder: 1, - status: "success", - id: "success1-1", - }); - // Order 1, stepOrder 2: streaming should be added - expect(result[2]).toEqual({ - order: 1, - stepOrder: 2, - status: "streaming", - id: "streaming1-2", - }); - }); - - it("should maintain proper sorting by order and stepOrder", () => { - const messages: TestMessage[] = [ - { order: 3, stepOrder: 0, status: "success", id: "msg3" }, - { order: 1, stepOrder: 1, status: "success", id: "msg1-1" }, - { order: 1, stepOrder: 0, status: "pending", id: "msg1-0" }, - ]; - const streamMessages: TestMessage[] = [ - { order: 2, stepOrder: 0, status: "streaming", id: "stream2" }, - { order: 1, stepOrder: 0, status: "streaming", id: "stream1-0" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(4); - // Should be sorted by order, then stepOrder - expect(result[0]).toEqual({ - order: 1, - stepOrder: 0, - status: "streaming", - id: "stream1-0", - }); - expect(result[1]).toEqual({ - order: 1, - stepOrder: 1, - status: "success", - id: "msg1-1", - }); - expect(result[2]).toEqual({ - order: 2, - stepOrder: 0, - status: "streaming", - id: "stream2", - }); - expect(result[3]).toEqual({ - order: 3, - stepOrder: 0, - status: "success", - id: "msg3", - }); - }); - - it("should handle empty arrays", () => { - const result = dedupeMessages([], []); - expect(result).toEqual([]); - }); - - it("should demonstrate order dependency when messages and streamMessages have same order/stepOrder", () => { - // This test shows that the current implementation doesn't guarantee - // which message wins when they have the same order/stepOrder and status - const messages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "success", id: "messages-success" }, - ]; - const streamMessages: TestMessage[] = [ - { order: 1, stepOrder: 0, status: "success", id: "stream-success" }, - ]; - - const result = dedupeMessages(messages, streamMessages); - - expect(result).toHaveLength(1); - // The result depends on which array comes first in messages.concat(streamMessages) - // Since messages comes first, it should win when both have same status - expect(result[0].id).toBe("messages-success"); - }); -}); - -describe("mergeUIMessages", () => { - it("dedupes streaming steps before combining assistant messages", () => { - const messages = [ - testUIMessage({ - id: "persisted-step-1", - order: 1, - stepOrder: 1, - status: "success", - text: "7", - }), - testUIMessage({ - id: "persisted-step-2", - order: 1, - stepOrder: 2, - status: "success", - text: "8", - }), - ]; - const streamMessages = [ - testUIMessage({ - id: "stream-step-2", - order: 1, - stepOrder: 2, - status: "streaming", - text: "8", - }), - ]; - - const result = mergeUIMessages(messages, streamMessages); - - expect(result).toHaveLength(1); - expect(result[0].id).toBe("persisted-step-1"); - expect(result[0].stepOrder).toBe(1); - expect(result[0].status).toBe("success"); - expect(result[0].text).toBe("7 8"); - expect(result[0].parts).toEqual([ - { type: "text", text: "7" }, - { type: "text", text: "8" }, - ]); - }); -}); diff --git a/src/vercel/react/useUIMessages.ts b/src/vercel/react/useUIMessages.ts index 0396adc7..7b133baf 100644 --- a/src/vercel/react/useUIMessages.ts +++ b/src/vercel/react/useUIMessages.ts @@ -4,7 +4,6 @@ import { type ErrorMessage, type Expand, } from "convex-helpers"; -import { usePaginatedQuery } from "convex-helpers/react"; import { type PaginatedQueryArgs, type UsePaginatedQueryResult, @@ -26,6 +25,7 @@ import { } from "../UIMessages.js"; import { sorted } from "../../shared.js"; import { useStreamingUIMessages } from "./useStreamingUIMessages.js"; +import { resolveStreams, usePaginatedOrders } from "./assemble.js"; export type UIMessageLike = { order: number; @@ -134,7 +134,7 @@ export function useUIMessages>( }, ): UsePaginatedQueryResult> { // These are full messages - const paginated = usePaginatedQuery( + const paginated = usePaginatedOrders( query, args as PaginatedQueryArgs | "skip", { initialNumItems: options.initialNumItems }, @@ -154,50 +154,30 @@ export function useUIMessages>( { startOrder, skipStreamIds: options.skipStreamIds }, ); - const merged = useMemo(() => { - return { - ...paginated, - results: mergeUIMessages(paginated.results, streamMessages ?? []), - }; - }, [paginated, streamMessages]); + const results = useMemo( + () => mergeUIMessages(paginated.results, streamMessages ?? []), + [paginated.results, streamMessages], + ); - return merged as UIMessagesQueryResult; + return { ...paginated, results } as UIMessagesQueryResult; } -export function mergeUIMessages( +export function mergeUIMessages( messages: M[], streamMessages: M[], ): M[] { - const deduped = dedupeMessages(messages, streamMessages); - // Messages may have been split by pagination. Re-combine them here - // after dedupe has had access to each message's original stepOrder. - return combineUIMessages(deduped as unknown as UIMessage[]) as unknown as M[]; -} - -export function dedupeMessages< - M extends { - order: number; - stepOrder: number; - status: UIStatus; - }, ->(messages: M[], streamMessages: M[]): M[] { - return sorted(messages.concat(streamMessages)).reduce((msgs, msg) => { - const last = msgs.at(-1); - if (!last) { - return [msg]; - } - if (last.order !== msg.order || last.stepOrder !== msg.stepOrder) { - return [...msgs, msg]; - } - if ( - (last.status === "pending" || last.status === "streaming") && - msg.status !== "pending" - ) { - // Let's prefer a streaming or finalized message over a pending - // one. - return [...msgs.slice(0, -1), msg]; - } - // skip the new one if the previous one (listed) was finalized - return msgs; - }, [] as M[]); + const { rows, streams } = resolveStreams( + messages, + streamMessages.map((m) => ({ + id: m.id, + order: m.order, + stepOrder: m.stepOrder, + live: m.status === "streaming", + value: m, + })), + ); + // Messages may have been split by pagination. Re-combine them here. + return combineUIMessages( + sorted([...rows, ...streams]) as unknown as UIMessage[], + ) as unknown as M[]; } diff --git a/vitest.config.js b/vitest.config.js index e9408f8b..933feb22 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -3,6 +3,12 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "edge-runtime", + server: { + deps: { + // Lets tests mock convex/react underneath usePaginatedQuery. + inline: ["convex-helpers"], + }, + }, typecheck: { tsconfig: "./tsconfig.test.json", },