@neurocode-ai/app 1.18.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (577) hide show
  1. package/AGENTS.md +35 -0
  2. package/README.md +50 -0
  3. package/V1_API_MIGRATION.md +220 -0
  4. package/bunfig.toml +3 -0
  5. package/create-effect-simplification-spec.md +515 -0
  6. package/e2e/performance/AGENTS.md +13 -0
  7. package/e2e/performance/README.md +79 -0
  8. package/e2e/performance/benchmark.ts +144 -0
  9. package/e2e/performance/chrome-trace.ts +95 -0
  10. package/e2e/performance/playwright.config.ts +20 -0
  11. package/e2e/performance/playwright.uncapped.config.ts +13 -0
  12. package/e2e/performance/timeline/first-navigation-benchmark.spec.ts +87 -0
  13. package/e2e/performance/timeline/first-navigation-metrics.ts +32 -0
  14. package/e2e/performance/timeline/first-navigation-probe.ts +86 -0
  15. package/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts +114 -0
  16. package/e2e/performance/timeline/navigation-milestones.ts +128 -0
  17. package/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts +312 -0
  18. package/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +172 -0
  19. package/e2e/performance/timeline/session-tab-flash.spec.ts +67 -0
  20. package/e2e/performance/timeline/session-tab-repaint-probe.ts +251 -0
  21. package/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +144 -0
  22. package/e2e/performance/timeline/session-tab-switch-metrics.ts +59 -0
  23. package/e2e/performance/timeline/session-tab-switch-probe.ts +226 -0
  24. package/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +504 -0
  25. package/e2e/performance/timeline/session-timeline-benchmark.spec.ts +306 -0
  26. package/e2e/performance/timeline/session-timeline-profile.ts +40 -0
  27. package/e2e/performance/timeline/session-timeline-stream-probe.ts +547 -0
  28. package/e2e/performance/timeline/session-timeline-stress.fixture.ts +369 -0
  29. package/e2e/performance/timeline/timeline-test-helpers.ts +134 -0
  30. package/e2e/performance/timeline-stability/README.md +61 -0
  31. package/e2e/performance/timeline-stability/adverse.spec.ts +250 -0
  32. package/e2e/performance/timeline-stability/context-matrix.spec.ts +192 -0
  33. package/e2e/performance/timeline-stability/environment-matrix.spec.ts +113 -0
  34. package/e2e/performance/timeline-stability/file-matrix.spec.ts +121 -0
  35. package/e2e/performance/timeline-stability/file-mutation.spec.ts +113 -0
  36. package/e2e/performance/timeline-stability/fixture.test.ts +68 -0
  37. package/e2e/performance/timeline-stability/fixture.ts +566 -0
  38. package/e2e/performance/timeline-stability/interaction.spec.ts +242 -0
  39. package/e2e/performance/timeline-stability/lifecycle.spec.ts +157 -0
  40. package/e2e/performance/timeline-stability/oracle-browser.spec.ts +75 -0
  41. package/e2e/performance/timeline-stability/playwright.config.ts +17 -0
  42. package/e2e/performance/timeline-stability/scroll-interaction.spec.ts +353 -0
  43. package/e2e/performance/timeline-stability/shell-matrix.spec.ts +255 -0
  44. package/e2e/performance/timeline-stability/tool-mutation.spec.ts +106 -0
  45. package/e2e/performance/timeline-stability/tools.spec.ts +198 -0
  46. package/e2e/performance/timeline-stability/transition-matrix.spec.ts +272 -0
  47. package/e2e/performance/unit/chrome-trace-write.test.ts +15 -0
  48. package/e2e/performance/unit/first-navigation-metrics.test.ts +32 -0
  49. package/e2e/performance/unit/mock-server.test.ts +46 -0
  50. package/e2e/performance/unit/navigation-milestones.test.ts +34 -0
  51. package/e2e/performance/unit/session-tab-repaint-probe.test.ts +42 -0
  52. package/e2e/performance/unit/session-tab-switch-metrics.test.ts +86 -0
  53. package/e2e/performance/unit/session-tab-switch-probe.test.ts +55 -0
  54. package/e2e/performance/unit/session-timeline-stream-probe.test.ts +14 -0
  55. package/e2e/performance/unit/session-timeline-visual-tracking.test.ts +16 -0
  56. package/e2e/performance/unit/timeline-test-helpers.test.ts +10 -0
  57. package/e2e/performance/unit/visual-stability.test.ts +392 -0
  58. package/e2e/regression/cross-server-tab-close.spec.ts +153 -0
  59. package/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +150 -0
  60. package/e2e/regression/legacy-new-session.spec.ts +41 -0
  61. package/e2e/regression/new-session-panel-corner.spec.ts +83 -0
  62. package/e2e/regression/prompt-input-v2-command-draft.spec.ts +50 -0
  63. package/e2e/regression/prompt-thinking-level.spec.ts +84 -0
  64. package/e2e/regression/remote-session-settings.spec.ts +302 -0
  65. package/e2e/regression/remote-tab-busy.spec.ts +131 -0
  66. package/e2e/regression/review-image-flash.spec.ts +202 -0
  67. package/e2e/regression/review-line-comment.spec.ts +158 -0
  68. package/e2e/regression/review-open-file.spec.ts +163 -0
  69. package/e2e/regression/review-state-persistence.spec.ts +155 -0
  70. package/e2e/regression/review-tab-switch.spec.ts +147 -0
  71. package/e2e/regression/review-terminal-stacked.spec.ts +308 -0
  72. package/e2e/regression/session-list-path-loading.spec.ts +41 -0
  73. package/e2e/regression/session-request-docks.spec.ts +221 -0
  74. package/e2e/regression/session-timeline-accessibility.spec.ts +22 -0
  75. package/e2e/regression/session-timeline-collapse-state.spec.ts +439 -0
  76. package/e2e/regression/session-timeline-context-resize.spec.ts +374 -0
  77. package/e2e/regression/session-timeline-context-state.spec.ts +31 -0
  78. package/e2e/regression/session-timeline-file-projection.spec.ts +52 -0
  79. package/e2e/regression/session-timeline-file-state.spec.ts +94 -0
  80. package/e2e/regression/session-timeline-history-root.spec.ts +242 -0
  81. package/e2e/regression/session-timeline-lifecycle-state.spec.ts +111 -0
  82. package/e2e/regression/session-timeline-locale-projection.spec.ts +25 -0
  83. package/e2e/regression/session-timeline-projection.spec.ts +287 -0
  84. package/e2e/regression/session-timeline-reasoning-projection.spec.ts +93 -0
  85. package/e2e/regression/session-timeline-reducer-projection.spec.ts +43 -0
  86. package/e2e/regression/session-timeline-shell-outline.spec.ts +228 -0
  87. package/e2e/regression/session-timeline-tool-projection.spec.ts +99 -0
  88. package/e2e/regression/session-timeline-tool-state.spec.ts +77 -0
  89. package/e2e/regression/session-timeline-transport.spec.ts +116 -0
  90. package/e2e/regression/session-todo-dock-navigation.spec.ts +190 -0
  91. package/e2e/regression/subagent-child-navigation.spec.ts +203 -0
  92. package/e2e/regression/tab-navigate-mousedown.spec.ts +127 -0
  93. package/e2e/regression/terminal-composer-focus.spec.ts +227 -0
  94. package/e2e/regression/terminal-hidden.spec.ts +117 -0
  95. package/e2e/regression/terminal-tab-switch.spec.ts +165 -0
  96. package/e2e/reproduction/timeline-suspense/index.html +34 -0
  97. package/e2e/reproduction/timeline-suspense/main.tsx +315 -0
  98. package/e2e/reproduction/timeline-suspense/playwright.config.ts +34 -0
  99. package/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +179 -0
  100. package/e2e/reproduction/timeline-suspense/vite.config.ts +7 -0
  101. package/e2e/smoke/session-timeline.fixture.ts +315 -0
  102. package/e2e/smoke/session-timeline.spec.ts +740 -0
  103. package/e2e/tsconfig.json +20 -0
  104. package/e2e/utils/errors.ts +18 -0
  105. package/e2e/utils/mock-server.ts +443 -0
  106. package/e2e/utils/sse-transport.ts +312 -0
  107. package/e2e/utils/visual-stability/analyzer.ts +209 -0
  108. package/e2e/utils/visual-stability/capture.ts +51 -0
  109. package/e2e/utils/visual-stability/index.ts +8 -0
  110. package/e2e/utils/visual-stability/invariant.ts +112 -0
  111. package/e2e/utils/visual-stability/model.ts +47 -0
  112. package/e2e/utils/visual-stability/probe.ts +226 -0
  113. package/e2e/utils/visual-stability/regions.ts +18 -0
  114. package/e2e/utils/visual-stability/reporter.ts +63 -0
  115. package/e2e/utils/visual-stability/scenario.ts +20 -0
  116. package/e2e/utils/visual-stability.ts +54 -0
  117. package/e2e/utils/waits.ts +11 -0
  118. package/happydom.ts +75 -0
  119. package/index.html +28 -0
  120. package/package.json +96 -0
  121. package/playwright.config.ts +45 -0
  122. package/public/_headers +17 -0
  123. package/public/apple-touch-icon-v3.png +1 -0
  124. package/public/apple-touch-icon.png +1 -0
  125. package/public/assets/Inter.ttf +0 -0
  126. package/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
  127. package/public/favicon-96x96-v3.png +1 -0
  128. package/public/favicon-96x96.png +1 -0
  129. package/public/favicon-v3.ico +1 -0
  130. package/public/favicon-v3.svg +1 -0
  131. package/public/favicon.ico +1 -0
  132. package/public/favicon.svg +1 -0
  133. package/public/oc-theme-preload.js +40 -0
  134. package/public/site.webmanifest +1 -0
  135. package/public/social-share-zen.png +1 -0
  136. package/public/social-share.png +1 -0
  137. package/public/web-app-manifest-192x192.png +1 -0
  138. package/public/web-app-manifest-512x512.png +1 -0
  139. package/src/addons/serialize.test.ts +327 -0
  140. package/src/addons/serialize.ts +642 -0
  141. package/src/app.tsx +659 -0
  142. package/src/assets/help/home.png +0 -0
  143. package/src/assets/help/introducing-tabs.mp4 +0 -0
  144. package/src/assets/help/placeholder.png +0 -0
  145. package/src/assets/help/tabs.png +0 -0
  146. package/src/components/command-palette.ts +281 -0
  147. package/src/components/command-tooltip-keybind.test.ts +22 -0
  148. package/src/components/command-tooltip-keybind.ts +11 -0
  149. package/src/components/debug-bar.tsx +550 -0
  150. package/src/components/dialog-command-palette-v2.css +220 -0
  151. package/src/components/dialog-command-palette-v2.tsx +342 -0
  152. package/src/components/dialog-connect-provider.stories.tsx +73 -0
  153. package/src/components/dialog-connect-provider.tsx +1178 -0
  154. package/src/components/dialog-custom-provider-form.ts +158 -0
  155. package/src/components/dialog-custom-provider.test.ts +80 -0
  156. package/src/components/dialog-custom-provider.tsx +330 -0
  157. package/src/components/dialog-edit-project-v2.tsx +156 -0
  158. package/src/components/dialog-edit-project.tsx +170 -0
  159. package/src/components/dialog-fork.tsx +104 -0
  160. package/src/components/dialog-manage-models.tsx +265 -0
  161. package/src/components/dialog-release-notes.tsx +144 -0
  162. package/src/components/dialog-select-directory-v2.css +107 -0
  163. package/src/components/dialog-select-directory-v2.tsx +378 -0
  164. package/src/components/dialog-select-directory.tsx +191 -0
  165. package/src/components/dialog-select-file.tsx +189 -0
  166. package/src/components/dialog-select-mcp.tsx +92 -0
  167. package/src/components/dialog-select-model-search.test.ts +19 -0
  168. package/src/components/dialog-select-model-search.ts +18 -0
  169. package/src/components/dialog-select-model-unpaid-v2.stories.tsx +55 -0
  170. package/src/components/dialog-select-model-unpaid-v2.tsx +175 -0
  171. package/src/components/dialog-select-model-unpaid.tsx +147 -0
  172. package/src/components/dialog-select-model.tsx +549 -0
  173. package/src/components/dialog-select-server.tsx +722 -0
  174. package/src/components/dialog-settings.tsx +94 -0
  175. package/src/components/dialog-usage-exceeded.tsx +44 -0
  176. package/src/components/directory-picker-domain.test.ts +248 -0
  177. package/src/components/directory-picker-domain.ts +407 -0
  178. package/src/components/directory-picker-policy.ts +7 -0
  179. package/src/components/directory-picker.test.ts +21 -0
  180. package/src/components/directory-picker.tsx +45 -0
  181. package/src/components/edit-project.ts +122 -0
  182. package/src/components/file-tree-v2-model.test.ts +74 -0
  183. package/src/components/file-tree-v2-model.ts +107 -0
  184. package/src/components/file-tree-v2.tsx +299 -0
  185. package/src/components/file-tree.test.ts +80 -0
  186. package/src/components/file-tree.tsx +509 -0
  187. package/src/components/help-button.tsx +148 -0
  188. package/src/components/link.tsx +26 -0
  189. package/src/components/model-tooltip.tsx +124 -0
  190. package/src/components/pierre-tree.test.ts +23 -0
  191. package/src/components/prompt-input/attachments.test.ts +108 -0
  192. package/src/components/prompt-input/attachments.ts +228 -0
  193. package/src/components/prompt-input/build-request-parts.test.ts +396 -0
  194. package/src/components/prompt-input/build-request-parts.ts +213 -0
  195. package/src/components/prompt-input/context-items.tsx +98 -0
  196. package/src/components/prompt-input/contracts.ts +57 -0
  197. package/src/components/prompt-input/drag-overlay.tsx +25 -0
  198. package/src/components/prompt-input/editor-dom.test.ts +99 -0
  199. package/src/components/prompt-input/editor-dom.ts +148 -0
  200. package/src/components/prompt-input/files.ts +98 -0
  201. package/src/components/prompt-input/history-store.ts +47 -0
  202. package/src/components/prompt-input/history.test.ts +153 -0
  203. package/src/components/prompt-input/history.ts +256 -0
  204. package/src/components/prompt-input/image-attachments.css +43 -0
  205. package/src/components/prompt-input/image-attachments.tsx +168 -0
  206. package/src/components/prompt-input/paste.ts +24 -0
  207. package/src/components/prompt-input/placeholder.test.ts +48 -0
  208. package/src/components/prompt-input/placeholder.ts +20 -0
  209. package/src/components/prompt-input/slash-popover.tsx +376 -0
  210. package/src/components/prompt-input/submission-state.ts +33 -0
  211. package/src/components/prompt-input/submit.test.ts +598 -0
  212. package/src/components/prompt-input/submit.ts +634 -0
  213. package/src/components/prompt-input/transient-state.ts +46 -0
  214. package/src/components/prompt-input-v2.tsx +592 -0
  215. package/src/components/prompt-input.stories.tsx +223 -0
  216. package/src/components/prompt-input.tsx +1790 -0
  217. package/src/components/prompt-project-selector.tsx +593 -0
  218. package/src/components/prompt-workspace-selector.tsx +128 -0
  219. package/src/components/server/server-row-menu.tsx +96 -0
  220. package/src/components/server/server-row.tsx +127 -0
  221. package/src/components/session/index.ts +7 -0
  222. package/src/components/session/open-in-app-v2.tsx +98 -0
  223. package/src/components/session/open-in-app.tsx +229 -0
  224. package/src/components/session/session-context-breakdown.test.ts +61 -0
  225. package/src/components/session/session-context-breakdown.ts +132 -0
  226. package/src/components/session/session-context-format.ts +20 -0
  227. package/src/components/session/session-context-metrics.test.ts +99 -0
  228. package/src/components/session/session-context-metrics.ts +65 -0
  229. package/src/components/session/session-context-tab.tsx +343 -0
  230. package/src/components/session/session-header.tsx +568 -0
  231. package/src/components/session/session-new-design-view.tsx +16 -0
  232. package/src/components/session/session-new-view.tsx +91 -0
  233. package/src/components/session/session-sortable-tab-v2.tsx +74 -0
  234. package/src/components/session/session-sortable-tab.tsx +78 -0
  235. package/src/components/session/session-sortable-terminal-tab-v2.tsx +284 -0
  236. package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
  237. package/src/components/session-context-usage.tsx +169 -0
  238. package/src/components/settings-dialog.tsx +43 -0
  239. package/src/components/settings-general.tsx +794 -0
  240. package/src/components/settings-keybinds.tsx +553 -0
  241. package/src/components/settings-list.tsx +5 -0
  242. package/src/components/settings-models.tsx +149 -0
  243. package/src/components/settings-providers.tsx +264 -0
  244. package/src/components/settings-server-picker.tsx +106 -0
  245. package/src/components/settings-servers.tsx +33 -0
  246. package/src/components/settings-v2/dialog-server-v2.tsx +134 -0
  247. package/src/components/settings-v2/dialog-settings-v2.tsx +98 -0
  248. package/src/components/settings-v2/general.tsx +722 -0
  249. package/src/components/settings-v2/index.tsx +1 -0
  250. package/src/components/settings-v2/interface-transition.stories.tsx +76 -0
  251. package/src/components/settings-v2/interface-transition.tsx +54 -0
  252. package/src/components/settings-v2/models.tsx +138 -0
  253. package/src/components/settings-v2/parts/list.tsx +6 -0
  254. package/src/components/settings-v2/parts/row.tsx +20 -0
  255. package/src/components/settings-v2/providers.tsx +264 -0
  256. package/src/components/settings-v2/servers.tsx +139 -0
  257. package/src/components/settings-v2/settings-v2.css +675 -0
  258. package/src/components/status-popover-body.tsx +514 -0
  259. package/src/components/status-popover-indicator.test.ts +36 -0
  260. package/src/components/status-popover-indicator.ts +20 -0
  261. package/src/components/status-popover.tsx +190 -0
  262. package/src/components/terminal.tsx +759 -0
  263. package/src/components/titlebar-history.test.ts +63 -0
  264. package/src/components/titlebar-history.ts +57 -0
  265. package/src/components/titlebar-session-events.test.ts +32 -0
  266. package/src/components/titlebar-session-events.ts +35 -0
  267. package/src/components/titlebar-tab-gesture.test.ts +33 -0
  268. package/src/components/titlebar-tab-gesture.ts +17 -0
  269. package/src/components/titlebar-tab-nav.css +100 -0
  270. package/src/components/titlebar-tab-nav.tsx +429 -0
  271. package/src/components/titlebar-tab-popover.css +127 -0
  272. package/src/components/titlebar-tab-popover.tsx +92 -0
  273. package/src/components/titlebar-tab-strip.tsx +333 -0
  274. package/src/components/titlebar.css +77 -0
  275. package/src/components/titlebar.tsx +776 -0
  276. package/src/components/ui/drawer.tsx +119 -0
  277. package/src/components/updater-action.test.ts +26 -0
  278. package/src/components/updater-action.ts +51 -0
  279. package/src/components/virtual-scroll-element.test.ts +18 -0
  280. package/src/components/virtual-scroll-element.ts +4 -0
  281. package/src/components/windows-app-menu.tsx +131 -0
  282. package/src/constants/file-picker.ts +89 -0
  283. package/src/context/closed-tabs.ts +40 -0
  284. package/src/context/command-keybind.test.ts +69 -0
  285. package/src/context/command.test.ts +55 -0
  286. package/src/context/command.tsx +467 -0
  287. package/src/context/comments.test.ts +188 -0
  288. package/src/context/comments.tsx +261 -0
  289. package/src/context/directory-sync.ts +155 -0
  290. package/src/context/file/content-cache.ts +88 -0
  291. package/src/context/file/path.test.ts +360 -0
  292. package/src/context/file/path.ts +151 -0
  293. package/src/context/file/tree-store.ts +174 -0
  294. package/src/context/file/types.ts +41 -0
  295. package/src/context/file/view-cache.ts +147 -0
  296. package/src/context/file/watcher.test.ts +149 -0
  297. package/src/context/file/watcher.ts +53 -0
  298. package/src/context/file-content-eviction-accounting.test.ts +65 -0
  299. package/src/context/file.tsx +303 -0
  300. package/src/context/global-sync/bootstrap.test.ts +231 -0
  301. package/src/context/global-sync/bootstrap.ts +536 -0
  302. package/src/context/global-sync/child-store.test.ts +276 -0
  303. package/src/context/global-sync/child-store.ts +396 -0
  304. package/src/context/global-sync/event-reducer.test.ts +616 -0
  305. package/src/context/global-sync/event-reducer.ts +478 -0
  306. package/src/context/global-sync/eviction.ts +28 -0
  307. package/src/context/global-sync/home-session-index.test.ts +154 -0
  308. package/src/context/global-sync/home-session-index.ts +174 -0
  309. package/src/context/global-sync/mcp.test.ts +54 -0
  310. package/src/context/global-sync/mcp.ts +19 -0
  311. package/src/context/global-sync/queue.test.ts +46 -0
  312. package/src/context/global-sync/queue.ts +87 -0
  313. package/src/context/global-sync/session-cache.test.ts +104 -0
  314. package/src/context/global-sync/session-cache.ts +62 -0
  315. package/src/context/global-sync/session-load.ts +33 -0
  316. package/src/context/global-sync/session-trim.test.ts +59 -0
  317. package/src/context/global-sync/session-trim.ts +57 -0
  318. package/src/context/global-sync/types.ts +135 -0
  319. package/src/context/global-sync/utils.test.ts +133 -0
  320. package/src/context/global-sync/utils.ts +171 -0
  321. package/src/context/global.tsx +161 -0
  322. package/src/context/highlights.tsx +233 -0
  323. package/src/context/language.tsx +243 -0
  324. package/src/context/layout-helpers.ts +38 -0
  325. package/src/context/layout-scroll.test.ts +64 -0
  326. package/src/context/layout-scroll.ts +126 -0
  327. package/src/context/layout-tabs.test.ts +82 -0
  328. package/src/context/layout-tabs.ts +103 -0
  329. package/src/context/layout.test.ts +69 -0
  330. package/src/context/layout.tsx +1075 -0
  331. package/src/context/local-agent.test.ts +29 -0
  332. package/src/context/local-agent.ts +7 -0
  333. package/src/context/local.tsx +417 -0
  334. package/src/context/mcp.ts +19 -0
  335. package/src/context/model-variant.test.ts +86 -0
  336. package/src/context/model-variant.ts +52 -0
  337. package/src/context/models.tsx +173 -0
  338. package/src/context/notification.tsx +478 -0
  339. package/src/context/permission-auto-respond.test.ts +125 -0
  340. package/src/context/permission-auto-respond.ts +60 -0
  341. package/src/context/permission.tsx +484 -0
  342. package/src/context/platform.tsx +142 -0
  343. package/src/context/prompt-state.test.ts +29 -0
  344. package/src/context/prompt-state.ts +266 -0
  345. package/src/context/prompt.tsx +170 -0
  346. package/src/context/sdk.tsx +17 -0
  347. package/src/context/server-sdk.test.ts +196 -0
  348. package/src/context/server-sdk.tsx +444 -0
  349. package/src/context/server-session-v2-reducer.test.ts +156 -0
  350. package/src/context/server-session-v2-reducer.ts +502 -0
  351. package/src/context/server-session.test.ts +1638 -0
  352. package/src/context/server-session.ts +1425 -0
  353. package/src/context/server-sync.test.ts +231 -0
  354. package/src/context/server-sync.tsx +753 -0
  355. package/src/context/server.test.ts +245 -0
  356. package/src/context/server.tsx +360 -0
  357. package/src/context/settings.test.ts +79 -0
  358. package/src/context/settings.tsx +531 -0
  359. package/src/context/sync-optimistic.test.ts +123 -0
  360. package/src/context/sync.tsx +119 -0
  361. package/src/context/tab-memory.ts +36 -0
  362. package/src/context/tabs.test.ts +107 -0
  363. package/src/context/tabs.tsx +387 -0
  364. package/src/context/terminal-title.ts +24 -0
  365. package/src/context/terminal.test.ts +91 -0
  366. package/src/context/terminal.tsx +546 -0
  367. package/src/custom-elements.d.ts +1 -0
  368. package/src/desktop-menu.test.ts +13 -0
  369. package/src/desktop-menu.ts +223 -0
  370. package/src/entry.tsx +182 -0
  371. package/src/env.d.ts +31 -0
  372. package/src/hooks/provider-catalog.test.ts +59 -0
  373. package/src/hooks/provider-catalog.ts +27 -0
  374. package/src/hooks/use-providers.ts +71 -0
  375. package/src/i18n/ar.ts +1008 -0
  376. package/src/i18n/br.ts +1024 -0
  377. package/src/i18n/bs.ts +1100 -0
  378. package/src/i18n/da.ts +1092 -0
  379. package/src/i18n/de.ts +1037 -0
  380. package/src/i18n/en.ts +1102 -0
  381. package/src/i18n/es.ts +1108 -0
  382. package/src/i18n/fr.ts +1037 -0
  383. package/src/i18n/ja.ts +1016 -0
  384. package/src/i18n/ko.ts +1015 -0
  385. package/src/i18n/no.ts +1111 -0
  386. package/src/i18n/parity.test.ts +118 -0
  387. package/src/i18n/pl.ts +1024 -0
  388. package/src/i18n/ru.ts +1103 -0
  389. package/src/i18n/th.ts +1087 -0
  390. package/src/i18n/tr.ts +1106 -0
  391. package/src/i18n/uk.ts +1105 -0
  392. package/src/i18n/zh.ts +1078 -0
  393. package/src/i18n/zht.ts +1074 -0
  394. package/src/index.css +330 -0
  395. package/src/index.ts +30 -0
  396. package/src/pages/directory-layout.tsx +122 -0
  397. package/src/pages/error-description.test.ts +17 -0
  398. package/src/pages/error-description.ts +11 -0
  399. package/src/pages/error.tsx +372 -0
  400. package/src/pages/home/home-controller.ts +108 -0
  401. package/src/pages/home/home-projects-controller.tsx +128 -0
  402. package/src/pages/home/home-projects-view.tsx +615 -0
  403. package/src/pages/home/home-projects.tsx +40 -0
  404. package/src/pages/home/home-scroll-controller.ts +145 -0
  405. package/src/pages/home/home-session-search-controller.ts +114 -0
  406. package/src/pages/home/home-sessions-controller.tsx +314 -0
  407. package/src/pages/home/home-sessions-view.tsx +550 -0
  408. package/src/pages/home/home-sessions.tsx +48 -0
  409. package/src/pages/home/legacy-home.tsx +142 -0
  410. package/src/pages/home-session-archive.test.ts +51 -0
  411. package/src/pages/home-session-archive.ts +27 -0
  412. package/src/pages/home-session-open.test.ts +31 -0
  413. package/src/pages/home-session-open.ts +14 -0
  414. package/src/pages/home.tsx +50 -0
  415. package/src/pages/layout/deep-links.ts +50 -0
  416. package/src/pages/layout/helpers.test.ts +321 -0
  417. package/src/pages/layout/helpers.ts +150 -0
  418. package/src/pages/layout/inline-editor.tsx +126 -0
  419. package/src/pages/layout/project-avatar-state.ts +49 -0
  420. package/src/pages/layout/session-tab-avatar.tsx +60 -0
  421. package/src/pages/layout/sidebar-items.tsx +335 -0
  422. package/src/pages/layout/sidebar-project.tsx +377 -0
  423. package/src/pages/layout/sidebar-shell.tsx +125 -0
  424. package/src/pages/layout/sidebar-workspace.tsx +488 -0
  425. package/src/pages/layout-new.tsx +53 -0
  426. package/src/pages/layout.tsx +2441 -0
  427. package/src/pages/new-session.tsx +282 -0
  428. package/src/pages/session/composer/index.ts +4 -0
  429. package/src/pages/session/composer/prompt-model-selection.ts +133 -0
  430. package/src/pages/session/composer/session-composer-controls.ts +129 -0
  431. package/src/pages/session/composer/session-composer-region-controller.ts +145 -0
  432. package/src/pages/session/composer/session-composer-region.tsx +168 -0
  433. package/src/pages/session/composer/session-composer-state.test.ts +138 -0
  434. package/src/pages/session/composer/session-composer-state.ts +204 -0
  435. package/src/pages/session/composer/session-followup-dock.tsx +109 -0
  436. package/src/pages/session/composer/session-permission-dock.tsx +74 -0
  437. package/src/pages/session/composer/session-question-dock.tsx +640 -0
  438. package/src/pages/session/composer/session-request-tree.ts +52 -0
  439. package/src/pages/session/composer/session-revert-dock.stories.tsx +124 -0
  440. package/src/pages/session/composer/session-revert-dock.tsx +188 -0
  441. package/src/pages/session/composer/session-todo-dock.tsx +277 -0
  442. package/src/pages/session/composer/todo-panel-motion.stories.tsx +622 -0
  443. package/src/pages/session/file-tab-scroll.test.ts +40 -0
  444. package/src/pages/session/file-tab-scroll.ts +67 -0
  445. package/src/pages/session/file-tabs.tsx +800 -0
  446. package/src/pages/session/handoff.ts +36 -0
  447. package/src/pages/session/helpers.test.ts +214 -0
  448. package/src/pages/session/helpers.ts +209 -0
  449. package/src/pages/session/message-gesture.test.ts +62 -0
  450. package/src/pages/session/message-gesture.ts +21 -0
  451. package/src/pages/session/message-id-from-hash.ts +6 -0
  452. package/src/pages/session/new-session-layout.ts +2 -0
  453. package/src/pages/session/review-tab.tsx +172 -0
  454. package/src/pages/session/session-layout.ts +30 -0
  455. package/src/pages/session/session-lineage.ts +69 -0
  456. package/src/pages/session/session-model-helpers.test.ts +151 -0
  457. package/src/pages/session/session-model-helpers.ts +63 -0
  458. package/src/pages/session/session-ownership.ts +37 -0
  459. package/src/pages/session/session-panel-layout.test.ts +19 -0
  460. package/src/pages/session/session-panel-layout.ts +6 -0
  461. package/src/pages/session/session-panel-width.test.ts +52 -0
  462. package/src/pages/session/session-panel-width.ts +19 -0
  463. package/src/pages/session/session-side-panel.tsx +865 -0
  464. package/src/pages/session/terminal-label.ts +16 -0
  465. package/src/pages/session/terminal-panel-v2.tsx +352 -0
  466. package/src/pages/session/terminal-panel.test.ts +25 -0
  467. package/src/pages/session/terminal-panel.tsx +343 -0
  468. package/src/pages/session/timeline/measure.test.ts +30 -0
  469. package/src/pages/session/timeline/measure.ts +5 -0
  470. package/src/pages/session/timeline/message-timeline.tsx +1864 -0
  471. package/src/pages/session/timeline/model.test.ts +95 -0
  472. package/src/pages/session/timeline/model.ts +128 -0
  473. package/src/pages/session/timeline/observe-element-offset.test.ts +199 -0
  474. package/src/pages/session/timeline/observe-element-offset.ts +73 -0
  475. package/src/pages/session/timeline/projection.test.ts +96 -0
  476. package/src/pages/session/timeline/projection.ts +81 -0
  477. package/src/pages/session/timeline/row-reconciliation.ts +56 -0
  478. package/src/pages/session/timeline/rows-current.test.ts +170 -0
  479. package/src/pages/session/timeline/rows.ts +344 -0
  480. package/src/pages/session/timeline/summary-diffs.test.ts +42 -0
  481. package/src/pages/session/timeline/summary-diffs.ts +20 -0
  482. package/src/pages/session/timeline/timeline-row.ts +80 -0
  483. package/src/pages/session/timeline/virtual-items.ts +3 -0
  484. package/src/pages/session/usage-exceeded-dialogs.tsx +104 -0
  485. package/src/pages/session/use-composer-commands.tsx +83 -0
  486. package/src/pages/session/use-session-commands.tsx +606 -0
  487. package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
  488. package/src/pages/session/use-session-hash-scroll.ts +202 -0
  489. package/src/pages/session/v2/review-diff-kinds.test.ts +70 -0
  490. package/src/pages/session/v2/review-diff-kinds.ts +62 -0
  491. package/src/pages/session/v2/review-panel-v2-state.ts +41 -0
  492. package/src/pages/session/v2/review-panel-v2.tsx +255 -0
  493. package/src/pages/session/v2/session-file-browser-tab.tsx +181 -0
  494. package/src/pages/session/v2/session-file-list-v2.tsx +166 -0
  495. package/src/pages/session.tsx +2385 -0
  496. package/src/sst-env.d.ts +12 -0
  497. package/src/theme-preload.test.ts +46 -0
  498. package/src/updater.ts +17 -0
  499. package/src/utils/agent.ts +44 -0
  500. package/src/utils/aim.ts +138 -0
  501. package/src/utils/base64.ts +10 -0
  502. package/src/utils/comment-note.ts +88 -0
  503. package/src/utils/diffs.test.ts +75 -0
  504. package/src/utils/diffs.ts +50 -0
  505. package/src/utils/file-manager.ts +24 -0
  506. package/src/utils/id.ts +93 -0
  507. package/src/utils/notification-click.test.ts +27 -0
  508. package/src/utils/notification-click.ts +13 -0
  509. package/src/utils/path-key.ts +24 -0
  510. package/src/utils/persist.test.ts +211 -0
  511. package/src/utils/persist.ts +661 -0
  512. package/src/utils/prompt.test.ts +44 -0
  513. package/src/utils/prompt.ts +203 -0
  514. package/src/utils/refcount.test.ts +49 -0
  515. package/src/utils/refcount.ts +32 -0
  516. package/src/utils/runtime-adapters.test.ts +64 -0
  517. package/src/utils/runtime-adapters.ts +39 -0
  518. package/src/utils/same.ts +6 -0
  519. package/src/utils/scoped-cache.test.ts +69 -0
  520. package/src/utils/scoped-cache.ts +104 -0
  521. package/src/utils/search-keydown.ts +116 -0
  522. package/src/utils/server-compat.test.ts +193 -0
  523. package/src/utils/server-compat.ts +512 -0
  524. package/src/utils/server-errors.test.ts +175 -0
  525. package/src/utils/server-errors.ts +109 -0
  526. package/src/utils/server-health.test.ts +178 -0
  527. package/src/utils/server-health.ts +172 -0
  528. package/src/utils/server-protocol.test.ts +40 -0
  529. package/src/utils/server-protocol.ts +35 -0
  530. package/src/utils/server-scope.test.ts +61 -0
  531. package/src/utils/server-scope.ts +73 -0
  532. package/src/utils/server.test.ts +23 -0
  533. package/src/utils/server.ts +62 -0
  534. package/src/utils/session-message.test.ts +214 -0
  535. package/src/utils/session-message.ts +355 -0
  536. package/src/utils/session-route.test.ts +71 -0
  537. package/src/utils/session-route.ts +39 -0
  538. package/src/utils/session-title.ts +7 -0
  539. package/src/utils/session.test.ts +94 -0
  540. package/src/utils/session.ts +37 -0
  541. package/src/utils/solid-dnd.tsx +49 -0
  542. package/src/utils/sound.ts +102 -0
  543. package/src/utils/terminal-websocket-url.test.ts +80 -0
  544. package/src/utils/terminal-websocket-url.ts +35 -0
  545. package/src/utils/terminal-writer.test.ts +64 -0
  546. package/src/utils/terminal-writer.ts +65 -0
  547. package/src/utils/time.ts +22 -0
  548. package/src/utils/toast.tsx +34 -0
  549. package/src/utils/uuid.test.ts +78 -0
  550. package/src/utils/uuid.ts +12 -0
  551. package/src/utils/worktree.test.ts +58 -0
  552. package/src/utils/worktree.ts +76 -0
  553. package/src/wsl/add-server-probes.ts +57 -0
  554. package/src/wsl/context.tsx +36 -0
  555. package/src/wsl/dialog-add-server.tsx +463 -0
  556. package/src/wsl/dialog-add-wsl-server.css +392 -0
  557. package/src/wsl/settings-model.test.ts +231 -0
  558. package/src/wsl/settings-model.ts +341 -0
  559. package/src/wsl/settings.tsx +173 -0
  560. package/src/wsl/types.ts +85 -0
  561. package/sst-env.d.ts +10 -0
  562. package/test-browser/command-palette.test.ts +78 -0
  563. package/test-browser/motion-spring.test.ts +20 -0
  564. package/test-browser/prompt-attachments.test.ts +90 -0
  565. package/test-browser/prompt-persistence.test.ts +71 -0
  566. package/test-browser/prompt-scope.test.ts +14 -0
  567. package/test-browser/prompt-submission-state.test.ts +73 -0
  568. package/test-browser/prompt-transient-state.test.ts +38 -0
  569. package/test-browser/review-panel-v2-state.test.ts +65 -0
  570. package/test-browser/session-lineage.test.ts +231 -0
  571. package/test-browser/session-ownership.test.ts +45 -0
  572. package/test-browser/solid-virtual.test.ts +112 -0
  573. package/test-browser/toast-owner.test.ts +26 -0
  574. package/tsconfig.json +26 -0
  575. package/vendor/opencode-ai-client-1.17.13.tgz +0 -0
  576. package/vite.config.ts +33 -0
  577. package/vite.js +48 -0
