@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.
- package/AGENTS.md +35 -0
- package/README.md +50 -0
- package/V1_API_MIGRATION.md +220 -0
- package/bunfig.toml +3 -0
- package/create-effect-simplification-spec.md +515 -0
- package/e2e/performance/AGENTS.md +13 -0
- package/e2e/performance/README.md +79 -0
- package/e2e/performance/benchmark.ts +144 -0
- package/e2e/performance/chrome-trace.ts +95 -0
- package/e2e/performance/playwright.config.ts +20 -0
- package/e2e/performance/playwright.uncapped.config.ts +13 -0
- package/e2e/performance/timeline/first-navigation-benchmark.spec.ts +87 -0
- package/e2e/performance/timeline/first-navigation-metrics.ts +32 -0
- package/e2e/performance/timeline/first-navigation-probe.ts +86 -0
- package/e2e/performance/timeline/home-tab-navigation-benchmark.spec.ts +114 -0
- package/e2e/performance/timeline/navigation-milestones.ts +128 -0
- package/e2e/performance/timeline/review-pane-scaling-benchmark.spec.ts +312 -0
- package/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +172 -0
- package/e2e/performance/timeline/session-tab-flash.spec.ts +67 -0
- package/e2e/performance/timeline/session-tab-repaint-probe.ts +251 -0
- package/e2e/performance/timeline/session-tab-switch-benchmark.spec.ts +144 -0
- package/e2e/performance/timeline/session-tab-switch-metrics.ts +59 -0
- package/e2e/performance/timeline/session-tab-switch-probe.ts +226 -0
- package/e2e/performance/timeline/session-timeline-benchmark.fixture.ts +504 -0
- package/e2e/performance/timeline/session-timeline-benchmark.spec.ts +306 -0
- package/e2e/performance/timeline/session-timeline-profile.ts +40 -0
- package/e2e/performance/timeline/session-timeline-stream-probe.ts +547 -0
- package/e2e/performance/timeline/session-timeline-stress.fixture.ts +369 -0
- package/e2e/performance/timeline/timeline-test-helpers.ts +134 -0
- package/e2e/performance/timeline-stability/README.md +61 -0
- package/e2e/performance/timeline-stability/adverse.spec.ts +250 -0
- package/e2e/performance/timeline-stability/context-matrix.spec.ts +192 -0
- package/e2e/performance/timeline-stability/environment-matrix.spec.ts +113 -0
- package/e2e/performance/timeline-stability/file-matrix.spec.ts +121 -0
- package/e2e/performance/timeline-stability/file-mutation.spec.ts +113 -0
- package/e2e/performance/timeline-stability/fixture.test.ts +68 -0
- package/e2e/performance/timeline-stability/fixture.ts +566 -0
- package/e2e/performance/timeline-stability/interaction.spec.ts +242 -0
- package/e2e/performance/timeline-stability/lifecycle.spec.ts +157 -0
- package/e2e/performance/timeline-stability/oracle-browser.spec.ts +75 -0
- package/e2e/performance/timeline-stability/playwright.config.ts +17 -0
- package/e2e/performance/timeline-stability/scroll-interaction.spec.ts +353 -0
- package/e2e/performance/timeline-stability/shell-matrix.spec.ts +255 -0
- package/e2e/performance/timeline-stability/tool-mutation.spec.ts +106 -0
- package/e2e/performance/timeline-stability/tools.spec.ts +198 -0
- package/e2e/performance/timeline-stability/transition-matrix.spec.ts +272 -0
- package/e2e/performance/unit/chrome-trace-write.test.ts +15 -0
- package/e2e/performance/unit/first-navigation-metrics.test.ts +32 -0
- package/e2e/performance/unit/mock-server.test.ts +46 -0
- package/e2e/performance/unit/navigation-milestones.test.ts +34 -0
- package/e2e/performance/unit/session-tab-repaint-probe.test.ts +42 -0
- package/e2e/performance/unit/session-tab-switch-metrics.test.ts +86 -0
- package/e2e/performance/unit/session-tab-switch-probe.test.ts +55 -0
- package/e2e/performance/unit/session-timeline-stream-probe.test.ts +14 -0
- package/e2e/performance/unit/session-timeline-visual-tracking.test.ts +16 -0
- package/e2e/performance/unit/timeline-test-helpers.test.ts +10 -0
- package/e2e/performance/unit/visual-stability.test.ts +392 -0
- package/e2e/regression/cross-server-tab-close.spec.ts +153 -0
- package/e2e/regression/file-browser-sidebar-tab-switch.spec.ts +150 -0
- package/e2e/regression/legacy-new-session.spec.ts +41 -0
- package/e2e/regression/new-session-panel-corner.spec.ts +83 -0
- package/e2e/regression/prompt-input-v2-command-draft.spec.ts +50 -0
- package/e2e/regression/prompt-thinking-level.spec.ts +84 -0
- package/e2e/regression/remote-session-settings.spec.ts +302 -0
- package/e2e/regression/remote-tab-busy.spec.ts +131 -0
- package/e2e/regression/review-image-flash.spec.ts +202 -0
- package/e2e/regression/review-line-comment.spec.ts +158 -0
- package/e2e/regression/review-open-file.spec.ts +163 -0
- package/e2e/regression/review-state-persistence.spec.ts +155 -0
- package/e2e/regression/review-tab-switch.spec.ts +147 -0
- package/e2e/regression/review-terminal-stacked.spec.ts +308 -0
- package/e2e/regression/session-list-path-loading.spec.ts +41 -0
- package/e2e/regression/session-request-docks.spec.ts +221 -0
- package/e2e/regression/session-timeline-accessibility.spec.ts +22 -0
- package/e2e/regression/session-timeline-collapse-state.spec.ts +439 -0
- package/e2e/regression/session-timeline-context-resize.spec.ts +374 -0
- package/e2e/regression/session-timeline-context-state.spec.ts +31 -0
- package/e2e/regression/session-timeline-file-projection.spec.ts +52 -0
- package/e2e/regression/session-timeline-file-state.spec.ts +94 -0
- package/e2e/regression/session-timeline-history-root.spec.ts +242 -0
- package/e2e/regression/session-timeline-lifecycle-state.spec.ts +111 -0
- package/e2e/regression/session-timeline-locale-projection.spec.ts +25 -0
- package/e2e/regression/session-timeline-projection.spec.ts +287 -0
- package/e2e/regression/session-timeline-reasoning-projection.spec.ts +93 -0
- package/e2e/regression/session-timeline-reducer-projection.spec.ts +43 -0
- package/e2e/regression/session-timeline-shell-outline.spec.ts +228 -0
- package/e2e/regression/session-timeline-tool-projection.spec.ts +99 -0
- package/e2e/regression/session-timeline-tool-state.spec.ts +77 -0
- package/e2e/regression/session-timeline-transport.spec.ts +116 -0
- package/e2e/regression/session-todo-dock-navigation.spec.ts +190 -0
- package/e2e/regression/subagent-child-navigation.spec.ts +203 -0
- package/e2e/regression/tab-navigate-mousedown.spec.ts +127 -0
- package/e2e/regression/terminal-composer-focus.spec.ts +227 -0
- package/e2e/regression/terminal-hidden.spec.ts +117 -0
- package/e2e/regression/terminal-tab-switch.spec.ts +165 -0
- package/e2e/reproduction/timeline-suspense/index.html +34 -0
- package/e2e/reproduction/timeline-suspense/main.tsx +315 -0
- package/e2e/reproduction/timeline-suspense/playwright.config.ts +34 -0
- package/e2e/reproduction/timeline-suspense/timeline-suspense.repro.ts +179 -0
- package/e2e/reproduction/timeline-suspense/vite.config.ts +7 -0
- package/e2e/smoke/session-timeline.fixture.ts +315 -0
- package/e2e/smoke/session-timeline.spec.ts +740 -0
- package/e2e/tsconfig.json +20 -0
- package/e2e/utils/errors.ts +18 -0
- package/e2e/utils/mock-server.ts +443 -0
- package/e2e/utils/sse-transport.ts +312 -0
- package/e2e/utils/visual-stability/analyzer.ts +209 -0
- package/e2e/utils/visual-stability/capture.ts +51 -0
- package/e2e/utils/visual-stability/index.ts +8 -0
- package/e2e/utils/visual-stability/invariant.ts +112 -0
- package/e2e/utils/visual-stability/model.ts +47 -0
- package/e2e/utils/visual-stability/probe.ts +226 -0
- package/e2e/utils/visual-stability/regions.ts +18 -0
- package/e2e/utils/visual-stability/reporter.ts +63 -0
- package/e2e/utils/visual-stability/scenario.ts +20 -0
- package/e2e/utils/visual-stability.ts +54 -0
- package/e2e/utils/waits.ts +11 -0
- package/happydom.ts +75 -0
- package/index.html +28 -0
- package/package.json +96 -0
- package/playwright.config.ts +45 -0
- package/public/_headers +17 -0
- package/public/apple-touch-icon-v3.png +1 -0
- package/public/apple-touch-icon.png +1 -0
- package/public/assets/Inter.ttf +0 -0
- package/public/assets/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- package/public/favicon-96x96-v3.png +1 -0
- package/public/favicon-96x96.png +1 -0
- package/public/favicon-v3.ico +1 -0
- package/public/favicon-v3.svg +1 -0
- package/public/favicon.ico +1 -0
- package/public/favicon.svg +1 -0
- package/public/oc-theme-preload.js +40 -0
- package/public/site.webmanifest +1 -0
- package/public/social-share-zen.png +1 -0
- package/public/social-share.png +1 -0
- package/public/web-app-manifest-192x192.png +1 -0
- package/public/web-app-manifest-512x512.png +1 -0
- package/src/addons/serialize.test.ts +327 -0
- package/src/addons/serialize.ts +642 -0
- package/src/app.tsx +659 -0
- package/src/assets/help/home.png +0 -0
- package/src/assets/help/introducing-tabs.mp4 +0 -0
- package/src/assets/help/placeholder.png +0 -0
- package/src/assets/help/tabs.png +0 -0
- package/src/components/command-palette.ts +281 -0
- package/src/components/command-tooltip-keybind.test.ts +22 -0
- package/src/components/command-tooltip-keybind.ts +11 -0
- package/src/components/debug-bar.tsx +550 -0
- package/src/components/dialog-command-palette-v2.css +220 -0
- package/src/components/dialog-command-palette-v2.tsx +342 -0
- package/src/components/dialog-connect-provider.stories.tsx +73 -0
- package/src/components/dialog-connect-provider.tsx +1178 -0
- package/src/components/dialog-custom-provider-form.ts +158 -0
- package/src/components/dialog-custom-provider.test.ts +80 -0
- package/src/components/dialog-custom-provider.tsx +330 -0
- package/src/components/dialog-edit-project-v2.tsx +156 -0
- package/src/components/dialog-edit-project.tsx +170 -0
- package/src/components/dialog-fork.tsx +104 -0
- package/src/components/dialog-manage-models.tsx +265 -0
- package/src/components/dialog-release-notes.tsx +144 -0
- package/src/components/dialog-select-directory-v2.css +107 -0
- package/src/components/dialog-select-directory-v2.tsx +378 -0
- package/src/components/dialog-select-directory.tsx +191 -0
- package/src/components/dialog-select-file.tsx +189 -0
- package/src/components/dialog-select-mcp.tsx +92 -0
- package/src/components/dialog-select-model-search.test.ts +19 -0
- package/src/components/dialog-select-model-search.ts +18 -0
- package/src/components/dialog-select-model-unpaid-v2.stories.tsx +55 -0
- package/src/components/dialog-select-model-unpaid-v2.tsx +175 -0
- package/src/components/dialog-select-model-unpaid.tsx +147 -0
- package/src/components/dialog-select-model.tsx +549 -0
- package/src/components/dialog-select-server.tsx +722 -0
- package/src/components/dialog-settings.tsx +94 -0
- package/src/components/dialog-usage-exceeded.tsx +44 -0
- package/src/components/directory-picker-domain.test.ts +248 -0
- package/src/components/directory-picker-domain.ts +407 -0
- package/src/components/directory-picker-policy.ts +7 -0
- package/src/components/directory-picker.test.ts +21 -0
- package/src/components/directory-picker.tsx +45 -0
- package/src/components/edit-project.ts +122 -0
- package/src/components/file-tree-v2-model.test.ts +74 -0
- package/src/components/file-tree-v2-model.ts +107 -0
- package/src/components/file-tree-v2.tsx +299 -0
- package/src/components/file-tree.test.ts +80 -0
- package/src/components/file-tree.tsx +509 -0
- package/src/components/help-button.tsx +148 -0
- package/src/components/link.tsx +26 -0
- package/src/components/model-tooltip.tsx +124 -0
- package/src/components/pierre-tree.test.ts +23 -0
- package/src/components/prompt-input/attachments.test.ts +108 -0
- package/src/components/prompt-input/attachments.ts +228 -0
- package/src/components/prompt-input/build-request-parts.test.ts +396 -0
- package/src/components/prompt-input/build-request-parts.ts +213 -0
- package/src/components/prompt-input/context-items.tsx +98 -0
- package/src/components/prompt-input/contracts.ts +57 -0
- package/src/components/prompt-input/drag-overlay.tsx +25 -0
- package/src/components/prompt-input/editor-dom.test.ts +99 -0
- package/src/components/prompt-input/editor-dom.ts +148 -0
- package/src/components/prompt-input/files.ts +98 -0
- package/src/components/prompt-input/history-store.ts +47 -0
- package/src/components/prompt-input/history.test.ts +153 -0
- package/src/components/prompt-input/history.ts +256 -0
- package/src/components/prompt-input/image-attachments.css +43 -0
- package/src/components/prompt-input/image-attachments.tsx +168 -0
- package/src/components/prompt-input/paste.ts +24 -0
- package/src/components/prompt-input/placeholder.test.ts +48 -0
- package/src/components/prompt-input/placeholder.ts +20 -0
- package/src/components/prompt-input/slash-popover.tsx +376 -0
- package/src/components/prompt-input/submission-state.ts +33 -0
- package/src/components/prompt-input/submit.test.ts +598 -0
- package/src/components/prompt-input/submit.ts +634 -0
- package/src/components/prompt-input/transient-state.ts +46 -0
- package/src/components/prompt-input-v2.tsx +592 -0
- package/src/components/prompt-input.stories.tsx +223 -0
- package/src/components/prompt-input.tsx +1790 -0
- package/src/components/prompt-project-selector.tsx +593 -0
- package/src/components/prompt-workspace-selector.tsx +128 -0
- package/src/components/server/server-row-menu.tsx +96 -0
- package/src/components/server/server-row.tsx +127 -0
- package/src/components/session/index.ts +7 -0
- package/src/components/session/open-in-app-v2.tsx +98 -0
- package/src/components/session/open-in-app.tsx +229 -0
- package/src/components/session/session-context-breakdown.test.ts +61 -0
- package/src/components/session/session-context-breakdown.ts +132 -0
- package/src/components/session/session-context-format.ts +20 -0
- package/src/components/session/session-context-metrics.test.ts +99 -0
- package/src/components/session/session-context-metrics.ts +65 -0
- package/src/components/session/session-context-tab.tsx +343 -0
- package/src/components/session/session-header.tsx +568 -0
- package/src/components/session/session-new-design-view.tsx +16 -0
- package/src/components/session/session-new-view.tsx +91 -0
- package/src/components/session/session-sortable-tab-v2.tsx +74 -0
- package/src/components/session/session-sortable-tab.tsx +78 -0
- package/src/components/session/session-sortable-terminal-tab-v2.tsx +284 -0
- package/src/components/session/session-sortable-terminal-tab.tsx +193 -0
- package/src/components/session-context-usage.tsx +169 -0
- package/src/components/settings-dialog.tsx +43 -0
- package/src/components/settings-general.tsx +794 -0
- package/src/components/settings-keybinds.tsx +553 -0
- package/src/components/settings-list.tsx +5 -0
- package/src/components/settings-models.tsx +149 -0
- package/src/components/settings-providers.tsx +264 -0
- package/src/components/settings-server-picker.tsx +106 -0
- package/src/components/settings-servers.tsx +33 -0
- package/src/components/settings-v2/dialog-server-v2.tsx +134 -0
- package/src/components/settings-v2/dialog-settings-v2.tsx +98 -0
- package/src/components/settings-v2/general.tsx +722 -0
- package/src/components/settings-v2/index.tsx +1 -0
- package/src/components/settings-v2/interface-transition.stories.tsx +76 -0
- package/src/components/settings-v2/interface-transition.tsx +54 -0
- package/src/components/settings-v2/models.tsx +138 -0
- package/src/components/settings-v2/parts/list.tsx +6 -0
- package/src/components/settings-v2/parts/row.tsx +20 -0
- package/src/components/settings-v2/providers.tsx +264 -0
- package/src/components/settings-v2/servers.tsx +139 -0
- package/src/components/settings-v2/settings-v2.css +675 -0
- package/src/components/status-popover-body.tsx +514 -0
- package/src/components/status-popover-indicator.test.ts +36 -0
- package/src/components/status-popover-indicator.ts +20 -0
- package/src/components/status-popover.tsx +190 -0
- package/src/components/terminal.tsx +759 -0
- package/src/components/titlebar-history.test.ts +63 -0
- package/src/components/titlebar-history.ts +57 -0
- package/src/components/titlebar-session-events.test.ts +32 -0
- package/src/components/titlebar-session-events.ts +35 -0
- package/src/components/titlebar-tab-gesture.test.ts +33 -0
- package/src/components/titlebar-tab-gesture.ts +17 -0
- package/src/components/titlebar-tab-nav.css +100 -0
- package/src/components/titlebar-tab-nav.tsx +429 -0
- package/src/components/titlebar-tab-popover.css +127 -0
- package/src/components/titlebar-tab-popover.tsx +92 -0
- package/src/components/titlebar-tab-strip.tsx +333 -0
- package/src/components/titlebar.css +77 -0
- package/src/components/titlebar.tsx +776 -0
- package/src/components/ui/drawer.tsx +119 -0
- package/src/components/updater-action.test.ts +26 -0
- package/src/components/updater-action.ts +51 -0
- package/src/components/virtual-scroll-element.test.ts +18 -0
- package/src/components/virtual-scroll-element.ts +4 -0
- package/src/components/windows-app-menu.tsx +131 -0
- package/src/constants/file-picker.ts +89 -0
- package/src/context/closed-tabs.ts +40 -0
- package/src/context/command-keybind.test.ts +69 -0
- package/src/context/command.test.ts +55 -0
- package/src/context/command.tsx +467 -0
- package/src/context/comments.test.ts +188 -0
- package/src/context/comments.tsx +261 -0
- package/src/context/directory-sync.ts +155 -0
- package/src/context/file/content-cache.ts +88 -0
- package/src/context/file/path.test.ts +360 -0
- package/src/context/file/path.ts +151 -0
- package/src/context/file/tree-store.ts +174 -0
- package/src/context/file/types.ts +41 -0
- package/src/context/file/view-cache.ts +147 -0
- package/src/context/file/watcher.test.ts +149 -0
- package/src/context/file/watcher.ts +53 -0
- package/src/context/file-content-eviction-accounting.test.ts +65 -0
- package/src/context/file.tsx +303 -0
- package/src/context/global-sync/bootstrap.test.ts +231 -0
- package/src/context/global-sync/bootstrap.ts +536 -0
- package/src/context/global-sync/child-store.test.ts +276 -0
- package/src/context/global-sync/child-store.ts +396 -0
- package/src/context/global-sync/event-reducer.test.ts +616 -0
- package/src/context/global-sync/event-reducer.ts +478 -0
- package/src/context/global-sync/eviction.ts +28 -0
- package/src/context/global-sync/home-session-index.test.ts +154 -0
- package/src/context/global-sync/home-session-index.ts +174 -0
- package/src/context/global-sync/mcp.test.ts +54 -0
- package/src/context/global-sync/mcp.ts +19 -0
- package/src/context/global-sync/queue.test.ts +46 -0
- package/src/context/global-sync/queue.ts +87 -0
- package/src/context/global-sync/session-cache.test.ts +104 -0
- package/src/context/global-sync/session-cache.ts +62 -0
- package/src/context/global-sync/session-load.ts +33 -0
- package/src/context/global-sync/session-trim.test.ts +59 -0
- package/src/context/global-sync/session-trim.ts +57 -0
- package/src/context/global-sync/types.ts +135 -0
- package/src/context/global-sync/utils.test.ts +133 -0
- package/src/context/global-sync/utils.ts +171 -0
- package/src/context/global.tsx +161 -0
- package/src/context/highlights.tsx +233 -0
- package/src/context/language.tsx +243 -0
- package/src/context/layout-helpers.ts +38 -0
- package/src/context/layout-scroll.test.ts +64 -0
- package/src/context/layout-scroll.ts +126 -0
- package/src/context/layout-tabs.test.ts +82 -0
- package/src/context/layout-tabs.ts +103 -0
- package/src/context/layout.test.ts +69 -0
- package/src/context/layout.tsx +1075 -0
- package/src/context/local-agent.test.ts +29 -0
- package/src/context/local-agent.ts +7 -0
- package/src/context/local.tsx +417 -0
- package/src/context/mcp.ts +19 -0
- package/src/context/model-variant.test.ts +86 -0
- package/src/context/model-variant.ts +52 -0
- package/src/context/models.tsx +173 -0
- package/src/context/notification.tsx +478 -0
- package/src/context/permission-auto-respond.test.ts +125 -0
- package/src/context/permission-auto-respond.ts +60 -0
- package/src/context/permission.tsx +484 -0
- package/src/context/platform.tsx +142 -0
- package/src/context/prompt-state.test.ts +29 -0
- package/src/context/prompt-state.ts +266 -0
- package/src/context/prompt.tsx +170 -0
- package/src/context/sdk.tsx +17 -0
- package/src/context/server-sdk.test.ts +196 -0
- package/src/context/server-sdk.tsx +444 -0
- package/src/context/server-session-v2-reducer.test.ts +156 -0
- package/src/context/server-session-v2-reducer.ts +502 -0
- package/src/context/server-session.test.ts +1638 -0
- package/src/context/server-session.ts +1425 -0
- package/src/context/server-sync.test.ts +231 -0
- package/src/context/server-sync.tsx +753 -0
- package/src/context/server.test.ts +245 -0
- package/src/context/server.tsx +360 -0
- package/src/context/settings.test.ts +79 -0
- package/src/context/settings.tsx +531 -0
- package/src/context/sync-optimistic.test.ts +123 -0
- package/src/context/sync.tsx +119 -0
- package/src/context/tab-memory.ts +36 -0
- package/src/context/tabs.test.ts +107 -0
- package/src/context/tabs.tsx +387 -0
- package/src/context/terminal-title.ts +24 -0
- package/src/context/terminal.test.ts +91 -0
- package/src/context/terminal.tsx +546 -0
- package/src/custom-elements.d.ts +1 -0
- package/src/desktop-menu.test.ts +13 -0
- package/src/desktop-menu.ts +223 -0
- package/src/entry.tsx +182 -0
- package/src/env.d.ts +31 -0
- package/src/hooks/provider-catalog.test.ts +59 -0
- package/src/hooks/provider-catalog.ts +27 -0
- package/src/hooks/use-providers.ts +71 -0
- package/src/i18n/ar.ts +1008 -0
- package/src/i18n/br.ts +1024 -0
- package/src/i18n/bs.ts +1100 -0
- package/src/i18n/da.ts +1092 -0
- package/src/i18n/de.ts +1037 -0
- package/src/i18n/en.ts +1102 -0
- package/src/i18n/es.ts +1108 -0
- package/src/i18n/fr.ts +1037 -0
- package/src/i18n/ja.ts +1016 -0
- package/src/i18n/ko.ts +1015 -0
- package/src/i18n/no.ts +1111 -0
- package/src/i18n/parity.test.ts +118 -0
- package/src/i18n/pl.ts +1024 -0
- package/src/i18n/ru.ts +1103 -0
- package/src/i18n/th.ts +1087 -0
- package/src/i18n/tr.ts +1106 -0
- package/src/i18n/uk.ts +1105 -0
- package/src/i18n/zh.ts +1078 -0
- package/src/i18n/zht.ts +1074 -0
- package/src/index.css +330 -0
- package/src/index.ts +30 -0
- package/src/pages/directory-layout.tsx +122 -0
- package/src/pages/error-description.test.ts +17 -0
- package/src/pages/error-description.ts +11 -0
- package/src/pages/error.tsx +372 -0
- package/src/pages/home/home-controller.ts +108 -0
- package/src/pages/home/home-projects-controller.tsx +128 -0
- package/src/pages/home/home-projects-view.tsx +615 -0
- package/src/pages/home/home-projects.tsx +40 -0
- package/src/pages/home/home-scroll-controller.ts +145 -0
- package/src/pages/home/home-session-search-controller.ts +114 -0
- package/src/pages/home/home-sessions-controller.tsx +314 -0
- package/src/pages/home/home-sessions-view.tsx +550 -0
- package/src/pages/home/home-sessions.tsx +48 -0
- package/src/pages/home/legacy-home.tsx +142 -0
- package/src/pages/home-session-archive.test.ts +51 -0
- package/src/pages/home-session-archive.ts +27 -0
- package/src/pages/home-session-open.test.ts +31 -0
- package/src/pages/home-session-open.ts +14 -0
- package/src/pages/home.tsx +50 -0
- package/src/pages/layout/deep-links.ts +50 -0
- package/src/pages/layout/helpers.test.ts +321 -0
- package/src/pages/layout/helpers.ts +150 -0
- package/src/pages/layout/inline-editor.tsx +126 -0
- package/src/pages/layout/project-avatar-state.ts +49 -0
- package/src/pages/layout/session-tab-avatar.tsx +60 -0
- package/src/pages/layout/sidebar-items.tsx +335 -0
- package/src/pages/layout/sidebar-project.tsx +377 -0
- package/src/pages/layout/sidebar-shell.tsx +125 -0
- package/src/pages/layout/sidebar-workspace.tsx +488 -0
- package/src/pages/layout-new.tsx +53 -0
- package/src/pages/layout.tsx +2441 -0
- package/src/pages/new-session.tsx +282 -0
- package/src/pages/session/composer/index.ts +4 -0
- package/src/pages/session/composer/prompt-model-selection.ts +133 -0
- package/src/pages/session/composer/session-composer-controls.ts +129 -0
- package/src/pages/session/composer/session-composer-region-controller.ts +145 -0
- package/src/pages/session/composer/session-composer-region.tsx +168 -0
- package/src/pages/session/composer/session-composer-state.test.ts +138 -0
- package/src/pages/session/composer/session-composer-state.ts +204 -0
- package/src/pages/session/composer/session-followup-dock.tsx +109 -0
- package/src/pages/session/composer/session-permission-dock.tsx +74 -0
- package/src/pages/session/composer/session-question-dock.tsx +640 -0
- package/src/pages/session/composer/session-request-tree.ts +52 -0
- package/src/pages/session/composer/session-revert-dock.stories.tsx +124 -0
- package/src/pages/session/composer/session-revert-dock.tsx +188 -0
- package/src/pages/session/composer/session-todo-dock.tsx +277 -0
- package/src/pages/session/composer/todo-panel-motion.stories.tsx +622 -0
- package/src/pages/session/file-tab-scroll.test.ts +40 -0
- package/src/pages/session/file-tab-scroll.ts +67 -0
- package/src/pages/session/file-tabs.tsx +800 -0
- package/src/pages/session/handoff.ts +36 -0
- package/src/pages/session/helpers.test.ts +214 -0
- package/src/pages/session/helpers.ts +209 -0
- package/src/pages/session/message-gesture.test.ts +62 -0
- package/src/pages/session/message-gesture.ts +21 -0
- package/src/pages/session/message-id-from-hash.ts +6 -0
- package/src/pages/session/new-session-layout.ts +2 -0
- package/src/pages/session/review-tab.tsx +172 -0
- package/src/pages/session/session-layout.ts +30 -0
- package/src/pages/session/session-lineage.ts +69 -0
- package/src/pages/session/session-model-helpers.test.ts +151 -0
- package/src/pages/session/session-model-helpers.ts +63 -0
- package/src/pages/session/session-ownership.ts +37 -0
- package/src/pages/session/session-panel-layout.test.ts +19 -0
- package/src/pages/session/session-panel-layout.ts +6 -0
- package/src/pages/session/session-panel-width.test.ts +52 -0
- package/src/pages/session/session-panel-width.ts +19 -0
- package/src/pages/session/session-side-panel.tsx +865 -0
- package/src/pages/session/terminal-label.ts +16 -0
- package/src/pages/session/terminal-panel-v2.tsx +352 -0
- package/src/pages/session/terminal-panel.test.ts +25 -0
- package/src/pages/session/terminal-panel.tsx +343 -0
- package/src/pages/session/timeline/measure.test.ts +30 -0
- package/src/pages/session/timeline/measure.ts +5 -0
- package/src/pages/session/timeline/message-timeline.tsx +1864 -0
- package/src/pages/session/timeline/model.test.ts +95 -0
- package/src/pages/session/timeline/model.ts +128 -0
- package/src/pages/session/timeline/observe-element-offset.test.ts +199 -0
- package/src/pages/session/timeline/observe-element-offset.ts +73 -0
- package/src/pages/session/timeline/projection.test.ts +96 -0
- package/src/pages/session/timeline/projection.ts +81 -0
- package/src/pages/session/timeline/row-reconciliation.ts +56 -0
- package/src/pages/session/timeline/rows-current.test.ts +170 -0
- package/src/pages/session/timeline/rows.ts +344 -0
- package/src/pages/session/timeline/summary-diffs.test.ts +42 -0
- package/src/pages/session/timeline/summary-diffs.ts +20 -0
- package/src/pages/session/timeline/timeline-row.ts +80 -0
- package/src/pages/session/timeline/virtual-items.ts +3 -0
- package/src/pages/session/usage-exceeded-dialogs.tsx +104 -0
- package/src/pages/session/use-composer-commands.tsx +83 -0
- package/src/pages/session/use-session-commands.tsx +606 -0
- package/src/pages/session/use-session-hash-scroll.test.ts +16 -0
- package/src/pages/session/use-session-hash-scroll.ts +202 -0
- package/src/pages/session/v2/review-diff-kinds.test.ts +70 -0
- package/src/pages/session/v2/review-diff-kinds.ts +62 -0
- package/src/pages/session/v2/review-panel-v2-state.ts +41 -0
- package/src/pages/session/v2/review-panel-v2.tsx +255 -0
- package/src/pages/session/v2/session-file-browser-tab.tsx +181 -0
- package/src/pages/session/v2/session-file-list-v2.tsx +166 -0
- package/src/pages/session.tsx +2385 -0
- package/src/sst-env.d.ts +12 -0
- package/src/theme-preload.test.ts +46 -0
- package/src/updater.ts +17 -0
- package/src/utils/agent.ts +44 -0
- package/src/utils/aim.ts +138 -0
- package/src/utils/base64.ts +10 -0
- package/src/utils/comment-note.ts +88 -0
- package/src/utils/diffs.test.ts +75 -0
- package/src/utils/diffs.ts +50 -0
- package/src/utils/file-manager.ts +24 -0
- package/src/utils/id.ts +93 -0
- package/src/utils/notification-click.test.ts +27 -0
- package/src/utils/notification-click.ts +13 -0
- package/src/utils/path-key.ts +24 -0
- package/src/utils/persist.test.ts +211 -0
- package/src/utils/persist.ts +661 -0
- package/src/utils/prompt.test.ts +44 -0
- package/src/utils/prompt.ts +203 -0
- package/src/utils/refcount.test.ts +49 -0
- package/src/utils/refcount.ts +32 -0
- package/src/utils/runtime-adapters.test.ts +64 -0
- package/src/utils/runtime-adapters.ts +39 -0
- package/src/utils/same.ts +6 -0
- package/src/utils/scoped-cache.test.ts +69 -0
- package/src/utils/scoped-cache.ts +104 -0
- package/src/utils/search-keydown.ts +116 -0
- package/src/utils/server-compat.test.ts +193 -0
- package/src/utils/server-compat.ts +512 -0
- package/src/utils/server-errors.test.ts +175 -0
- package/src/utils/server-errors.ts +109 -0
- package/src/utils/server-health.test.ts +178 -0
- package/src/utils/server-health.ts +172 -0
- package/src/utils/server-protocol.test.ts +40 -0
- package/src/utils/server-protocol.ts +35 -0
- package/src/utils/server-scope.test.ts +61 -0
- package/src/utils/server-scope.ts +73 -0
- package/src/utils/server.test.ts +23 -0
- package/src/utils/server.ts +62 -0
- package/src/utils/session-message.test.ts +214 -0
- package/src/utils/session-message.ts +355 -0
- package/src/utils/session-route.test.ts +71 -0
- package/src/utils/session-route.ts +39 -0
- package/src/utils/session-title.ts +7 -0
- package/src/utils/session.test.ts +94 -0
- package/src/utils/session.ts +37 -0
- package/src/utils/solid-dnd.tsx +49 -0
- package/src/utils/sound.ts +102 -0
- package/src/utils/terminal-websocket-url.test.ts +80 -0
- package/src/utils/terminal-websocket-url.ts +35 -0
- package/src/utils/terminal-writer.test.ts +64 -0
- package/src/utils/terminal-writer.ts +65 -0
- package/src/utils/time.ts +22 -0
- package/src/utils/toast.tsx +34 -0
- package/src/utils/uuid.test.ts +78 -0
- package/src/utils/uuid.ts +12 -0
- package/src/utils/worktree.test.ts +58 -0
- package/src/utils/worktree.ts +76 -0
- package/src/wsl/add-server-probes.ts +57 -0
- package/src/wsl/context.tsx +36 -0
- package/src/wsl/dialog-add-server.tsx +463 -0
- package/src/wsl/dialog-add-wsl-server.css +392 -0
- package/src/wsl/settings-model.test.ts +231 -0
- package/src/wsl/settings-model.ts +341 -0
- package/src/wsl/settings.tsx +173 -0
- package/src/wsl/types.ts +85 -0
- package/sst-env.d.ts +10 -0
- package/test-browser/command-palette.test.ts +78 -0
- package/test-browser/motion-spring.test.ts +20 -0
- package/test-browser/prompt-attachments.test.ts +90 -0
- package/test-browser/prompt-persistence.test.ts +71 -0
- package/test-browser/prompt-scope.test.ts +14 -0
- package/test-browser/prompt-submission-state.test.ts +73 -0
- package/test-browser/prompt-transient-state.test.ts +38 -0
- package/test-browser/review-panel-v2-state.test.ts +65 -0
- package/test-browser/session-lineage.test.ts +231 -0
- package/test-browser/session-ownership.test.ts +45 -0
- package/test-browser/solid-virtual.test.ts +112 -0
- package/test-browser/toast-owner.test.ts +26 -0
- package/tsconfig.json +26 -0
- package/vendor/opencode-ai-client-1.17.13.tgz +0 -0
- package/vite.config.ts +33 -0
- 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
|
+
})
|