@@ -0,0 +1,1638 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import type { retry } from "@neurocode-ai/core/util/retry"
3
+ import type { MessageApi, OpenCodeEvent, SessionApi } from "@neurocode-ai/client/promise"
4
+ import type { Message, NeurocodeClient, Part, Session } from "@neurocode-ai/sdk/v2/client"
5
+ import { createServerSession } from "./server-session"
6
+
7
+ const session = (id: string, parentID?: string): Session => ({
8
+ id,
9
+ slug: id,
10
+ projectID: "project",
11
+ directory: "/repo",
12
+ title: id,
13
+ version: "1",
14
+ parentID,
15
+ time: { created: 1, updated: 1 },
16
+ })
17
+
18
+ type UserMessage = Extract<Message, { role: "user" }>
19
+ type AssistantMessage = Extract<Message, { role: "assistant" }>
20
+ type TextPart = Extract<Part, { type: "text" }>
21
+ type MessageResponse = {
22
+ data: { info: Message; parts: Part[] }[]
23
+ response: { headers: Headers }
24
+ }
25
+ type SingleMessageResponse = { data: MessageResponse["data"][number] }
26
+
27
+ const userMessage = (id: string, input: Partial<UserMessage> = {}): UserMessage => ({
28
+ id,
29
+ sessionID: "child",
30
+ role: "user",
31
+ time: { created: 1 },
32
+ agent: "build",
33
+ model: { providerID: "provider", modelID: "model" },
34
+ ...input,
35
+ })
36
+
37
+ const assistantMessage = (id: string, parentID: string, input: Partial<AssistantMessage> = {}): AssistantMessage => ({
38
+ id,
39
+ sessionID: "child",
40
+ role: "assistant",
41
+ time: { created: Number(id.at(-1)), completed: Number(id.at(-1)) },
42
+ parentID,
43
+ modelID: "model",
44
+ providerID: "provider",
45
+ mode: "build",
46
+ agent: "build",
47
+ path: { cwd: "/repo", root: "/repo" },
48
+ cost: 0,
49
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
50
+ ...input,
51
+ })
52
+
53
+ const textPart = (messageID: string, input: Partial<TextPart> = {}): TextPart => ({
54
+ id: "part",
55
+ sessionID: "child",
56
+ messageID,
57
+ type: "text",
58
+ text: "text",
59
+ ...input,
60
+ })
61
+
62
+ const response = (data: MessageResponse["data"] = [], cursor?: string): MessageResponse => ({
63
+ data,
64
+ response: { headers: new Headers(cursor ? { "x-next-cursor": cursor } : undefined) },
65
+ })
66
+
67
+ const singleResponse = (info: Message, parts: Part[] = []): SingleMessageResponse => ({ data: { info, parts } })
68
+
69
+ const deferredResponse = () => Promise.withResolvers<MessageResponse>()
70
+
71
+ function messageClient(...responses: Array<MessageResponse | Promise<MessageResponse>>) {
72
+ let index = 0
73
+ const requests: unknown[] = []
74
+ const waiting = new Map<number, () => void>()
75
+ const client = {
76
+ session: {
77
+ get: async () => ({ data: session("child", "root") }),
78
+ messages: (input: unknown) => {
79
+ requests.push(input)
80
+ waiting.get(requests.length)?.()
81
+ waiting.delete(requests.length)
82
+ return responses[index++]
83
+ },
84
+ },
85
+ } as unknown as NeurocodeClient
86
+ return Object.assign(client, {
87
+ requests,
88
+ requested(count: number) {
89
+ if (requests.length >= count) return Promise.resolve()
90
+ return new Promise<void>((resolve) => waiting.set(count, resolve))
91
+ },
92
+ })
93
+ }
94
+
95
+ function rootMessageClient(
96
+ pages: Array<MessageResponse | Promise<MessageResponse>>,
97
+ roots: Array<SingleMessageResponse | Promise<SingleMessageResponse>>,
98
+ ) {
99
+ let pageIndex = 0
100
+ let rootIndex = 0
101
+ const requests: unknown[] = []
102
+ const rootRequests: unknown[] = []
103
+ const rootWaiting = new Map<number, () => void>()
104
+ const client = {
105
+ session: {
106
+ get: async () => ({ data: session("child", "root") }),
107
+ messages: (input: unknown) => {
108
+ requests.push(input)
109
+ return pages[pageIndex++]
110
+ },
111
+ message: (input: unknown) => {
112
+ rootRequests.push(input)
113
+ rootWaiting.get(rootRequests.length)?.()
114
+ rootWaiting.delete(rootRequests.length)
115
+ return roots[rootIndex++]
116
+ },
117
+ },
118
+ } as unknown as NeurocodeClient
119
+ return Object.assign(client, {
120
+ requests,
121
+ rootRequests,
122
+ rootRequested(count: number) {
123
+ if (rootRequests.length >= count) return Promise.resolve()
124
+ return new Promise<void>((resolve) => rootWaiting.set(count, resolve))
125
+ },
126
+ })
127
+ }
128
+
129
+ const retryImmediately: typeof retry = async (task, options = {}) => {
130
+ const attempts = options.attempts ?? 3
131
+ for (let attempt = 0; ; attempt++) {
132
+ try {
133
+ return await task()
134
+ } catch (error) {
135
+ if (attempt === attempts - 1) throw error
136
+ }
137
+ }
138
+ }
139
+
140
+ function setup(sessions: Record<string, Session>) {
141
+ const get: unknown[] = []
142
+ const messages: unknown[] = []
143
+ const client = {
144
+ session: {
145
+ get: async (input: unknown) => {
146
+ get.push(input)
147
+ const id = (input as { sessionID: string }).sessionID
148
+ return { data: sessions[id] }
149
+ },
150
+ messages: async (input: unknown) => {
151
+ messages.push(input)
152
+ return response()
153
+ },
154
+ diff: async () => ({ data: [] }),
155
+ todo: async () => ({ data: [] }),
156
+ },
157
+ } as unknown as NeurocodeClient
158
+ return { get, messages, store: createServerSession(client) }
159
+ }
160
+
161
+ describe("server session", () => {
162
+ test("projects V2 session events into current and legacy message state", () => {
163
+ const ctx = setup({ child: session("child") })
164
+ ctx.store.remember(session("child"))
165
+ ctx.store.set("session_message", "child", [
166
+ {
167
+ id: "msg_1_user",
168
+ type: "user",
169
+ text: "hello",
170
+ time: { created: 1 },
171
+ },
172
+ ])
173
+ const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent)
174
+
175
+ apply({
176
+ id: "evt_step",
177
+ created: 2,
178
+ type: "session.step.started",
179
+ durable: { aggregateID: "child", seq: 1, version: 1 },
180
+ location: { directory: "/repo" },
181
+ data: {
182
+ sessionID: "child",
183
+ assistantMessageID: "msg_2_assistant",
184
+ agent: "build",
185
+ model: { id: "model", providerID: "provider" },
186
+ },
187
+ })
188
+ apply({
189
+ id: "evt_text_start",
190
+ created: 3,
191
+ type: "session.text.started",
192
+ durable: { aggregateID: "child", seq: 2, version: 1 },
193
+ location: { directory: "/repo" },
194
+ data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 },
195
+ })
196
+ apply({
197
+ id: "evt_text_delta",
198
+ created: 4,
199
+ type: "session.text.delta",
200
+ location: { directory: "/repo" },
201
+ data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" },
202
+ })
203
+
204
+ expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({
205
+ id: "msg_2_assistant",
206
+ type: "assistant",
207
+ content: [{ type: "text", text: "world" }],
208
+ })
209
+ expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"])
210
+ expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }])
211
+ })
212
+
213
+ test("resolves lineage by session ID without directory", async () => {
214
+ const ctx = setup({ child: session("child", "root"), root: session("root") })
215
+
216
+ const result = await ctx.store.lineage.resolve("child")
217
+
218
+ expect(result.root.id).toBe("root")
219
+ expect(ctx.get).toEqual([{ sessionID: "child" }, { sessionID: "root" }])
220
+ expect(ctx.store.lineage.peek("child")).toEqual(result)
221
+ })
222
+
223
+ test("loads session content through the server client", async () => {
224
+ const ctx = setup({ root: session("root") })
225
+
226
+ await ctx.store.sync("root")
227
+
228
+ expect(ctx.get).toEqual([{ sessionID: "root" }])
229
+ expect(ctx.messages).toEqual([{ sessionID: "root", limit: 20, before: undefined }])
230
+ expect(ctx.store.data.message.root).toEqual([])
231
+ })
232
+
233
+ test("loads current session content through the current message API", async () => {
234
+ const requests: unknown[] = []
235
+ const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } }
236
+ const assistant = {
237
+ id: "msg_a_assistant",
238
+ type: "assistant",
239
+ agent: "build",
240
+ model: { id: "model", providerID: "provider" },
241
+ content: [{ type: "text", text: "hi" }],
242
+ time: { created: 2, completed: 3 },
243
+ }
244
+ const client = {
245
+ session: {
246
+ messages: () => {
247
+ throw new Error("legacy message endpoint called")
248
+ },
249
+ },
250
+ } as unknown as NeurocodeClient
251
+ const messageApi = {
252
+ list: async (input: unknown) => {
253
+ requests.push(input)
254
+ return { data: [assistant, user], cursor: { previous: null, next: null } }
255
+ },
256
+ } as unknown as MessageApi
257
+ const store = createServerSession(client, {} as SessionApi, messageApi)
258
+ store.remember(session("root"))
259
+
260
+ await store.sync("root")
261
+
262
+ expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }])
263
+ expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
264
+ })
265
+
266
+ test("extends a current page to include the user for split assistant turns", async () => {
267
+ const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const
268
+ const assistant = (id: string, created: number) => ({
269
+ id,
270
+ type: "assistant" as const,
271
+ agent: "build",
272
+ model: { id: "model", providerID: "provider" },
273
+ content: [{ type: "text" as const, text: id }],
274
+ time: { created, completed: created },
275
+ })
276
+ const assistants = [
277
+ assistant("msg_2_assistant", 2),
278
+ assistant("msg_3_assistant", 3),
279
+ assistant("msg_4_assistant", 4),
280
+ ]
281
+ const pages = [
282
+ { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } },
283
+ { data: [assistants[0], user], cursor: { previous: null, next: null } },
284
+ ]
285
+ const requests: unknown[] = []
286
+ const messageApi = {
287
+ list: async (input: unknown) => {
288
+ requests.push(input)
289
+ return pages.shift()!
290
+ },
291
+ } as unknown as MessageApi
292
+ const store = createServerSession({} as NeurocodeClient, {} as SessionApi, messageApi)
293
+ store.remember(session("root"))
294
+
295
+ await store.sync("root")
296
+
297
+ expect(requests).toEqual([
298
+ { sessionID: "root", limit: 20, order: "desc" },
299
+ { sessionID: "root", limit: 20, cursor: "older" },
300
+ ])
301
+ expect(store.data.message.root.map((message) => message.id)).toEqual([
302
+ user.id,
303
+ ...assistants.map((item) => item.id),
304
+ ])
305
+ expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"])
306
+ })
307
+
308
+ test("indexes V1 messages for the current timeline projection", async () => {
309
+ const user = userMessage("message-1", { sessionID: "root" })
310
+ const assistant = assistantMessage("message-2", user.id, { sessionID: "root" })
311
+ const client = messageClient(
312
+ response([
313
+ { info: user, parts: [textPart(user.id, { sessionID: "root" })] },
314
+ { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] },
315
+ ]),
316
+ )
317
+ const messageApi = {
318
+ list: () => {
319
+ throw new Error("current message endpoint called")
320
+ },
321
+ } as unknown as MessageApi
322
+ const store = createServerSession(client, {} as SessionApi, messageApi, {
323
+ protocol: Promise.resolve("v1"),
324
+ })
325
+ store.remember(session("root"))
326
+
327
+ await store.sync("root")
328
+
329
+ expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
330
+ expect(store.data.session_message.root).toMatchObject([
331
+ { id: user.id, type: "user", text: "text" },
332
+ { id: assistant.id, type: "assistant" },
333
+ ])
334
+
335
+ const next = userMessage("message-3", { sessionID: "root" })
336
+ store.apply({ type: "message.updated", properties: { info: next } })
337
+ expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id])
338
+
339
+ store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } })
340
+ expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id])
341
+ })
342
+
343
+ test("backfills an assistant-only initial page through its user root", async () => {
344
+ const user = userMessage("message-1")
345
+ const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
346
+ const client = rootMessageClient(
347
+ [
348
+ response(
349
+ assistants.map((info) => ({ info, parts: [] })),
350
+ "older",
351
+ ),
352
+ ],
353
+ [singleResponse(user)],
354
+ )
355
+ const store = createServerSession(client)
356
+
357
+ await store.sync("child")
358
+
359
+ expect(client.requests).toEqual([{ sessionID: "child", limit: 20, before: undefined }])
360
+ expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: user.id }])
361
+ expect(store.data.message.child).toEqual([user, ...assistants])
362
+ expect(store.history.more("child")).toBe(true)
363
+ })
364
+
365
+ test("keeps assistant history when its deleted parent cannot be backfilled", async () => {
366
+ const missing = Promise.withResolvers<SingleMessageResponse>()
367
+ const assistant = assistantMessage("message-2", "message-missing")
368
+ const client = rootMessageClient([response([{ info: assistant, parts: [] }], "older")], [missing.promise])
369
+ const store = createServerSession(client)
370
+ const loading = store.sync("child")
371
+ await client.rootRequested(1)
372
+
373
+ missing.reject(new Error("Message not found: message-missing", { cause: { status: 404 } }))
374
+ await loading
375
+
376
+ expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: "message-missing" }])
377
+ expect(store.data.message.child).toEqual([assistant])
378
+ expect(store.history.more("child")).toBe(true)
379
+ })
380
+
381
+ test("drops a cached parent when a forced refresh confirms it was deleted", async () => {
382
+ const missing = Promise.withResolvers<SingleMessageResponse>()
383
+ const parent = userMessage("message-1")
384
+ const part = textPart(parent.id)
385
+ const assistant = assistantMessage("message-2", parent.id)
386
+ const client = rootMessageClient(
387
+ [
388
+ response([
389
+ { info: parent, parts: [part] },
390
+ { info: assistant, parts: [] },
391
+ ]),
392
+ response([{ info: assistant, parts: [] }], "older"),
393
+ ],
394
+ [missing.promise],
395
+ )
396
+ const store = createServerSession(client)
397
+ await store.sync("child")
398
+ const loading = store.sync("child", { force: true })
399
+ await client.rootRequested(1)
400
+
401
+ missing.reject(new Error(`Message not found: ${parent.id}`, { cause: { status: 404 } }))
402
+ await loading
403
+
404
+ expect(store.data.message.child).toEqual([assistant])
405
+ expect(store.data.part[parent.id]).toBeUndefined()
406
+ })
407
+
408
+ test("does not let an optimistic user suppress initial root backfill", async () => {
409
+ const user = userMessage("message-1")
410
+ const part = textPart(user.id)
411
+ const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
412
+ const client = rootMessageClient(
413
+ [
414
+ response(
415
+ assistants.map((info) => ({ info, parts: [] })),
416
+ "older",
417
+ ),
418
+ ],
419
+ [singleResponse(user)],
420
+ )
421
+ const store = createServerSession(client)
422
+ store.optimistic.add({ sessionID: "child", message: user, parts: [part] })
423
+
424
+ await store.sync("child")
425
+ store.optimistic.remove({ sessionID: "child", messageID: user.id })
426
+
427
+ expect(client.requests).toHaveLength(1)
428
+ expect(client.rootRequests).toHaveLength(1)
429
+ expect(store.data.message.child).toEqual([user, ...assistants])
430
+ })
431
+
432
+ test("backfills the parent of fetched assistants when another user is cached", async () => {
433
+ const unrelated = userMessage("message-0", { time: { created: 0 } })
434
+ const user = userMessage("message-1")
435
+ const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
436
+ const client = rootMessageClient(
437
+ [
438
+ response([{ info: unrelated, parts: [] }]),
439
+ response(
440
+ assistants.map((info) => ({ info, parts: [] })),
441
+ "older",
442
+ ),
443
+ ],
444
+ [singleResponse(user)],
445
+ )
446
+ const store = createServerSession(client)
447
+ await store.sync("child")
448
+
449
+ await store.sync("child", { force: true })
450
+
451
+ expect(client.requests).toHaveLength(2)
452
+ expect(client.rootRequests).toHaveLength(1)
453
+ expect(store.data.message.child).toEqual([unrelated, user, ...assistants])
454
+ })
455
+
456
+ test("preserves cached history between an injected parent and the page boundary", async () => {
457
+ const user = userMessage("message-1")
458
+ const cached = userMessage("message-3", { time: { created: 3 } })
459
+ const assistant = assistantMessage("message-4", user.id)
460
+ const client = rootMessageClient(
461
+ [response([{ info: cached, parts: [] }]), response([{ info: assistant, parts: [] }], "older")],
462
+ [singleResponse(user)],
463
+ )
464
+ const store = createServerSession(client)
465
+ await store.sync("child")
466
+
467
+ await store.sync("child", { force: true })
468
+
469
+ expect(store.data.message.child).toEqual([user, cached, assistant])
470
+ })
471
+
472
+ test("refreshes a cached parent omitted by an assistant-only replacement page", async () => {
473
+ const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
474
+ const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
475
+ const stalePart = textPart(stale.id, { text: "stale" })
476
+ const freshPart = { ...stalePart, text: "fresh" }
477
+ const assistant = assistantMessage("message-2", stale.id)
478
+ const client = rootMessageClient(
479
+ [response([{ info: stale, parts: [stalePart] }]), response([{ info: assistant, parts: [] }], "older")],
480
+ [singleResponse(fresh, [freshPart])],
481
+ )
482
+ const store = createServerSession(client)
483
+ await store.sync("child")
484
+
485
+ await store.sync("child", { force: true })
486
+
487
+ expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
488
+ expect(store.data.message.child).toEqual([fresh, assistant])
489
+ expect(store.data.part[stale.id]).toEqual([freshPart])
490
+ })
491
+
492
+ test("refreshes a confirmed optimistic parent while preserving pending parts", async () => {
493
+ const stale = userMessage("message-1", { summary: { title: "stale", diffs: [] } })
494
+ const fresh = { ...stale, summary: { title: "fresh", diffs: [] } }
495
+ const confirmed = textPart(stale.id, { id: "confirmed", text: "stale" })
496
+ const refreshed = { ...confirmed, text: "fresh" }
497
+ const pending = textPart(stale.id, { id: "pending", text: "pending" })
498
+ const assistant = assistantMessage("message-2", stale.id)
499
+ const client = rootMessageClient(
500
+ [response([{ info: stale, parts: [confirmed] }]), response([{ info: assistant, parts: [] }], "older")],
501
+ [singleResponse(fresh, [refreshed])],
502
+ )
503
+ const store = createServerSession(client)
504
+ store.optimistic.add({ sessionID: "child", message: stale, parts: [confirmed, pending] })
505
+ await store.sync("child")
506
+
507
+ await store.sync("child", { force: true })
508
+
509
+ expect(client.rootRequests).toEqual([{ sessionID: "child", messageID: stale.id }])
510
+ expect(store.data.message.child).toEqual([fresh, assistant])
511
+ expect(store.data.part[stale.id]).toEqual([refreshed, pending])
512
+ })
513
+
514
+ test("uses a parent received by SSE during the replacement load", async () => {
515
+ const pending = deferredResponse()
516
+ const user = userMessage("message-1")
517
+ const assistant = assistantMessage("message-2", user.id)
518
+ const client = rootMessageClient([pending.promise], [])
519
+ const store = createServerSession(client)
520
+ const loading = store.sync("child")
521
+
522
+ store.apply({ type: "message.updated", properties: { info: user } })
523
+ pending.resolve(response([{ info: assistant, parts: [] }], "older"))
524
+ await loading
525
+
526
+ expect(client.rootRequests).toEqual([])
527
+ expect(store.data.message.child).toEqual([user, assistant])
528
+ })
529
+
530
+ test("uses a successful retry over events received by a failed backfill attempt", async () => {
531
+ const failed = deferredResponse()
532
+ const user = userMessage("message-1")
533
+ const live = { ...user, agent: "stale" }
534
+ const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)]
535
+ const client = rootMessageClient(
536
+ [
537
+ response(
538
+ assistants.map((info) => ({ info, parts: [] })),
539
+ "older",
540
+ ),
541
+ ],
542
+ [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
543
+ )
544
+ const store = createServerSession(client, { retry: retryImmediately })
545
+ const loading = store.sync("child")
546
+ await client.rootRequested(1)
547
+
548
+ store.apply({ type: "message.updated", properties: { info: live } })
549
+ failed.reject(new Error("retry"))
550
+ await loading
551
+
552
+ expect(client.requests).toHaveLength(1)
553
+ expect(client.rootRequests).toHaveLength(2)
554
+ expect(store.data.message.child).toEqual([user, ...assistants])
555
+ })
556
+
557
+ test("preserves newer-page events across a failed parent retry", async () => {
558
+ const failed = deferredResponse()
559
+ const user = userMessage("message-1")
560
+ const assistant = assistantMessage("message-2", user.id)
561
+ const live = { ...assistant, cost: 1 }
562
+ const client = rootMessageClient(
563
+ [response([{ info: assistant, parts: [] }], "older")],
564
+ [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
565
+ )
566
+ const store = createServerSession(client, { retry: retryImmediately })
567
+ const loading = store.sync("child")
568
+ await client.rootRequested(1)
569
+
570
+ store.apply({ type: "message.updated", properties: { info: live } })
571
+ failed.reject(new Error("retry"))
572
+ await loading
573
+
574
+ expect(store.data.message.child).toEqual([user, live])
575
+ })
576
+
577
+ test("preserves unrelated message events across a failed parent retry", async () => {
578
+ const failed = deferredResponse()
579
+ const user = userMessage("message-1")
580
+ const assistant = assistantMessage("message-2", user.id)
581
+ const live = userMessage("message-4", { time: { created: 4 } })
582
+ const client = rootMessageClient(
583
+ [response([{ info: assistant, parts: [] }], "older")],
584
+ [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
585
+ )
586
+ const store = createServerSession(client, { retry: retryImmediately })
587
+ const loading = store.sync("child")
588
+ await client.rootRequested(1)
589
+
590
+ store.apply({ type: "message.updated", properties: { info: live } })
591
+ failed.reject(new Error("retry"))
592
+ await loading
593
+
594
+ expect(store.data.message.child).toEqual([user, assistant, live])
595
+ })
596
+
597
+ test("preserves newer-page part events across a failed parent retry", async () => {
598
+ const failed = deferredResponse()
599
+ const user = userMessage("message-1")
600
+ const assistant = assistantMessage("message-2", user.id)
601
+ const stale = textPart(assistant.id, { text: "stale" })
602
+ const live = { ...stale, text: "live" }
603
+ const client = rootMessageClient(
604
+ [response([{ info: assistant, parts: [stale] }], "older")],
605
+ [failed.promise.then((result) => ({ data: result.data[0]! })), singleResponse(user)],
606
+ )
607
+ const store = createServerSession(client, { retry: retryImmediately })
608
+ const loading = store.sync("child")
609
+ await client.rootRequested(1)
610
+
611
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
612
+ failed.reject(new Error("retry"))
613
+ await loading
614
+
615
+ expect(store.data.part[assistant.id]).toEqual([live])
616
+ })
617
+
618
+ test("merges live events into the initial page", async () => {
619
+ const pending = deferredResponse()
620
+ const user = userMessage("message-1")
621
+ const live = userMessage("message-2", { time: { created: 2 } })
622
+ const livePart = textPart(live.id, { text: "live" })
623
+ const store = createServerSession(messageClient(pending.promise))
624
+ const loading = store.sync("child")
625
+
626
+ store.apply({ type: "message.updated", properties: { info: live } })
627
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } })
628
+ pending.resolve(response([{ info: user, parts: [] }]))
629
+ await loading
630
+
631
+ expect(store.data.message.child).toEqual([user, live])
632
+ expect(store.data.part[live.id]).toEqual([livePart])
633
+ })
634
+
635
+ test("preserves same-ID live updates over the initial page", async () => {
636
+ const pending = deferredResponse()
637
+ const fetched = userMessage("message")
638
+ const fetchedPart = textPart(fetched.id, { text: "fetched" })
639
+ const live = { ...fetched, time: { created: 2 } }
640
+ const livePart = { ...fetchedPart, text: "live" }
641
+ const store = createServerSession(messageClient(pending.promise))
642
+ const loading = store.sync("child")
643
+
644
+ store.apply({ type: "message.updated", properties: { info: live } })
645
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: livePart, time: 2 } })
646
+ pending.resolve(response([{ info: fetched, parts: [fetchedPart] }]))
647
+ await loading
648
+
649
+ expect(store.data.message.child).toEqual([live])
650
+ expect(store.data.part[live.id]).toEqual([livePart])
651
+ })
652
+
653
+ test("preserves removals received during the initial load", async () => {
654
+ const pending = deferredResponse()
655
+ const removed = userMessage("message-1")
656
+ const kept = { ...removed, id: "message-2" }
657
+ const part = textPart(kept.id, { text: "removed" })
658
+ const store = createServerSession(messageClient(pending.promise))
659
+ const loading = store.sync("child")
660
+
661
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: removed.id } })
662
+ store.apply({
663
+ type: "message.part.removed",
664
+ properties: { sessionID: "child", messageID: kept.id, partID: part.id },
665
+ })
666
+ pending.resolve(
667
+ response([
668
+ { info: removed, parts: [] },
669
+ { info: kept, parts: [part] },
670
+ ]),
671
+ )
672
+ await loading
673
+
674
+ expect(store.data.message.child).toEqual([kept])
675
+ expect(store.data.part[kept.id]).toBeUndefined()
676
+ })
677
+
678
+ test("keeps removal tracking isolated across load generations", async () => {
679
+ const firstResponse = deferredResponse()
680
+ const secondResponse = deferredResponse()
681
+ const message = userMessage("message")
682
+ const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise))
683
+ const first = store.sync("child")
684
+
685
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
686
+ store.apply({
687
+ type: "session.deleted",
688
+ properties: { sessionID: "child", info: session("child", "root") },
689
+ })
690
+ const second = store.sync("child")
691
+
692
+ firstResponse.resolve(response())
693
+ await first
694
+ secondResponse.resolve(response([{ info: message, parts: [] }]))
695
+ await second
696
+
697
+ expect(store.data.message.child).toEqual([message])
698
+ })
699
+
700
+ test("tracks removals in a replacement load generation", async () => {
701
+ const firstResponse = deferredResponse()
702
+ const secondResponse = deferredResponse()
703
+ const message = userMessage("message")
704
+ const store = createServerSession(messageClient(firstResponse.promise, secondResponse.promise))
705
+ const first = store.sync("child")
706
+ store.apply({
707
+ type: "session.deleted",
708
+ properties: { sessionID: "child", info: session("child", "root") },
709
+ })
710
+ const second = store.sync("child")
711
+
712
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
713
+ firstResponse.resolve(response())
714
+ await first
715
+ secondResponse.resolve(response([{ info: message, parts: [] }]))
716
+ await second
717
+
718
+ expect(store.data.message.child).toEqual([])
719
+ })
720
+
721
+ test("preserves remove then re-add when a refresh omits the message", async () => {
722
+ const pending = deferredResponse()
723
+ const message = userMessage("message")
724
+ const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise))
725
+ await store.sync("child")
726
+ const refreshing = store.sync("child", { force: true })
727
+
728
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
729
+ store.apply({ type: "message.updated", properties: { info: message } })
730
+ pending.resolve(response())
731
+ await refreshing
732
+
733
+ expect(store.data.message.child).toEqual([message])
734
+ })
735
+
736
+ test("preserves a re-added message without restoring removed parts", async () => {
737
+ const pending = deferredResponse()
738
+ const message = userMessage("message")
739
+ const part = textPart(message.id, { text: "stale" })
740
+ const store = createServerSession(messageClient(response([{ info: message, parts: [] }]), pending.promise))
741
+ await store.sync("child")
742
+ const refreshing = store.sync("child", { force: true })
743
+
744
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
745
+ store.apply({ type: "message.updated", properties: { info: message } })
746
+ pending.resolve(response([{ info: message, parts: [part] }]))
747
+ await refreshing
748
+
749
+ expect(store.data.message.child).toEqual([message])
750
+ expect(store.data.part[message.id]).toBeUndefined()
751
+ })
752
+
753
+ test("preserves optimistic parts re-added after removal during a refresh", async () => {
754
+ const pending = deferredResponse()
755
+ const message = userMessage("message")
756
+ const stale = textPart(message.id, { id: "stale", text: "stale" })
757
+ const part = textPart(message.id, { id: "optimistic", text: "optimistic" })
758
+ const store = createServerSession(
759
+ messageClient(response([{ info: message, parts: [] }]), pending.promise, response()),
760
+ )
761
+ await store.sync("child")
762
+ const refreshing = store.sync("child", { force: true })
763
+
764
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
765
+ store.optimistic.add({ sessionID: "child", message, parts: [part] })
766
+ pending.resolve(response([{ info: message, parts: [stale] }]))
767
+ await refreshing
768
+
769
+ expect(store.data.message.child).toEqual([message])
770
+ expect(store.data.part[message.id]).toEqual([part])
771
+
772
+ await store.sync("child", { force: true })
773
+ expect(store.data.message.child).toEqual([message])
774
+ expect(store.data.part[message.id]).toEqual([part])
775
+ })
776
+
777
+ test("drops stale event content omitted by a complete initial page", async () => {
778
+ const stale = userMessage("stale")
779
+ const store = createServerSession(messageClient(response()))
780
+ store.apply({ type: "message.updated", properties: { info: stale } })
781
+
782
+ await store.sync("child")
783
+
784
+ expect(store.data.message.child).toEqual([])
785
+ })
786
+
787
+ test("preserves event content outside an incomplete initial page", async () => {
788
+ const live = userMessage("message-1")
789
+ const fetched = userMessage("message-2", { time: { created: 2 } })
790
+ const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }], "older")))
791
+ store.apply({ type: "message.updated", properties: { info: live } })
792
+
793
+ await store.sync("child")
794
+
795
+ expect(store.data.message.child).toEqual([live, fetched])
796
+ })
797
+
798
+ test("does not restore removed optimistic content on refresh", async () => {
799
+ const message = userMessage("message")
800
+ const part = textPart(message.id, { text: "removed" })
801
+ const kept = { ...message, id: "kept" }
802
+ const keptPart = { ...part, id: "kept-part", messageID: kept.id }
803
+ const store = createServerSession(messageClient(response([{ info: kept, parts: [] }])))
804
+ store.optimistic.add({ sessionID: "child", message, parts: [part] })
805
+ store.optimistic.add({ sessionID: "child", message: kept, parts: [keptPart] })
806
+
807
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
808
+ store.apply({
809
+ type: "message.part.removed",
810
+ properties: { sessionID: "child", messageID: kept.id, partID: keptPart.id },
811
+ })
812
+ await store.sync("child", { force: true })
813
+
814
+ expect(store.data.message.child).toEqual([kept])
815
+ expect(store.data.part[message.id]).toBeUndefined()
816
+ expect(store.data.part[kept.id]).toBeUndefined()
817
+ })
818
+
819
+ test("replaces confirmed optimistic content with the initial page", async () => {
820
+ const optimistic = userMessage("message")
821
+ const fetched = { ...optimistic, time: { created: 2 } }
822
+ const store = createServerSession(messageClient(response([{ info: fetched, parts: [] }])))
823
+ store.optimistic.add({ sessionID: "child", message: optimistic, parts: [] })
824
+
825
+ await store.sync("child")
826
+
827
+ expect(store.data.message.child).toEqual([fetched])
828
+ })
829
+
830
+ test("replaces a confirmed optimistic part with fetched content", async () => {
831
+ const pending = deferredResponse()
832
+ const message = userMessage("message")
833
+ const optimistic = textPart(message.id, { text: "optimistic" })
834
+ const fetched = { ...optimistic, text: "fetched" }
835
+ const store = createServerSession(messageClient(pending.promise))
836
+ const loading = store.sync("child")
837
+
838
+ store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
839
+ pending.resolve(response([{ info: message, parts: [fetched] }]))
840
+ await loading
841
+
842
+ expect(store.data.part[message.id]).toEqual([fetched])
843
+ })
844
+
845
+ test("rolls back only unconfirmed optimistic parts", async () => {
846
+ const pending = deferredResponse()
847
+ const message = userMessage("message")
848
+ const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
849
+ const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
850
+ const store = createServerSession(messageClient(pending.promise))
851
+ const loading = store.sync("child")
852
+ store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
853
+
854
+ pending.resolve(response([{ info: message, parts: [confirmed] }]))
855
+ await loading
856
+ store.optimistic.remove({ sessionID: "child", messageID: message.id })
857
+
858
+ expect(store.data.message.child).toEqual([message])
859
+ expect(store.data.part[message.id]).toEqual([confirmed])
860
+ })
861
+
862
+ test("updates confirmed optimistic parts from later pages", async () => {
863
+ const message = userMessage("message")
864
+ const confirmed = textPart(message.id, { id: "confirmed", text: "first" })
865
+ const updated = { ...confirmed, text: "updated" }
866
+ const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
867
+ const store = createServerSession(
868
+ messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [updated] }])),
869
+ )
870
+ store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
871
+ await store.sync("child")
872
+
873
+ await store.sync("child", { force: true })
874
+ store.optimistic.remove({ sessionID: "child", messageID: message.id })
875
+
876
+ expect(store.data.part[message.id]).toEqual([updated])
877
+ })
878
+
879
+ test("does not restore a confirmed optimistic part after its removal event", async () => {
880
+ const message = userMessage("message")
881
+ const confirmed = textPart(message.id, { id: "confirmed", text: "confirmed" })
882
+ const pendingPart = textPart(message.id, { id: "pending", text: "pending" })
883
+ const store = createServerSession(
884
+ messageClient(response([{ info: message, parts: [confirmed] }]), response([{ info: message, parts: [] }])),
885
+ )
886
+ store.optimistic.add({ sessionID: "child", message, parts: [confirmed, pendingPart] })
887
+ await store.sync("child")
888
+ store.apply({
889
+ type: "message.part.removed",
890
+ properties: { sessionID: "child", messageID: message.id, partID: confirmed.id },
891
+ })
892
+
893
+ await store.sync("child", { force: true })
894
+
895
+ expect(store.data.part[message.id]).toEqual([pendingPart])
896
+ })
897
+
898
+ test("clears delta buffers when removing optimistic content", () => {
899
+ const message = userMessage("message")
900
+ const part = textPart(message.id, { text: "optimistic" })
901
+ const store = setup({ child: session("child") }).store
902
+ store.optimistic.add({ sessionID: "child", message, parts: [part] })
903
+ store.apply({
904
+ type: "message.part.delta",
905
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
906
+ })
907
+
908
+ store.optimistic.remove({ sessionID: "child", messageID: message.id })
909
+
910
+ expect(store.data.part[message.id]).toBeUndefined()
911
+ expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
912
+ })
913
+
914
+ test("does not remove content confirmed by a message event", () => {
915
+ const message = userMessage("message")
916
+ const part = textPart(message.id)
917
+ const store = setup({ child: session("child") }).store
918
+ store.optimistic.add({ sessionID: "child", message, parts: [part] })
919
+ store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
920
+
921
+ store.optimistic.remove({ sessionID: "child", messageID: message.id })
922
+
923
+ expect(store.data.message.child).toEqual([message])
924
+ expect(store.data.part[message.id]).toBeUndefined()
925
+ })
926
+
927
+ test("does not remove parts confirmed by part events", () => {
928
+ const message = userMessage("message")
929
+ const part = textPart(message.id)
930
+ const store = setup({ child: session("child") }).store
931
+ store.optimistic.add({ sessionID: "child", message, parts: [part] })
932
+ store.apply({ type: "message.updated", properties: { sessionID: "child", info: message } })
933
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
934
+
935
+ store.optimistic.remove({ sessionID: "child", messageID: message.id })
936
+
937
+ expect(store.data.message.child).toEqual([message])
938
+ expect(store.data.part[message.id]).toEqual([part])
939
+ })
940
+
941
+ test("treats a part event as confirmation when it precedes the message event", () => {
942
+ const message = userMessage("message")
943
+ const part = textPart(message.id)
944
+ const store = setup({ child: session("child") }).store
945
+ store.optimistic.add({ sessionID: "child", message, parts: [part] })
946
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
947
+
948
+ store.optimistic.remove({ sessionID: "child", messageID: message.id })
949
+
950
+ expect(store.data.message.child).toEqual([message])
951
+ expect(store.data.part[message.id]).toEqual([part])
952
+ })
953
+
954
+ test("clears stale parts when the initial page has none", async () => {
955
+ const pending = deferredResponse()
956
+ const message = userMessage("message")
957
+ const part = textPart(message.id, { text: "stale" })
958
+ const store = createServerSession(messageClient(pending.promise))
959
+ store.apply({ type: "message.updated", properties: { info: message } })
960
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } })
961
+ const loading = store.sync("child")
962
+
963
+ pending.resolve(response([{ info: message, parts: [] }]))
964
+ await loading
965
+
966
+ expect(store.data.part[message.id]).toBeUndefined()
967
+ })
968
+
969
+ test("clears delta buffers for parts omitted by the initial page", async () => {
970
+ const pending = deferredResponse()
971
+ const message = userMessage("message")
972
+ const kept = textPart(message.id, { id: "part-1", text: "kept" })
973
+ const removed: Part = { ...kept, id: "part-2", text: "removed" }
974
+ const store = createServerSession(messageClient(pending.promise))
975
+ store.apply({ type: "message.updated", properties: { info: message } })
976
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: kept, time: 1 } })
977
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: removed, time: 1 } })
978
+ store.apply({
979
+ type: "message.part.delta",
980
+ properties: { sessionID: "child", messageID: message.id, partID: removed.id, field: "text", delta: " delta" },
981
+ })
982
+ const loading = store.sync("child")
983
+
984
+ pending.resolve(response([{ info: message, parts: [kept] }]))
985
+ await loading
986
+
987
+ expect(store.data.part[message.id]).toEqual([kept])
988
+ expect(store.data.part_text_accum_delta[removed.id]).toBeUndefined()
989
+ })
990
+
991
+ test("clears a stale delta buffer when a refresh replaces its part", async () => {
992
+ const message = userMessage("message")
993
+ const stale = textPart(message.id, { text: "stale" })
994
+ const fetched = { ...stale, text: "fetched" }
995
+ const store = createServerSession(
996
+ messageClient(response([{ info: message, parts: [stale] }]), response([{ info: message, parts: [fetched] }])),
997
+ )
998
+ await store.sync("child")
999
+ store.apply({
1000
+ type: "message.part.delta",
1001
+ properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
1002
+ })
1003
+
1004
+ await store.sync("child", { force: true })
1005
+
1006
+ expect(store.data.part[message.id]).toEqual([fetched])
1007
+ expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
1008
+ })
1009
+
1010
+ test("preserves a non-durable delta received before refresh", async () => {
1011
+ const message = userMessage("message")
1012
+ const part = textPart(message.id, { text: "stale" })
1013
+ const store = createServerSession(
1014
+ messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [{ ...part }] }])),
1015
+ )
1016
+ await store.sync("child")
1017
+ store.apply({
1018
+ type: "message.part.delta",
1019
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
1020
+ })
1021
+
1022
+ await store.sync("child", { force: true })
1023
+
1024
+ expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }])
1025
+ expect(store.data.part_text_accum_delta[part.id]).toBe("stale delta")
1026
+ })
1027
+
1028
+ test("accepts fetched text that intentionally replaces an accumulated prefix", async () => {
1029
+ const message = userMessage("message")
1030
+ const part = textPart(message.id, { text: "abc" })
1031
+ const fetched = { ...part, text: "ab" }
1032
+ const store = createServerSession(
1033
+ messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
1034
+ )
1035
+ await store.sync("child")
1036
+ store.apply({
1037
+ type: "message.part.delta",
1038
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "def" },
1039
+ })
1040
+
1041
+ await store.sync("child", { force: true })
1042
+
1043
+ expect(store.data.part[message.id]).toEqual([fetched])
1044
+ expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
1045
+ })
1046
+
1047
+ test("preserves an unpersisted delta suffix after partial server catch-up", async () => {
1048
+ const message = userMessage("message")
1049
+ const part = textPart(message.id, { text: "a" })
1050
+ const fetched = { ...part, text: "ab" }
1051
+ const store = createServerSession(
1052
+ messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
1053
+ )
1054
+ await store.sync("child")
1055
+ store.apply({
1056
+ type: "message.part.delta",
1057
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "bc" },
1058
+ })
1059
+
1060
+ await store.sync("child", { force: true })
1061
+
1062
+ expect(store.data.part[message.id]).toEqual([{ ...part, text: "abc" }])
1063
+ expect(store.data.part_text_accum_delta[part.id]).toBe("abc")
1064
+ })
1065
+
1066
+ test("clears delta state after exact server catch-up", async () => {
1067
+ const message = userMessage("message")
1068
+ const part = textPart(message.id, { text: "a" })
1069
+ const fetched = { ...part, text: "ab" }
1070
+ const store = createServerSession(
1071
+ messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [fetched] }])),
1072
+ )
1073
+ await store.sync("child")
1074
+ store.apply({
1075
+ type: "message.part.delta",
1076
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: "b" },
1077
+ })
1078
+
1079
+ await store.sync("child", { force: true })
1080
+
1081
+ expect(store.data.part[message.id]).toEqual([fetched])
1082
+ expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
1083
+ })
1084
+
1085
+ test("uses the successful retry response over events from a failed attempt", async () => {
1086
+ const failed = Promise.withResolvers<MessageResponse>()
1087
+ const retried = Promise.withResolvers<MessageResponse>()
1088
+ const message = userMessage("message")
1089
+ const stale = textPart(message.id, { text: "stale" })
1090
+ const intermediate = { ...stale, text: "intermediate" }
1091
+ const fetched = { ...stale, text: "fetched" }
1092
+ const client = messageClient(failed.promise, retried.promise)
1093
+ const store = createServerSession(client, { retry: retryImmediately })
1094
+ store.apply({ type: "message.updated", properties: { info: message } })
1095
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: stale, time: 1 } })
1096
+ const loading = store.sync("child")
1097
+
1098
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: intermediate, time: 2 } })
1099
+ failed.reject(new Error("failed to fetch"))
1100
+ await client.requested(2)
1101
+ retried.resolve(response([{ info: message, parts: [fetched] }]))
1102
+ await loading
1103
+
1104
+ expect(store.data.part[message.id]).toEqual([fetched])
1105
+ })
1106
+
1107
+ test("preserves non-durable deltas across message retries", async () => {
1108
+ const failed = Promise.withResolvers<MessageResponse>()
1109
+ const retried = Promise.withResolvers<MessageResponse>()
1110
+ const message = userMessage("message")
1111
+ const part = textPart(message.id, { text: "stale" })
1112
+ const client = messageClient(failed.promise, retried.promise)
1113
+ const store = createServerSession(client, { retry: retryImmediately })
1114
+ store.apply({ type: "message.updated", properties: { info: message } })
1115
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 1 } })
1116
+ const loading = store.sync("child")
1117
+
1118
+ store.apply({
1119
+ type: "message.part.delta",
1120
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
1121
+ })
1122
+ failed.reject(new Error("failed to fetch"))
1123
+ await client.requested(2)
1124
+ retried.resolve(response([{ info: message, parts: [part] }]))
1125
+ await loading
1126
+
1127
+ expect(store.data.part[message.id]).toEqual([{ ...part, text: "stale delta" }])
1128
+ })
1129
+
1130
+ test("preserves part removals across message retries", async () => {
1131
+ const failed = Promise.withResolvers<MessageResponse>()
1132
+ const retried = Promise.withResolvers<MessageResponse>()
1133
+ const message = userMessage("message")
1134
+ const part = textPart(message.id)
1135
+ const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
1136
+ const store = createServerSession(client, { retry: retryImmediately })
1137
+ await store.sync("child")
1138
+ const loading = store.sync("child", { force: true })
1139
+
1140
+ store.apply({
1141
+ type: "message.part.removed",
1142
+ properties: { sessionID: "child", messageID: message.id, partID: part.id },
1143
+ })
1144
+ failed.reject(new Error("failed to fetch"))
1145
+ await client.requested(3)
1146
+ retried.resolve(response([{ info: message, parts: [part] }]))
1147
+ await loading
1148
+
1149
+ expect(store.data.part[message.id]).toBeUndefined()
1150
+ })
1151
+
1152
+ test("preserves message removals across message retries", async () => {
1153
+ const failed = Promise.withResolvers<MessageResponse>()
1154
+ const retried = Promise.withResolvers<MessageResponse>()
1155
+ const message = userMessage("message")
1156
+ const part = textPart(message.id)
1157
+ const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
1158
+ const store = createServerSession(client, { retry: retryImmediately })
1159
+ await store.sync("child")
1160
+ const loading = store.sync("child", { force: true })
1161
+
1162
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
1163
+ failed.reject(new Error("failed to fetch"))
1164
+ await client.requested(3)
1165
+ retried.resolve(response([{ info: message, parts: [part] }]))
1166
+ await loading
1167
+
1168
+ expect(store.data.message.child).toEqual([])
1169
+ expect(store.data.part[message.id]).toBeUndefined()
1170
+ })
1171
+
1172
+ test("preserves optimistic re-adds across message retries", async () => {
1173
+ const failed = Promise.withResolvers<MessageResponse>()
1174
+ const retried = Promise.withResolvers<MessageResponse>()
1175
+ const message = userMessage("message")
1176
+ const stale = textPart(message.id, { id: "stale", text: "stale" })
1177
+ const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
1178
+ const client = messageClient(response([{ info: message, parts: [stale] }]), failed.promise, retried.promise)
1179
+ const store = createServerSession(client, { retry: retryImmediately })
1180
+ await store.sync("child")
1181
+ const loading = store.sync("child", { force: true })
1182
+
1183
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
1184
+ store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
1185
+ failed.reject(new Error("failed to fetch"))
1186
+ await client.requested(3)
1187
+ retried.resolve(response([{ info: message, parts: [stale] }]))
1188
+ await loading
1189
+
1190
+ expect(store.data.message.child).toEqual([message])
1191
+ expect(store.data.part[message.id]).toEqual([optimistic])
1192
+ })
1193
+
1194
+ test("accepts part omission from a successful retry after an earlier delta", async () => {
1195
+ const failed = Promise.withResolvers<MessageResponse>()
1196
+ const retried = Promise.withResolvers<MessageResponse>()
1197
+ const message = userMessage("message")
1198
+ const part = textPart(message.id)
1199
+ const client = messageClient(response([{ info: message, parts: [part] }]), failed.promise, retried.promise)
1200
+ const store = createServerSession(client, { retry: retryImmediately })
1201
+ await store.sync("child")
1202
+ const loading = store.sync("child", { force: true })
1203
+
1204
+ store.apply({
1205
+ type: "message.part.delta",
1206
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
1207
+ })
1208
+ failed.reject(new Error("failed to fetch"))
1209
+ await client.requested(3)
1210
+ retried.resolve(response([{ info: message, parts: [] }]))
1211
+ await loading
1212
+
1213
+ expect(store.data.part[message.id]).toBeUndefined()
1214
+ expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
1215
+ })
1216
+
1217
+ test("clears load-owned orphan parts when all retries fail", async () => {
1218
+ const first = Promise.withResolvers<MessageResponse>()
1219
+ const second = Promise.withResolvers<MessageResponse>()
1220
+ const third = Promise.withResolvers<MessageResponse>()
1221
+ const message = userMessage("message")
1222
+ const part = textPart(message.id)
1223
+ const client = messageClient(first.promise, second.promise, third.promise)
1224
+ const store = createServerSession(client, { retry: retryImmediately })
1225
+ const loading = store.sync("child").catch((error) => error)
1226
+
1227
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
1228
+ first.reject(new Error("failed to fetch"))
1229
+ await client.requested(2)
1230
+ second.reject(new Error("failed to fetch"))
1231
+ await client.requested(3)
1232
+ third.reject(new Error("failed to fetch"))
1233
+ await loading
1234
+
1235
+ expect(store.data.part[message.id]).toBeUndefined()
1236
+ })
1237
+
1238
+ test("preserves live updates during a forced refresh", async () => {
1239
+ const pending = deferredResponse()
1240
+ const stale = userMessage("message")
1241
+ const stalePart = textPart(stale.id, { text: "stale" })
1242
+ const store = createServerSession(messageClient(response([{ info: stale, parts: [stalePart] }]), pending.promise))
1243
+ await store.sync("child")
1244
+ const refreshing = store.sync("child", { force: true })
1245
+ const live = { ...stale, time: { created: 2 } }
1246
+
1247
+ store.apply({ type: "message.updated", properties: { info: live } })
1248
+ store.apply({
1249
+ type: "message.part.delta",
1250
+ properties: { sessionID: "child", messageID: stale.id, partID: stalePart.id, field: "text", delta: " live" },
1251
+ })
1252
+ pending.resolve(response([{ info: stale, parts: [stalePart] }]))
1253
+ await refreshing
1254
+
1255
+ expect(store.data.message.child).toEqual([live])
1256
+ expect(store.data.part[stale.id]).toEqual([{ ...stalePart, text: "stale live" }])
1257
+ })
1258
+
1259
+ test("keeps fetched message metadata when only a part changes", async () => {
1260
+ const pending = deferredResponse()
1261
+ const stale = userMessage("message")
1262
+ const fetched = { ...stale, time: { created: 2 } }
1263
+ const part = textPart(stale.id, { text: "stale" })
1264
+ const store = createServerSession(messageClient(response([{ info: stale, parts: [part] }]), pending.promise))
1265
+ await store.sync("child")
1266
+ const refreshing = store.sync("child", { force: true })
1267
+
1268
+ store.apply({
1269
+ type: "message.part.delta",
1270
+ properties: { sessionID: "child", messageID: stale.id, partID: part.id, field: "text", delta: " live" },
1271
+ })
1272
+ pending.resolve(response([{ info: fetched, parts: [part] }]))
1273
+ await refreshing
1274
+
1275
+ expect(store.data.message.child).toEqual([fetched])
1276
+ expect(store.data.part[stale.id]).toEqual([{ ...part, text: "stale live" }])
1277
+ })
1278
+
1279
+ test("preserves a part update when a forced refresh omits its message", async () => {
1280
+ const pending = deferredResponse()
1281
+ const message = userMessage("message")
1282
+ const stale = textPart(message.id, { text: "stale" })
1283
+ const live = { ...stale, text: "live" }
1284
+ const store = createServerSession(messageClient(response([{ info: message, parts: [stale] }]), pending.promise))
1285
+ await store.sync("child")
1286
+ const refreshing = store.sync("child", { force: true })
1287
+
1288
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
1289
+ pending.resolve(response())
1290
+ await refreshing
1291
+
1292
+ expect(store.data.message.child).toEqual([message])
1293
+ expect(store.data.part[message.id]).toEqual([live])
1294
+ })
1295
+
1296
+ test("ignores a late part update after its message is removed", async () => {
1297
+ const pending = deferredResponse()
1298
+ const message = userMessage("message")
1299
+ const part = textPart(message.id)
1300
+ const store = createServerSession(messageClient(pending.promise))
1301
+ const loading = store.sync("child")
1302
+
1303
+ store.apply({ type: "message.updated", properties: { info: message } })
1304
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
1305
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
1306
+ pending.resolve(response([{ info: message, parts: [part] }]))
1307
+ await loading
1308
+
1309
+ expect(store.data.message.child).toEqual([])
1310
+ expect(store.data.part[message.id]).toBeUndefined()
1311
+ })
1312
+
1313
+ test("ignores a late part update after a completed message removal", () => {
1314
+ const message = userMessage("message")
1315
+ const part = textPart(message.id)
1316
+ const store = setup({ child: session("child") }).store
1317
+ store.apply({ type: "message.updated", properties: { info: message } })
1318
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
1319
+
1320
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
1321
+
1322
+ expect(store.data.part[message.id]).toBeUndefined()
1323
+ })
1324
+
1325
+ test("does not restore a completed message removal from a stale refresh", async () => {
1326
+ const message = userMessage("message")
1327
+ const part = textPart(message.id)
1328
+ const store = createServerSession(
1329
+ messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])),
1330
+ )
1331
+ await store.sync("child")
1332
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: message.id } })
1333
+
1334
+ await store.sync("child", { force: true })
1335
+
1336
+ expect(store.data.message.child).toEqual([])
1337
+ expect(store.data.part[message.id]).toBeUndefined()
1338
+ })
1339
+
1340
+ test("does not restore a completed part removal from a stale refresh", async () => {
1341
+ const message = userMessage("message")
1342
+ const part = textPart(message.id)
1343
+ const store = createServerSession(
1344
+ messageClient(response([{ info: message, parts: [part] }]), response([{ info: message, parts: [part] }])),
1345
+ )
1346
+ await store.sync("child")
1347
+ store.apply({
1348
+ type: "message.part.removed",
1349
+ properties: { sessionID: "child", messageID: message.id, partID: part.id },
1350
+ })
1351
+
1352
+ await store.sync("child", { force: true })
1353
+
1354
+ expect(store.data.part[message.id]).toBeUndefined()
1355
+ })
1356
+
1357
+ test("does not cache skipped optimistic parts", () => {
1358
+ const message = userMessage("message")
1359
+ const part = { id: "part", sessionID: "child", messageID: message.id, type: "step-start" as const }
1360
+ const store = setup({ child: session("child") }).store
1361
+
1362
+ store.optimistic.add({ sessionID: "child", message, parts: [part] })
1363
+
1364
+ expect(store.data.part[message.id]).toEqual([])
1365
+ })
1366
+
1367
+ test("clears stale delta buffers when replacing optimistic parts", () => {
1368
+ const message = userMessage("message")
1369
+ const stale = textPart(message.id, { id: "stale", text: "stale" })
1370
+ const optimistic = textPart(message.id, { id: "optimistic", text: "optimistic" })
1371
+ const store = setup({ child: session("child") }).store
1372
+ store.optimistic.add({ sessionID: "child", message, parts: [stale] })
1373
+ store.apply({
1374
+ type: "message.part.delta",
1375
+ properties: { sessionID: "child", messageID: message.id, partID: stale.id, field: "text", delta: " delta" },
1376
+ })
1377
+
1378
+ store.optimistic.add({ sessionID: "child", message, parts: [optimistic] })
1379
+
1380
+ expect(store.data.part_text_accum_delta[stale.id]).toBeUndefined()
1381
+ expect(store.data.part_text_accum_delta[optimistic.id]).toBeUndefined()
1382
+ })
1383
+
1384
+ test("preserves removals during history prepend", async () => {
1385
+ const pending = deferredResponse()
1386
+ const latest = userMessage("message-2", { time: { created: 2 } })
1387
+ const older = { ...latest, id: "message-1", time: { created: 1 } }
1388
+ const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
1389
+ await store.sync("child")
1390
+ const loading = store.history.loadMore("child")
1391
+
1392
+ store.apply({ type: "message.removed", properties: { sessionID: "child", messageID: older.id } })
1393
+ pending.resolve(response([{ info: older, parts: [] }]))
1394
+ await loading
1395
+
1396
+ expect(store.data.message.child).toEqual([latest])
1397
+ })
1398
+
1399
+ test("does not scan cached messages for user roots during history prepend", async () => {
1400
+ const guard = { active: false }
1401
+ const latest = new Proxy(userMessage("message-2", { time: { created: 2 } }), {
1402
+ get(target, property, receiver) {
1403
+ if (guard.active && property === "role") throw new Error("cached role accessed")
1404
+ return Reflect.get(target, property, receiver)
1405
+ },
1406
+ })
1407
+ const older = userMessage("message-1")
1408
+ const store = createServerSession(
1409
+ messageClient(response([{ info: latest, parts: [] }], "older"), response([{ info: older, parts: [] }])),
1410
+ )
1411
+ await store.sync("child")
1412
+ guard.active = true
1413
+
1414
+ await store.history.loadMore("child")
1415
+
1416
+ expect(store.data.message.child).toEqual([older, latest])
1417
+ })
1418
+
1419
+ test("preserves loaded history during an incomplete refresh", async () => {
1420
+ const older = userMessage("message-1")
1421
+ const latest = userMessage("message-2", { time: { created: 2 } })
1422
+ const fresh = userMessage("message-3", { time: { created: 3 } })
1423
+ const store = createServerSession(
1424
+ messageClient(
1425
+ response(
1426
+ [
1427
+ { info: older, parts: [] },
1428
+ { info: latest, parts: [] },
1429
+ ],
1430
+ "older",
1431
+ ),
1432
+ response(
1433
+ [
1434
+ { info: latest, parts: [] },
1435
+ { info: fresh, parts: [] },
1436
+ ],
1437
+ "older",
1438
+ ),
1439
+ ),
1440
+ )
1441
+ await store.sync("child")
1442
+
1443
+ await store.sync("child", { force: true })
1444
+
1445
+ expect(store.data.message.child).toEqual([older, latest, fresh])
1446
+ })
1447
+
1448
+ test("drops stale recent messages omitted by an incomplete refresh", async () => {
1449
+ const third = userMessage("message-3", { time: { created: 3 } })
1450
+ const fourth = userMessage("message-4", { time: { created: 4 } })
1451
+ const stale = userMessage("message-5", { time: { created: 5 } })
1452
+ const store = createServerSession(
1453
+ messageClient(
1454
+ response(
1455
+ [
1456
+ { info: fourth, parts: [] },
1457
+ { info: stale, parts: [] },
1458
+ ],
1459
+ "older",
1460
+ ),
1461
+ response(
1462
+ [
1463
+ { info: third, parts: [] },
1464
+ { info: fourth, parts: [] },
1465
+ ],
1466
+ "older",
1467
+ ),
1468
+ ),
1469
+ )
1470
+ await store.sync("child")
1471
+
1472
+ await store.sync("child", { force: true })
1473
+
1474
+ expect(store.data.message.child).toEqual([third, fourth])
1475
+ })
1476
+
1477
+ test("uses message creation time for incomplete refresh boundaries", async () => {
1478
+ const older = userMessage("msg_z", { time: { created: 1 } })
1479
+ const boundary = userMessage("msg_m", { time: { created: 2 } })
1480
+ const stale = userMessage("msg_a", { time: { created: 3 } })
1481
+ const store = createServerSession(
1482
+ messageClient(
1483
+ response(
1484
+ [
1485
+ { info: older, parts: [] },
1486
+ { info: stale, parts: [] },
1487
+ ],
1488
+ "older",
1489
+ ),
1490
+ response([{ info: boundary, parts: [] }], "older"),
1491
+ ),
1492
+ )
1493
+ await store.sync("child")
1494
+
1495
+ await store.sync("child", { force: true })
1496
+
1497
+ expect(store.data.message.child).toEqual([boundary, older])
1498
+ })
1499
+
1500
+ test("preserves a part update for a message being loaded from history", async () => {
1501
+ const pending = deferredResponse()
1502
+ const latest = userMessage("message-2", { time: { created: 2 } })
1503
+ const older = userMessage("message-1")
1504
+ const stale = textPart(older.id, { text: "stale" })
1505
+ const live = { ...stale, text: "live" }
1506
+ const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
1507
+ await store.sync("child")
1508
+ const loading = store.history.loadMore("child")
1509
+
1510
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part: live, time: 2 } })
1511
+ pending.resolve(response([{ info: older, parts: [stale] }]))
1512
+ await loading
1513
+
1514
+ expect(store.data.part[older.id]).toEqual([live])
1515
+ })
1516
+
1517
+ test("does not clear newer orphan parts after terminal history prepend", async () => {
1518
+ const pending = deferredResponse()
1519
+ const latest = userMessage("message-2", { time: { created: 2 } })
1520
+ const older = userMessage("message-1")
1521
+ const newer = userMessage("message-3", { time: { created: 3 } })
1522
+ const part = textPart(newer.id, { text: "live" })
1523
+ const store = createServerSession(messageClient(response([{ info: latest, parts: [] }], "older"), pending.promise))
1524
+ await store.sync("child")
1525
+ const loading = store.history.loadMore("child")
1526
+
1527
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 3 } })
1528
+ pending.resolve(response([{ info: older, parts: [] }]))
1529
+ await loading
1530
+ store.apply({ type: "message.updated", properties: { sessionID: "child", info: newer } })
1531
+
1532
+ expect(store.data.part[newer.id]).toEqual([part])
1533
+ })
1534
+
1535
+ test("accepts an authoritative history part after an earlier unknown-parent update", async () => {
1536
+ const pending = deferredResponse()
1537
+ const history = deferredResponse()
1538
+ const latest = userMessage("message-2", { time: { created: 2 } })
1539
+ const older = userMessage("message-1")
1540
+ const part = textPart(older.id, { text: "live" })
1541
+ const store = createServerSession(messageClient(pending.promise, history.promise))
1542
+ const loading = store.sync("child")
1543
+
1544
+ store.apply({ type: "message.part.updated", properties: { sessionID: "child", part, time: 2 } })
1545
+ pending.resolve(response([{ info: latest, parts: [] }], "older"))
1546
+ await loading
1547
+
1548
+ expect(store.data.part[older.id]).toEqual([part])
1549
+
1550
+ const loadingHistory = store.history.loadMore("child")
1551
+ history.resolve(response([{ info: older, parts: [{ ...part, text: "stale" }] }]))
1552
+ await loadingHistory
1553
+
1554
+ expect(store.data.part[older.id]).toEqual([{ ...part, text: "stale" }])
1555
+ })
1556
+
1557
+ test("preserves an unknown-parent part removal across pages", async () => {
1558
+ const initial = deferredResponse()
1559
+ const history = deferredResponse()
1560
+ const latest = userMessage("message-2", { time: { created: 2 } })
1561
+ const older = userMessage("message-1")
1562
+ const part = textPart(older.id)
1563
+ const store = createServerSession(messageClient(initial.promise, history.promise))
1564
+ const loading = store.sync("child")
1565
+
1566
+ store.apply({
1567
+ type: "message.part.removed",
1568
+ properties: { sessionID: "child", messageID: older.id, partID: part.id },
1569
+ })
1570
+ initial.resolve(response([{ info: latest, parts: [] }], "older"))
1571
+ await loading
1572
+ const loadingHistory = store.history.loadMore("child")
1573
+ history.resolve(response([{ info: older, parts: [part] }]))
1574
+ await loadingHistory
1575
+
1576
+ expect(store.data.part[older.id]).toBeUndefined()
1577
+ })
1578
+
1579
+ test("clears orphaned parts when a refresh drops a message", async () => {
1580
+ const message = userMessage("message")
1581
+ const part = textPart(message.id, { text: "stale" })
1582
+ const store = createServerSession(messageClient(response([{ info: message, parts: [part] }]), response()))
1583
+ await store.sync("child")
1584
+ store.apply({
1585
+ type: "message.part.delta",
1586
+ properties: { sessionID: "child", messageID: message.id, partID: part.id, field: "text", delta: " delta" },
1587
+ })
1588
+ await store.sync("child", { force: true })
1589
+
1590
+ expect(store.data.message.child).toEqual([])
1591
+ expect(store.data.part[message.id]).toBeUndefined()
1592
+ expect(store.data.part_text_accum_delta[part.id]).toBeUndefined()
1593
+ })
1594
+
1595
+ test("applies events without a directory store", () => {
1596
+ const ctx = setup({})
1597
+ ctx.store.apply({ type: "session.created", properties: { sessionID: "root", info: session("root") } })
1598
+ ctx.store.apply({ type: "session.status", properties: { sessionID: "root", status: { type: "busy" } } })
1599
+
1600
+ expect(ctx.store.get("root")?.directory).toBe("/repo")
1601
+ expect(ctx.store.data.session_working("root")).toBe(true)
1602
+ expect(ctx.get).toEqual([])
1603
+ })
1604
+
1605
+ test("preserves pinned session content under server-wide cache pressure", () => {
1606
+ const ctx = setup({})
1607
+ ctx.store.pin("active")
1608
+ ctx.store.optimistic.add({
1609
+ sessionID: "active",
1610
+ message: {
1611
+ id: "message",
1612
+ sessionID: "active",
1613
+ role: "assistant",
1614
+ time: { created: 1 },
1615
+ parentID: "parent",
1616
+ modelID: "model",
1617
+ providerID: "provider",
1618
+ mode: "build",
1619
+ agent: "agent",
1620
+ path: { cwd: "/repo", root: "/repo" },
1621
+ cost: 0,
1622
+ tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
1623
+ },
1624
+ parts: [],
1625
+ })
1626
+
1627
+ for (let index = 0; index < 50; index++) {
1628
+ ctx.store.remember(session(`session-${index}`))
1629
+ ctx.store.apply({
1630
+ type: "session.status",
1631
+ properties: { sessionID: `session-${index}`, status: { type: "idle" } },
1632
+ })
1633
+ }
1634
+
1635
+ expect(ctx.store.data.message.active?.map((message) => message.id)).toEqual(["message"])
1636
+ expect(ctx.store.data.session_status["session-0"]).toBeUndefined()
1637
+ })
1638
+ })